From 1e88d55b432fa25c449602ac7e114c8782dbecf5 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 15:58:16 +0100 Subject: [PATCH 001/940] add empty files --- shopfloor/__init__.py | 0 shopfloor/__manifest__.py | 0 shopfloor/models/__init__.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 shopfloor/__init__.py create mode 100644 shopfloor/__manifest__.py create mode 100644 shopfloor/models/__init__.py diff --git a/shopfloor/__init__.py b/shopfloor/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 From 7cf6c6ca0f50df0de0b06f6f6c8efa5bcace1a73 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 16:08:05 +0100 Subject: [PATCH 002/940] add some boilerplate --- shopfloor/__init__.py | 1 + shopfloor/__manifest__.py | 20 ++++++++++++++++++++ shopfloor/readme/CONFIGURE.rst | 1 + shopfloor/readme/CONTRIBUTORS.rst | 3 +++ shopfloor/readme/DESCRIPTION.rst | 1 + shopfloor/readme/HISTORY.rst | 4 ++++ shopfloor/readme/ROADMAP.rst | 1 + shopfloor/readme/USAGE.rst | 5 +++++ 8 files changed, 36 insertions(+) create mode 100644 shopfloor/readme/CONFIGURE.rst create mode 100644 shopfloor/readme/CONTRIBUTORS.rst create mode 100644 shopfloor/readme/DESCRIPTION.rst create mode 100644 shopfloor/readme/HISTORY.rst create mode 100644 shopfloor/readme/ROADMAP.rst create mode 100644 shopfloor/readme/USAGE.rst diff --git a/shopfloor/__init__.py b/shopfloor/__init__.py index e69de29bb2..0650744f6b 100644 --- a/shopfloor/__init__.py +++ b/shopfloor/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index e69de29bb2..5cc45996a6 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# © 2020 Camptocamp, Akretion, BCIM +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Shopfloor", + "summary": "manage warehouse operations with barcode scanners", + "version": "13.0.1.0.0", + "category": "Inventory", + "website": "https://odoo-community.org", + "author": "Akretion, BCIM, Camptocamp, Odoo Community Association (OCA)", + "licence": "AGPL-3", + "application": True, + "depends": [ + "stock", + "base_rest", + ], + "data": [ + ], +} diff --git a/shopfloor/readme/CONFIGURE.rst b/shopfloor/readme/CONFIGURE.rst new file mode 100644 index 0000000000..8442c179fd --- /dev/null +++ b/shopfloor/readme/CONFIGURE.rst @@ -0,0 +1 @@ +writeme diff --git a/shopfloor/readme/CONTRIBUTORS.rst b/shopfloor/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..7a55e5ba99 --- /dev/null +++ b/shopfloor/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* Alexandre Fayolle + + ADD YOURSELF diff --git a/shopfloor/readme/DESCRIPTION.rst b/shopfloor/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..756a0db67d --- /dev/null +++ b/shopfloor/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +write me diff --git a/shopfloor/readme/HISTORY.rst b/shopfloor/readme/HISTORY.rst new file mode 100644 index 0000000000..dc27ee2605 --- /dev/null +++ b/shopfloor/readme/HISTORY.rst @@ -0,0 +1,4 @@ +13.0.1.0.0 +~~~~~~~~~~ + +First official version. diff --git a/shopfloor/readme/ROADMAP.rst b/shopfloor/readme/ROADMAP.rst new file mode 100644 index 0000000000..756a0db67d --- /dev/null +++ b/shopfloor/readme/ROADMAP.rst @@ -0,0 +1 @@ +write me diff --git a/shopfloor/readme/USAGE.rst b/shopfloor/readme/USAGE.rst new file mode 100644 index 0000000000..098af7f8df --- /dev/null +++ b/shopfloor/readme/USAGE.rst @@ -0,0 +1,5 @@ +To add your own REST service you must provides at least 2 classes. + +* A Component providing the business logic of your service, +* A Controller to register your service. +write me From b92ee110d3b9154280e2e1bf1331325e5d78e619 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 16:37:49 +0100 Subject: [PATCH 003/940] more boilerplate --- shopfloor/__manifest__.py | 1 + shopfloor/controllers/__init__.py | 1 + shopfloor/controllers/main.py | 3 +++ 3 files changed, 5 insertions(+) create mode 100644 shopfloor/controllers/__init__.py create mode 100644 shopfloor/controllers/main.py diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 5cc45996a6..c0d07303fe 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,6 +6,7 @@ "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", "version": "13.0.1.0.0", + "development_status": "Alpha", "category": "Inventory", "website": "https://odoo-community.org", "author": "Akretion, BCIM, Camptocamp, Odoo Community Association (OCA)", diff --git a/shopfloor/controllers/__init__.py b/shopfloor/controllers/__init__.py new file mode 100644 index 0000000000..12a7e529b6 --- /dev/null +++ b/shopfloor/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py new file mode 100644 index 0000000000..1a5fe43b70 --- /dev/null +++ b/shopfloor/controllers/main.py @@ -0,0 +1,3 @@ +from odoo import http + + From db6b3688e39efe59ace44b0b2991beca96a6e948 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 17:11:52 +0100 Subject: [PATCH 004/940] add some models --- shopfloor/__manifest__.py | 5 +++ shopfloor/models/__init__.py | 3 ++ shopfloor/models/res_users.py | 13 +++++++ shopfloor/models/shopfloor_group.py | 14 ++++++++ shopfloor/models/shopfloor_menu.py | 15 ++++++++ shopfloor/security/ir.model.access.csv | 5 +++ shopfloor/views/menus.xml | 6 ++++ shopfloor/views/res_users.xml | 24 +++++++++++++ shopfloor/views/shopfloor_group.xml | 47 ++++++++++++++++++++++++++ shopfloor/views/shopfloor_menu.xml | 29 ++++++++++++++++ 10 files changed, 161 insertions(+) create mode 100644 shopfloor/models/res_users.py create mode 100644 shopfloor/models/shopfloor_group.py create mode 100644 shopfloor/models/shopfloor_menu.py create mode 100644 shopfloor/security/ir.model.access.csv create mode 100644 shopfloor/views/menus.xml create mode 100644 shopfloor/views/res_users.xml create mode 100644 shopfloor/views/shopfloor_group.xml create mode 100644 shopfloor/views/shopfloor_menu.xml diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index c0d07303fe..a6b85e88b1 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -17,5 +17,10 @@ "base_rest", ], "data": [ + "security/ir.model.access.csv", + "views/res_users.xml", + "views/shopfloor_group.xml", + "views/shopfloor_menu.xml", + "views/menus.xml", ], } diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index e69de29bb2..d8addf9144 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -0,0 +1,3 @@ +from . import shopfloor_menu +from . import shopfloor_group +from . import res_users diff --git a/shopfloor/models/res_users.py b/shopfloor/models/res_users.py new file mode 100644 index 0000000000..fe7db83dd4 --- /dev/null +++ b/shopfloor/models/res_users.py @@ -0,0 +1,13 @@ +from odoo import fields, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + shopfloor_group_ids = fields.Many2many( + 'shopfloor.group', + string="Shopfloor groups" + ) + shopfloor_current_process = fields.Char() + shopfloor_last_call = fields.Char() + shopfloor_picking_id = fields.Many2one('stock.picking') diff --git a/shopfloor/models/shopfloor_group.py b/shopfloor/models/shopfloor_group.py new file mode 100644 index 0000000000..9e3ebb6b16 --- /dev/null +++ b/shopfloor/models/shopfloor_group.py @@ -0,0 +1,14 @@ +from odoo import fields, models + + +class ShopfloorGroup(models.Model): + _name = "shopfloor.group" + _description = "Shopfloor group, governs which menu items are visible" + + name = fields.Char(required=True) + user_ids = fields.Many2many("res.users", string="Members") + menu_ids = fields.Many2many( + 'shopfloor.menu', + string="Menus", + help="Can see these menus", + ) diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py new file mode 100644 index 0000000000..1e823b4e25 --- /dev/null +++ b/shopfloor/models/shopfloor_menu.py @@ -0,0 +1,15 @@ +from odoo import fields, models + + +class ShopfloorMenu(models.Model): + _name = 'shopfloor.menu' + _description = "Menu displayed in the scanner application" + _order = 'sequence' + + name = fields.Char(translate=True) + sequence = fields.Integer() + group_ids = fields.Many2many( + 'shopfloor.group', + string="Groups", + help="visible for these groups", + ) diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv new file mode 100644 index 0000000000..5ca90ecc73 --- /dev/null +++ b/shopfloor/security/ir.model.access.csv @@ -0,0 +1,5 @@ +"id","name","model_id/id","group_id/id","perm_read","perm_write","perm_create","perm_unlink" +"access_shopfloor_menu_users","shopfloor menu","model_shopfloor_menu",,1,0,, +"access_shopfloor_menu_stock_manager","shopfloor menu inventory manager","model_shopfloor_menu","stock.group_stock_manager,1,1,1,1 +"access_shopfloor_group_users","shopfloor group","model_shopfloor_group",,1,0,, +"access_shopfloor_group_stock_manager","shopfloor group inventory manager","model_shopfloor_group","stock.group_stock_manager,1,1,1,1 diff --git a/shopfloor/views/menus.xml b/shopfloor/views/menus.xml new file mode 100644 index 0000000000..c4a8cbb2f3 --- /dev/null +++ b/shopfloor/views/menus.xml @@ -0,0 +1,6 @@ + +< + + + + diff --git a/shopfloor/views/res_users.xml b/shopfloor/views/res_users.xml new file mode 100644 index 0000000000..2bf16a31f6 --- /dev/null +++ b/shopfloor/views/res_users.xml @@ -0,0 +1,24 @@ + + + + + Res users Shopfloor + + + + + + + + + + + + + + + + + + + diff --git a/shopfloor/views/shopfloor_group.xml b/shopfloor/views/shopfloor_group.xml new file mode 100644 index 0000000000..1aedf5f4a0 --- /dev/null +++ b/shopfloor/views/shopfloor_group.xml @@ -0,0 +1,47 @@ + + + + shopfloor group tree + + + + + + + + + + shopfloor group form + +
+ + + + + + + + + + + +
+
+
+ + + shopfloor group search + + + + + + + + + Groups + ir.actions.act_window + tree,form + + +
diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml new file mode 100644 index 0000000000..9c50eafa7e --- /dev/null +++ b/shopfloor/views/shopfloor_menu.xml @@ -0,0 +1,29 @@ + + + + + shopfloor menu tree + + + + + + + + + + + shopfloor menu search + + + + + + + + + Menus + ir.actions.act_window + tree + + From 7e1c7e1491111a08b82af5c96e23ccb27ff00278 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 20 Jan 2020 17:20:56 +0100 Subject: [PATCH 005/940] fixup! add some models --- shopfloor/security/ir.model.access.csv | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv index 5ca90ecc73..5b805f475d 100644 --- a/shopfloor/security/ir.model.access.csv +++ b/shopfloor/security/ir.model.access.csv @@ -1,5 +1,5 @@ "id","name","model_id/id","group_id/id","perm_read","perm_write","perm_create","perm_unlink" -"access_shopfloor_menu_users","shopfloor menu","model_shopfloor_menu",,1,0,, -"access_shopfloor_menu_stock_manager","shopfloor menu inventory manager","model_shopfloor_menu","stock.group_stock_manager,1,1,1,1 -"access_shopfloor_group_users","shopfloor group","model_shopfloor_group",,1,0,, -"access_shopfloor_group_stock_manager","shopfloor group inventory manager","model_shopfloor_group","stock.group_stock_manager,1,1,1,1 +"access_shopfloor_menu_users","shopfloor menu","model_shopfloor_menu",,1,0,0,0 +"access_shopfloor_menu_stock_manager","shopfloor menu inventory manager","model_shopfloor_menu","stock.group_stock_manager",1,1,1,1 +"access_shopfloor_group_users","shopfloor group","model_shopfloor_group",,1,0,0,0 +"access_shopfloor_group_stock_manager","shopfloor group inventory manager","model_shopfloor_group","stock.group_stock_manager",1,1,1,1 From 4fa6c4afa13465d727f3b2199b225b229a5a6df2 Mon Sep 17 00:00:00 2001 From: Benoit Date: Mon, 20 Jan 2020 17:23:12 +0100 Subject: [PATCH 006/940] add start controllers and services --- shopfloor/__init__.py | 2 ++ shopfloor/controllers/main.py | 10 +++++++++- shopfloor/services/__init__.py | 1 + shopfloor/services/shopfloor_service.py | 26 +++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 shopfloor/services/__init__.py create mode 100644 shopfloor/services/shopfloor_service.py diff --git a/shopfloor/__init__.py b/shopfloor/__init__.py index 0650744f6b..c312a8487c 100644 --- a/shopfloor/__init__.py +++ b/shopfloor/__init__.py @@ -1 +1,3 @@ +from . import controllers from . import models +from . import services diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py index 1a5fe43b70..94b9d46c50 100644 --- a/shopfloor/controllers/main.py +++ b/shopfloor/controllers/main.py @@ -1,3 +1,11 @@ -from odoo import http +from odoo.addons.base_rest.controllers import main +import json + + +class ShopfloorController(main.RestController): + _root_path = "/shopfloor/" + _collection_name = "shopfloor.services" + _default_auth = "api_key" + diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py new file mode 100644 index 0000000000..86d4e03e8f --- /dev/null +++ b/shopfloor/services/__init__.py @@ -0,0 +1 @@ +from . shopfloor_service diff --git a/shopfloor/services/shopfloor_service.py b/shopfloor/services/shopfloor_service.py new file mode 100644 index 0000000000..18146f3461 --- /dev/null +++ b/shopfloor/services/shopfloor_service.py @@ -0,0 +1,26 @@ +from odoo.addons.component.core import Component + + +class ShopfloorService(Component): + _inherit = "base.rest.service" + _name = "shopfloor.service" + _collection = "shopfloor.services" + _usage = "shopfloor" + + def get_pack(self, pack_name): + """ + Get pack informations + """ + pack = self.env['stock.quant.package'].search([('name', '=', pack_name)]) + return self._to_json(pack) + + def _validator_search(self): + return {"pack_name": {"type": "string", "nullable": False, "required": True}} + + def _to_json(self, pack): + res = { + "id": pack.id, + "name": pack.name, + "location": pack.location_id.name, + } + return res From 88d0a02d6a0b78732d781bfcb06636a52da1e771 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 17:28:09 +0100 Subject: [PATCH 007/940] fixup! add some models --- shopfloor/views/res_users.xml | 1 + shopfloor/views/shopfloor_group.xml | 4 ++++ shopfloor/views/shopfloor_menu.xml | 3 +++ 3 files changed, 8 insertions(+) diff --git a/shopfloor/views/res_users.xml b/shopfloor/views/res_users.xml index 2bf16a31f6..7215a23cb6 100644 --- a/shopfloor/views/res_users.xml +++ b/shopfloor/views/res_users.xml @@ -3,6 +3,7 @@ Res users Shopfloor + res.users diff --git a/shopfloor/views/shopfloor_group.xml b/shopfloor/views/shopfloor_group.xml index 1aedf5f4a0..212f672858 100644 --- a/shopfloor/views/shopfloor_group.xml +++ b/shopfloor/views/shopfloor_group.xml @@ -2,6 +2,7 @@ shopfloor group tree + shopfloor.group @@ -12,6 +13,7 @@ shopfloor group form + shopfloor.group
@@ -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 9c50eafa7e..461eda3a9f 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 b95cdb03c60e52e37168e4c9f5fb8f7e7fe1fb16 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 17:30:24 +0100 Subject: [PATCH 008/940] 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 212f672858..f705515e5c 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 461eda3a9f..b7648b2b12 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 3998780012f5edf257d6c99d5495239f899dc633 Mon Sep 17 00:00:00 2001 From: Benoit Date: Mon, 20 Jan 2020 17:32:59 +0100 Subject: [PATCH 009/940] 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 86d4e03e8f..5f9782abe9 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -1 +1 @@ -from . shopfloor_service +from . import shopfloor_service From 34fe3cc49cbace611515fdd51e51cc29ab0c58b5 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 17:36:28 +0100 Subject: [PATCH 010/940] 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 c4a8cbb2f3..e79ab726b9 100644 --- a/shopfloor/views/menus.xml +++ b/shopfloor/views/menus.xml @@ -1,6 +1,6 @@ -< - - - + + + + From cb9ba42e9b3f7ac4f7078bbfecc0b3988f840ec4 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 17:40:09 +0100 Subject: [PATCH 011/940] 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 e79ab726b9..e29c43aa40 100644 --- a/shopfloor/views/menus.xml +++ b/shopfloor/views/menus.xml @@ -2,5 +2,5 @@ - + From b964483957f272df4330aabc9a6f217919c8f04b Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 17:42:23 +0100 Subject: [PATCH 012/940] 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 e29c43aa40..487df55ccf 100644 --- a/shopfloor/views/menus.xml +++ b/shopfloor/views/menus.xml @@ -2,5 +2,5 @@ - + From 8bdfac2063263a359b21a3e729dddc80777ba0f9 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 09:27:41 +0100 Subject: [PATCH 013/940] 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 a6b85e88b1..45b15a0aab 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 0000000000..800aec5f0b --- /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 098af7f8df..a83522c0be 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 886290aee780e0da19ff72d03609356e96e70ffc Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 09:42:45 +0100 Subject: [PATCH 014/940] 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 a83522c0be..2d5767567e 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 7c3b010b9d373cfef9b64887379d1ee4773ddae7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 09:42:35 +0100 Subject: [PATCH 015/940] 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 18146f3461..7db5a5c4b7 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 ec6d5d1934241692731b5c1356b60ad14b65d72f Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Tue, 21 Jan 2020 11:05:24 +0100 Subject: [PATCH 016/940] [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 45b15a0aab..653d4f6491 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 d8addf9144..6dca41f10c 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 fe7db83dd4..5b6514def6 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 1e823b4e25..8e62c40d0e 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 9e3ebb6b16..1d78aeeb83 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 5b805f475d..6c0c0ddd14 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 487df55ccf..3acc388d27 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 7215a23cb6..b1ce6c1e56 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 b7648b2b12..c9845601b0 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 f705515e5c..28fdc3ecc3 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 14173e9659ff02d3e2e72162d16414eacebe5b5c Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Tue, 21 Jan 2020 11:22:55 +0100 Subject: [PATCH 017/940] [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 653d4f6491..7cbf3e87cd 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 6dca41f10c..15fe538e93 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 8e62c40d0e..48f836bd37 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 0000000000..accbc02155 --- /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 0000000000..206b68c7fe --- /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 3acc388d27..4fba7ea428 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 c9845601b0..016b872f25 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 0000000000..67afa35bc8 --- /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 0000000000..1ccb8c591b --- /dev/null +++ b/shopfloor/views/stock_picking_type.xml @@ -0,0 +1,13 @@ + + + + Operation Types + stock.picking.type + + + + + + + + From 5fa81a13efb4f2a772e087a0381b5e3a5a7945a2 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 12:00:20 +0100 Subject: [PATCH 018/940] 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 7cbf3e87cd..7bae4764a9 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 15fe538e93..7bfd287eba 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 5b6514def6..0000000000 --- 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 0000000000..986367f936 --- /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 6c0c0ddd14..2726088b88 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 5f9782abe9..7369a5602f 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 0000000000..74857cd933 --- /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 0000000000..f52894f558 --- /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 7db5a5c4b7..c3e68528b0 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 4fba7ea428..f6bc4f105d 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 b1ce6c1e56..0000000000 --- a/shopfloor/views/res_users.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - Res users Shopfloor - res.users - - - - - - - - - - - - - - - - - - - diff --git a/shopfloor/views/shopfloor_device_views.xml b/shopfloor/views/shopfloor_device_views.xml new file mode 100644 index 0000000000..2f92f30928 --- /dev/null +++ b/shopfloor/views/shopfloor_device_views.xml @@ -0,0 +1,61 @@ + + + + shopfloor.device tree + shopfloor.device + + + + + + + + + + + shopfloor.device form + shopfloor.device + +
+ + + + + + + + + + + + + + + + + +
+
+
+ + + shopfloor.device search + shopfloor.device + + + + + + + + + + + + Devices + shopfloor.device + ir.actions.act_window + tree,form + + +
From c34a069085bd513f6e50a5d9b5c5066d9782ca79 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 13:55:19 +0100 Subject: [PATCH 019/940] fixup! [IMP] add shopfloor.process --- shopfloor/security/ir.model.access.csv | 1 + 1 file changed, 1 insertion(+) diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv index 2726088b88..6db6c86799 100644 --- a/shopfloor/security/ir.model.access.csv +++ b/shopfloor/security/ir.model.access.csv @@ -5,3 +5,4 @@ "access_shopfloor_operation_group_stock_manager","shopfloor operation group inventory manager","model_shopfloor_operation_group","stock.group_stock_manager",1,1,1,1 "access_shopfloor_device_users","shopfloor device","model_shopfloor_device",,1,0,0,0 "access_shopfloor_device_stock_manager","shopfloor device inventory manager","model_shopfloor_device","stock.group_stock_manager",1,1,1,1 +"access_shopfloor_process_users","shopfloor process","model_shopfloor_process",,1,0,0,0 From c1e1558da0ef0036b5f2ee3030f01f16a190de8c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 14:09:58 +0100 Subject: [PATCH 020/940] Add device restriction per user or operation group * If a user is assigned on a device, this user can use only this device: the API returns only this record. * If a user is not assigned on any device, the devices are filtered by the operation groups: no group on a device means everyone can use it, otherwise the user must be in at least one the group assigned to the device. --- shopfloor/models/__init__.py | 1 + shopfloor/models/res_users.py | 12 ++++++++++++ shopfloor/models/shopfloor_device.py | 14 +++++++++++++- shopfloor/services/device.py | 14 +++++++++++++- shopfloor/views/shopfloor_device_views.xml | 3 ++- 5 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 shopfloor/models/res_users.py diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index 7bfd287eba..6878290486 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -3,3 +3,4 @@ from . import shopfloor_process from . import stock_picking_type from . import shopfloor_device +from . import res_users diff --git a/shopfloor/models/res_users.py b/shopfloor/models/res_users.py new file mode 100644 index 0000000000..e86bb27d2c --- /dev/null +++ b/shopfloor/models/res_users.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + # in practice, it's a one2one + shopfloor_device_ids = fields.One2many( + comodel_name="shopfloor.device", + inverse_name="user_id", + readonly=True, + ) diff --git a/shopfloor/models/shopfloor_device.py b/shopfloor/models/shopfloor_device.py index 986367f936..f3f8ffc56b 100644 --- a/shopfloor/models/shopfloor_device.py +++ b/shopfloor/models/shopfloor_device.py @@ -1,4 +1,4 @@ -from odoo import fields, models +from odoo import api, fields, models class ShopfloorDevice(models.Model): @@ -9,6 +9,7 @@ class ShopfloorDevice(models.Model): warehouse_id = fields.Many2one( "stock.warehouse", required=True, + default=lambda self: self._default_warehouse_id(), ) shopfloor_operation_group_ids = fields.Many2many( "shopfloor.operation.group", @@ -16,6 +17,7 @@ class ShopfloorDevice(models.Model): ) user_id = fields.Many2one( "res.users", + copy=False, help="Optional user using the device. The device will" "use this configuration when the users logs in the client " "application." @@ -23,3 +25,13 @@ class ShopfloorDevice(models.Model): shopfloor_current_process = fields.Char(readonly=True) shopfloor_last_call = fields.Char(readonly=True) shopfloor_picking_id = fields.Many2one('stock.picking', readonly=True) + + _sql_constraints = [ + ('user_id_uniq', 'unique(user_id)', 'A user can be assigned to only one device.'), + ] + + @api.model + def _default_warehouse_id(self): + wh = self.env['stock.warehouse'].search([]) + if len(wh) == 1: + return wh diff --git a/shopfloor/services/device.py b/shopfloor/services/device.py index 74857cd933..5edd4a565b 100644 --- a/shopfloor/services/device.py +++ b/shopfloor/services/device.py @@ -1,3 +1,4 @@ +from odoo import fields from odoo.addons.component.core import Component @@ -8,13 +9,24 @@ class ShopfloorDevice(Component): _expose_model = "shopfloor.device" def search(self, name_fragment=None): - # TODO filter on shopfloor.group or user in override of _get_base_search_domain domain = self._get_base_search_domain() if name_fragment: domain.append(("name", "ilike", name_fragment)) records = self.env[self._expose_model].search(domain) return {"size": len(records), "data": self._to_json(records)} + def _get_base_search_domain(self): + # shopfloor_device_ids is a one2one + user = self.env.user + assigned_device = fields.first(user.shopfloor_device_ids) + if assigned_device: + return [("id", "=", assigned_device.id)] + return [ + "|", + ("shopfloor_operation_group_ids", "=", False), + ("shopfloor_operation_group_ids.user_ids", "=", user.id), + ] + def _validator_search(self): return { "name_fragment": { diff --git a/shopfloor/views/shopfloor_device_views.xml b/shopfloor/views/shopfloor_device_views.xml index 2f92f30928..d1805604c7 100644 --- a/shopfloor/views/shopfloor_device_views.xml +++ b/shopfloor/views/shopfloor_device_views.xml @@ -4,9 +4,10 @@ shopfloor.device tree shopfloor.device - + + From a1341ea56399e8e10c7ec0c981f9a8cf975d042b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 14:36:06 +0100 Subject: [PATCH 021/940] Ignore validation of search return --- shopfloor/services/device.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shopfloor/services/device.py b/shopfloor/services/device.py index 5edd4a565b..f92d1a18d1 100644 --- a/shopfloor/services/device.py +++ b/shopfloor/services/device.py @@ -1,6 +1,8 @@ from odoo import fields from odoo.addons.component.core import Component +from odoo.addons.base_rest.components.service import skip_secure_response + class ShopfloorDevice(Component): _inherit = "base.shopfloor.service" @@ -8,6 +10,7 @@ class ShopfloorDevice(Component): _usage = "device" _expose_model = "shopfloor.device" + @skip_secure_response def search(self, name_fragment=None): domain = self._get_base_search_domain() if name_fragment: From deb42863a3f1e6b3e883a54d981b12c970ec6e45 Mon Sep 17 00:00:00 2001 From: Benoit Date: Tue, 21 Jan 2020 15:14:56 +0100 Subject: [PATCH 022/940] improve putaway service methods and add first version of tests --- shopfloor/services/shopfloor_service.py | 67 +++++++++++++++++++++++++ shopfloor/tests/__init__.py | 1 + shopfloor/tests/common.py | 17 +++++++ shopfloor/tests/test_putaway.py | 56 +++++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 shopfloor/tests/__init__.py create mode 100644 shopfloor/tests/common.py create mode 100644 shopfloor/tests/test_putaway.py diff --git a/shopfloor/services/shopfloor_service.py b/shopfloor/services/shopfloor_service.py index c3e68528b0..3e14289e33 100644 --- a/shopfloor/services/shopfloor_service.py +++ b/shopfloor/services/shopfloor_service.py @@ -1,4 +1,5 @@ from odoo.addons.component.core import Component +from odoo.addons.base_rest.components.service import to_int # TODO move in a pack service @@ -7,6 +8,72 @@ class ShopfloorService(Component): _name = "shopfloor.service" _usage = "shopfloor" + def scan_pack(self, pack_name): + pack = self.env['stock.quant.package'].search([('name', '=', pack_name)]) + company = self.env.user.company_id # FIXME add logic to get proper company + # FIXME add logic to get proper warehouse + warehouse = self.env['stock.warehouse'].search([])[0] + picking_type = warehouse.int_type_id # FIXME add logic to get picking type properly + product = pack.quant_ids[0].product_id # FIXME we consider only one product per pack + move_vals = { + 'picking_type_id': picking_type.id, + 'product_id': product.id, + 'location_id': pack.location_id.id, + 'location_dest_id': picking_type.default_location_dest_id.id, + 'name': product.name, + 'product_uom': product.uom_id.id, + 'product_uom_qty': pack.quant_ids[0].quantity, + 'company_id': company.id, + } + move = self.env['stock.move'].create(move_vals) + move._action_confirm() + pack_level = self.env['stock.package_level'].create({ + 'package_id': pack.id, + 'move_ids': [(6, 0, [move.id])], + 'company_id': company.id, + }) + move.picking_id.action_assign() + return_vals = { + 'name': pack.name, + 'location_name': pack.location_id.name, + 'location_dest_name': move.move_line_ids[0].location_dest_id.name, + 'product_name': move.name, + 'picking_name': move.picking_id.name, + 'location_id': pack.location_id.id, + 'location_dest_id': move.move_line_ids[0].location_dest_id.id, + 'move_id': move.id, +# 'allow_change_destination': True, #TODO + } + return return_vals + + def validate(self, move_id, location_name): + move = self.env['stock.move'].browse(move_id) + dest_location = self.env['stock.location'].search([('name', '=', location_name)]) + if move.move_line_ids[0].location_dest_id.id != dest_location.id: + move.move_line_ids[0].location_dest_id = dest_location.id + move.move_line_ids[0].qty_done = move.move_line_ids[0].product_uom_qty + move.picking_id.button_validate() + return True + + def cancel(self, move_id): + move = self.env['stock.move'].browse(move_id) + move.picking_id.cancel() + return True + + def _validator_validate(self): + return { + "move_id": {"coerce": to_int, "required": True, "type": "integer"}, + "location_name": {"type": "string", "nullable": False, "required": True}, + } + + def _validator_scan_pack(self): + return {"pack_name": {"type": "string", "nullable": False, "required": True}} + + def _validator_cancel(self): + return { + "move_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + def get_pack(self, pack_name): """ Get pack informations diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py new file mode 100644 index 0000000000..3a80e1ce96 --- /dev/null +++ b/shopfloor/tests/__init__.py @@ -0,0 +1 @@ +from . import test_putaway diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py new file mode 100644 index 0000000000..f85f3c3f94 --- /dev/null +++ b/shopfloor/tests/common.py @@ -0,0 +1,17 @@ +from contextlib import contextmanager +from odoo.addons.base_rest.controllers.main import _PseudoCollection +from odoo.addons.component.core import WorkContext +from odoo.tests.common import SavepointCase + + +class CommonCase(SavepointCase): + + @contextmanager + def work_on_services(self, **params): + params = params or {} + collection = _PseudoCollection("shopfloor.service", self.env) + yield WorkContext( + model_name="rest.service.registration", + collection=collection, + **params + ) diff --git a/shopfloor/tests/test_putaway.py b/shopfloor/tests/test_putaway.py new file mode 100644 index 0000000000..45b87743d6 --- /dev/null +++ b/shopfloor/tests/test_putaway.py @@ -0,0 +1,56 @@ +from .common import CommonCase + + +class PutawayCase(CommonCase): + + def setUp(self, *args, **kwargs): + super(PutawayCase, self).setUp(*args, **kwargs) + in_location = self.env.ref('stock.stock_location_company').child_ids[0] + stock_location = self.env.ref('stock.stock_location_stock') + self.productA = self.env['product.product'].create({'name': 'Product A', 'type': 'product'}) + self.packA = self.env['stock.quant.package'].create({ + 'location_id': in_location.id + }) + self.quantA = self.env['stock.quant'].create({ + 'product_id': self.productA.id, + 'location_id': in_location.id, + 'quantity': 1, + 'package_id': self.packA.id, + }) + self.env['stock.putaway.rule'].create({ + 'product_id': self.productA.id, + 'location_in_id': stock_location.id, + 'location_out_id': stock_location.child_ids[0].id, + }) + with self.work_on_services( + ) as work: + self.service = work.component(usage="shopfloor") + + def test_scan_pack(self): + pack_name = self.packA.name + params = { + 'pack_name': pack_name, + } + response = self.service.dispatch("scan_pack", params=params) + move_id = response['move_id'] + params ={ + 'move_id': move_id, + 'location_name': response['location_dest_name'], + } + location_dest_id = self.env['stock.location'].search([ + ('name', '=', params['location_name']) + ]).id + new_loc_quant = self.env['stock.quant'].search([ + ('product_id', '=', self.productA.id), + ('location_id', '=', location_dest_id) + ]) + self.assertFalse(new_loc_quant) + response = self.service.dispatch("validate", params=params) + new_loc_quant = self.env['stock.quant'].search([ + ('product_id', '=', self.productA.id), + ('location_id', '=', location_dest_id) + ]) + move = self.env['stock.move'].browse(move_id) + self.assertEquals(move.state, 'done') + self.assertEquals(self.quantA.quantity, 0) + self.assertEquals(new_loc_quant.quantity, move.product_uom_qty) From 14ef08b9e22d6cb6acbf853274ff8411494d3c57 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 15:16:51 +0100 Subject: [PATCH 023/940] Add demo data for groups and devices --- shopfloor/__manifest__.py | 2 ++ shopfloor/demo/shopfloor_device_demo.xml | 21 +++++++++++++++++++ .../demo/shopfloor_operation_group_demo.xml | 8 +++++++ shopfloor/models/shopfloor_device.py | 4 +++- 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 shopfloor/demo/shopfloor_device_demo.xml create mode 100644 shopfloor/demo/shopfloor_operation_group_demo.xml diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 7bae4764a9..5110d5a2eb 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -28,5 +28,7 @@ ], "demo": [ "demo/auth_api_key_demo.xml", + "demo/shopfloor_operation_group_demo.xml", + "demo/shopfloor_device_demo.xml", ] } diff --git a/shopfloor/demo/shopfloor_device_demo.xml b/shopfloor/demo/shopfloor_device_demo.xml new file mode 100644 index 0000000000..bd9ed3f250 --- /dev/null +++ b/shopfloor/demo/shopfloor_device_demo.xml @@ -0,0 +1,21 @@ + + + + Highbay Truck 1 + + + + + + Highbay Truck 2 + + + + + + Shelf 1 + + + + + diff --git a/shopfloor/demo/shopfloor_operation_group_demo.xml b/shopfloor/demo/shopfloor_operation_group_demo.xml new file mode 100644 index 0000000000..4f8104efb5 --- /dev/null +++ b/shopfloor/demo/shopfloor_operation_group_demo.xml @@ -0,0 +1,8 @@ + + + + HighBay + + + + diff --git a/shopfloor/models/shopfloor_device.py b/shopfloor/models/shopfloor_device.py index f3f8ffc56b..bd32e5c2e1 100644 --- a/shopfloor/models/shopfloor_device.py +++ b/shopfloor/models/shopfloor_device.py @@ -13,7 +13,9 @@ class ShopfloorDevice(models.Model): ) shopfloor_operation_group_ids = fields.Many2many( "shopfloor.operation.group", - string="Shopfloor Operation Groups" + string="Shopfloor Operation Groups", + help="When unset, all users can use the device. When set," + "only users belonging to at least one group can use the device.", ) user_id = fields.Many2one( "res.users", From ee7b19ddddbaada11c5fd1acf28debeb93b47a2a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 15:36:12 +0100 Subject: [PATCH 024/940] Add demo data for menus and processes --- shopfloor/__manifest__.py | 2 ++ shopfloor/demo/shopfloor_menu_demo.xml | 9 +++++ shopfloor/demo/shopfloor_process_demo.xml | 7 ++++ shopfloor/models/shopfloor_device.py | 1 + shopfloor/services/__init__.py | 1 + shopfloor/services/menu.py | 41 +++++++++++++++++++++++ 6 files changed, 61 insertions(+) create mode 100644 shopfloor/demo/shopfloor_menu_demo.xml create mode 100644 shopfloor/demo/shopfloor_process_demo.xml create mode 100644 shopfloor/services/menu.py diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 5110d5a2eb..f94a70f42e 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -28,6 +28,8 @@ ], "demo": [ "demo/auth_api_key_demo.xml", + "demo/shopfloor_process_demo.xml", + "demo/shopfloor_menu_demo.xml", "demo/shopfloor_operation_group_demo.xml", "demo/shopfloor_device_demo.xml", ] diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml new file mode 100644 index 0000000000..7d643a0e1f --- /dev/null +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -0,0 +1,9 @@ + + + + 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 0000000000..30964d7b23 --- /dev/null +++ b/shopfloor/demo/shopfloor_process_demo.xml @@ -0,0 +1,7 @@ + + + + Put-Away Reach Truck + + + diff --git a/shopfloor/models/shopfloor_device.py b/shopfloor/models/shopfloor_device.py index bd32e5c2e1..ab47e8ea2f 100644 --- a/shopfloor/models/shopfloor_device.py +++ b/shopfloor/models/shopfloor_device.py @@ -11,6 +11,7 @@ class ShopfloorDevice(models.Model): required=True, default=lambda self: self._default_warehouse_id(), ) + # TODO: remove shopfloor_ prefix shopfloor_operation_group_ids = fields.Many2many( "shopfloor.operation.group", string="Shopfloor Operation Groups", diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 7369a5602f..60ec641820 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -2,3 +2,4 @@ # TODO rename file shop_floor_service to pack from . import shopfloor_service from . import device +from . import menu diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py new file mode 100644 index 0000000000..c61f5c66b6 --- /dev/null +++ b/shopfloor/services/menu.py @@ -0,0 +1,41 @@ +from odoo.addons.component.core import Component + +from odoo.addons.base_rest.components.service import skip_secure_response + + +class ShopfloorMenu(Component): + _inherit = "base.shopfloor.service" + _name = "shopfloor.menu" + _usage = "menu" + _expose_model = "shopfloor.menu" + + @skip_secure_response + def search(self, name_fragment=None): + domain = self._get_base_search_domain() + if name_fragment: + domain.append(("name", "ilike", name_fragment)) + records = self.env[self._expose_model].search(domain) + return {"size": len(records), "data": self._to_json(records)} + + def _get_base_search_domain(self): + user = self.env.user + return [ + "|", + ("operation_group_ids", "=", False), + ("operation_group_ids.user_ids", "=", user.id), + ] + + def _validator_search(self): + return { + "name_fragment": { + "type": "string", + "nullable": True, + "required": False, + } + } + + def _convert_one_record(self, record): + return { + "id": record.id, + "name": record.name, + } From 26f44c6377cc1485135a6d2cba3b35b1e2d3e491 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 15:43:13 +0100 Subject: [PATCH 025/940] Rename field (sorry, you'll have to rebuild your db) --- shopfloor/models/shopfloor_device.py | 3 +-- shopfloor/services/device.py | 4 ++-- shopfloor/views/shopfloor_device_views.xml | 6 +++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/shopfloor/models/shopfloor_device.py b/shopfloor/models/shopfloor_device.py index ab47e8ea2f..f84c78322e 100644 --- a/shopfloor/models/shopfloor_device.py +++ b/shopfloor/models/shopfloor_device.py @@ -11,8 +11,7 @@ class ShopfloorDevice(models.Model): required=True, default=lambda self: self._default_warehouse_id(), ) - # TODO: remove shopfloor_ prefix - shopfloor_operation_group_ids = fields.Many2many( + operation_group_ids = fields.Many2many( "shopfloor.operation.group", string="Shopfloor Operation Groups", help="When unset, all users can use the device. When set," diff --git a/shopfloor/services/device.py b/shopfloor/services/device.py index f92d1a18d1..c53060838e 100644 --- a/shopfloor/services/device.py +++ b/shopfloor/services/device.py @@ -26,8 +26,8 @@ def _get_base_search_domain(self): return [("id", "=", assigned_device.id)] return [ "|", - ("shopfloor_operation_group_ids", "=", False), - ("shopfloor_operation_group_ids.user_ids", "=", user.id), + ("operation_group_ids", "=", False), + ("operation_group_ids.user_ids", "=", user.id), ] def _validator_search(self): diff --git a/shopfloor/views/shopfloor_device_views.xml b/shopfloor/views/shopfloor_device_views.xml index d1805604c7..1f6dc4cc25 100644 --- a/shopfloor/views/shopfloor_device_views.xml +++ b/shopfloor/views/shopfloor_device_views.xml @@ -7,7 +7,7 @@ - +
@@ -25,7 +25,7 @@ - + @@ -47,7 +47,7 @@ - +
From 8d5cb533060643c4cf87d87132ba345d9b4c0165 Mon Sep 17 00:00:00 2001 From: Benoit Date: Tue, 21 Jan 2020 16:03:21 +0100 Subject: [PATCH 026/940] [IMP] add validator return for search shopfloor device --- shopfloor/services/device.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/shopfloor/services/device.py b/shopfloor/services/device.py index c53060838e..8e6ede204a 100644 --- a/shopfloor/services/device.py +++ b/shopfloor/services/device.py @@ -1,7 +1,7 @@ from odoo import fields from odoo.addons.component.core import Component -from odoo.addons.base_rest.components.service import skip_secure_response +from odoo.addons.base_rest.components.service import to_int class ShopfloorDevice(Component): @@ -10,7 +10,6 @@ class ShopfloorDevice(Component): _usage = "device" _expose_model = "shopfloor.device" - @skip_secure_response def search(self, name_fragment=None): domain = self._get_base_search_domain() if name_fragment: @@ -39,10 +38,34 @@ def _validator_search(self): } } + def _validator_return_search(self): + return { + "size": {"coerce": to_int, "required": True, "type": "integer"}, + "data": { + "type": "list", + "schema": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "warehouse": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + } + } + } + } + } + } + def _convert_one_record(self, record): return { "id": record.id, "name": record.name, - "warehouse_id": record.warehouse_id.id, - "warehouse": record.warehouse_id.name, + "warehouse": { + "id": record.warehouse_id.id, + "name": record.warehouse_id.name, + } } From b2c2f1635e64aef2ce2dbe1fb158c8c6a24c5a10 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 16:16:30 +0100 Subject: [PATCH 027/940] Add schema validation for menu /search output --- shopfloor/services/menu.py | 42 ++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index c61f5c66b6..c4270d1b9a 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -1,7 +1,6 @@ +from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component -from odoo.addons.base_rest.components.service import skip_secure_response - class ShopfloorMenu(Component): _inherit = "base.shopfloor.service" @@ -9,14 +8,6 @@ class ShopfloorMenu(Component): _usage = "menu" _expose_model = "shopfloor.menu" - @skip_secure_response - def search(self, name_fragment=None): - domain = self._get_base_search_domain() - if name_fragment: - domain.append(("name", "ilike", name_fragment)) - records = self.env[self._expose_model].search(domain) - return {"size": len(records), "data": self._to_json(records)} - def _get_base_search_domain(self): user = self.env.user return [ @@ -25,17 +16,32 @@ def _get_base_search_domain(self): ("operation_group_ids.user_ids", "=", user.id), ] + def search(self, name_fragment=None): + domain = self._get_base_search_domain() + if name_fragment: + domain.append(("name", "ilike", name_fragment)) + records = self.env[self._expose_model].search(domain) + return {"size": len(records), "data": self._to_json(records)} + def _validator_search(self): return { - "name_fragment": { - "type": "string", - "nullable": True, - "required": False, - } + "name_fragment": {"type": "string", "nullable": True, "required": False} } - def _convert_one_record(self, record): + def _validator_return_search(self): return { - "id": record.id, - "name": record.name, + "size": {"coerce": to_int, "required": True, "type": "integer"}, + "data": { + "type": "list", + "schema": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + }, } + + def _convert_one_record(self, record): + return {"id": record.id, "name": record.name} From cdf4c066802d085770f7948aa2e47e7986706ba7 Mon Sep 17 00:00:00 2001 From: Benoit Date: Tue, 21 Jan 2020 16:24:28 +0100 Subject: [PATCH 028/940] add process name and menu in request header --- shopfloor/controllers/main.py | 22 +++++++++++++++++++++- shopfloor/services/service.py | 22 ++++++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py index 94b9d46c50..4a95642ab9 100644 --- a/shopfloor/controllers/main.py +++ b/shopfloor/controllers/main.py @@ -1,5 +1,6 @@ from odoo.addons.base_rest.controllers import main -import json +from odoo.exceptions import MissingError +from odoo.http import request class ShopfloorController(main.RestController): @@ -7,5 +8,24 @@ class ShopfloorController(main.RestController): _collection_name = "shopfloor.services" _default_auth = "api_key" + @classmethod + def _get_process_from_headers(cls, headers): + process_name = headers.get("HTTP_SERVICE_CTX_PROCESS_NAME") + return process_name + @classmethod + def _get_process_menu_from_headers(cls, headers): + process_menu = headers.get("HTTP_SERVICE_CTX_PROCESS_MENU") + return process_menu + def _get_component_context(self): + """ + This method adds the component context: + * the process name + * the process menu + """ + res = super(ShopfloorController, self)._get_component_context() + headers = request.httprequest.environ + res["process_name"] = self._get_process_from_headers(headers) + res["process_menu"] = self._get_process_menu_from_headers(headers) + return res diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index f52894f558..7d6cdb05dd 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -40,7 +40,7 @@ def _get_openapi_default_parameters(self): demo_api_key = self.env.ref( "shopfloor.api_key_demo", raise_if_not_found=False ).key - defaults.append( + defaults.extend([ { "name": "API-KEY", "in": "header", @@ -49,6 +49,24 @@ def _get_openapi_default_parameters(self): "schema": {"type": "string"}, "style": "simple", "value": demo_api_key, + }, + { + "name": "SERVICE_CTX_PROCESS_NAME", + "in": "header", + "description": "Name of the current process", + "required": True, + "schema": {"type": "string"}, + "style": "simple", + "value": "Put-Away Reach Truck", + }, + { + "name": "SERVICE_CTX_PROCESS_MENU", + "in": "header", + "description": "Name of the current process menu", + "required": True, + "schema": {"type": "string"}, + "style": "simple", + "value": "Put-Away Reach Truck", } - ) + ]) return defaults From 4fd93563f3795897aba6658957f628d9379fceaa Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 16:46:15 +0100 Subject: [PATCH 029/940] Extract pack service in rest api --- shopfloor/services/__init__.py | 3 +- shopfloor/services/pack.py | 124 ++++++++++++++++++++++++ shopfloor/services/shopfloor_service.py | 93 ------------------ 3 files changed, 125 insertions(+), 95 deletions(-) create mode 100644 shopfloor/services/pack.py delete mode 100644 shopfloor/services/shopfloor_service.py diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 60ec641820..1fa6f94a43 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -1,5 +1,4 @@ from . import service -# TODO rename file shop_floor_service to pack -from . import shopfloor_service from . import device from . import menu +from . import pack diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py new file mode 100644 index 0000000000..e0dcd1980e --- /dev/null +++ b/shopfloor/services/pack.py @@ -0,0 +1,124 @@ +from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import Component + + +class ShopfloorPack(Component): + _inherit = "base.shopfloor.service" + _name = "shopfloor.pack" + _usage = "pack" + + def scan(self, pack_name): + pack = self.env["stock.quant.package"].search([("name", "=", pack_name)]) + company = self.env.user.company_id # FIXME add logic to get proper company + # FIXME add logic to get proper warehouse + warehouse = self.env["stock.warehouse"].search([])[0] + picking_type = ( + warehouse.int_type_id + ) # FIXME add logic to get picking type properly + product = pack.quant_ids[ + 0 + ].product_id # FIXME we consider only one product per pack + move_vals = { + "picking_type_id": picking_type.id, + "product_id": product.id, + "location_id": pack.location_id.id, + "location_dest_id": picking_type.default_location_dest_id.id, + "name": product.name, + "product_uom": product.uom_id.id, + "product_uom_qty": pack.quant_ids[0].quantity, + "company_id": company.id, + } + move = self.env["stock.move"].create(move_vals) + move._action_confirm() + self.env["stock.package_level"].create( + { + "package_id": pack.id, + "move_ids": [(6, 0, [move.id])], + "company_id": company.id, + } + ) + move.picking_id.action_assign() + return_vals = { + "name": pack.name, + "location_name": pack.location_id.name, + "location_dest_name": move.move_line_ids[0].location_dest_id.name, + "product_name": move.name, + "picking_name": move.picking_id.name, + "location_id": pack.location_id.id, + "location_dest_id": move.move_line_ids[0].location_dest_id.id, + "move_id": move.id, + # 'allow_change_destination': True, #TODO + } + return return_vals + + def validate(self, move_id, location_name): + move = self.env["stock.move"].browse(move_id) + dest_location = self.env["stock.location"].search( + [("name", "=", location_name)] + ) + if move.move_line_ids[0].location_dest_id.id != dest_location.id: + move.move_line_ids[0].location_dest_id = dest_location.id + move.move_line_ids[0].qty_done = move.move_line_ids[0].product_uom_qty + move.picking_id.button_validate() + return True + + def cancel(self, move_id): + move = self.env["stock.move"].browse(move_id) + move.picking_id.cancel() + return True + + def _validator_cancel(self): + return {"move_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def _validator_validate(self): + return { + "move_id": {"coerce": to_int, "required": True, "type": "integer"}, + "location_name": {"type": "string", "nullable": False, "required": True}, + } + + def _validator_scan(self): + return {"pack_name": {"type": "string", "nullable": False, "required": True}} + + def _validator_return_scan(self): + return {"data": self._record_return_schema} + + def get_by_name(self, pack_name): + """ + Get pack informations + """ + pack = self.env["stock.quant.package"].search( + [("name", "=", pack_name)], + # TODO, is it what we want? error if not found? + limit=1, + ) + return self._to_json(pack)[:1] + + def _validator_get_by_name(self): + return {"pack_name": {"type": "string", "nullable": False, "required": True}} + + def _validator_return_get_by_name(self): + return {"data": self._record_return_schema} + + def _convert_one_record(self, record): + return { + "id": record.id, + "name": record.name, + "location": {"id": record.location_id.id, "name": record.location_id.name}, + } + + @property + def _record_return_schema(self): + return { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "location": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + }, + } diff --git a/shopfloor/services/shopfloor_service.py b/shopfloor/services/shopfloor_service.py deleted file mode 100644 index 3e14289e33..0000000000 --- a/shopfloor/services/shopfloor_service.py +++ /dev/null @@ -1,93 +0,0 @@ -from odoo.addons.component.core import Component -from odoo.addons.base_rest.components.service import to_int - - -# TODO move in a pack service -class ShopfloorService(Component): - _inherit = "base.shopfloor.service" - _name = "shopfloor.service" - _usage = "shopfloor" - - def scan_pack(self, pack_name): - pack = self.env['stock.quant.package'].search([('name', '=', pack_name)]) - company = self.env.user.company_id # FIXME add logic to get proper company - # FIXME add logic to get proper warehouse - warehouse = self.env['stock.warehouse'].search([])[0] - picking_type = warehouse.int_type_id # FIXME add logic to get picking type properly - product = pack.quant_ids[0].product_id # FIXME we consider only one product per pack - move_vals = { - 'picking_type_id': picking_type.id, - 'product_id': product.id, - 'location_id': pack.location_id.id, - 'location_dest_id': picking_type.default_location_dest_id.id, - 'name': product.name, - 'product_uom': product.uom_id.id, - 'product_uom_qty': pack.quant_ids[0].quantity, - 'company_id': company.id, - } - move = self.env['stock.move'].create(move_vals) - move._action_confirm() - pack_level = self.env['stock.package_level'].create({ - 'package_id': pack.id, - 'move_ids': [(6, 0, [move.id])], - 'company_id': company.id, - }) - move.picking_id.action_assign() - return_vals = { - 'name': pack.name, - 'location_name': pack.location_id.name, - 'location_dest_name': move.move_line_ids[0].location_dest_id.name, - 'product_name': move.name, - 'picking_name': move.picking_id.name, - 'location_id': pack.location_id.id, - 'location_dest_id': move.move_line_ids[0].location_dest_id.id, - 'move_id': move.id, -# 'allow_change_destination': True, #TODO - } - return return_vals - - def validate(self, move_id, location_name): - move = self.env['stock.move'].browse(move_id) - dest_location = self.env['stock.location'].search([('name', '=', location_name)]) - if move.move_line_ids[0].location_dest_id.id != dest_location.id: - move.move_line_ids[0].location_dest_id = dest_location.id - move.move_line_ids[0].qty_done = move.move_line_ids[0].product_uom_qty - move.picking_id.button_validate() - return True - - def cancel(self, move_id): - move = self.env['stock.move'].browse(move_id) - move.picking_id.cancel() - return True - - def _validator_validate(self): - return { - "move_id": {"coerce": to_int, "required": True, "type": "integer"}, - "location_name": {"type": "string", "nullable": False, "required": True}, - } - - def _validator_scan_pack(self): - return {"pack_name": {"type": "string", "nullable": False, "required": True}} - - def _validator_cancel(self): - return { - "move_id": {"coerce": to_int, "required": True, "type": "integer"}, - } - - def get_pack(self, pack_name): - """ - Get pack informations - """ - pack = self.env['stock.quant.package'].search([('name', '=', pack_name)]) - return self._to_json(pack) - - def _validator_get_pack(self): - return {"pack_name": {"type": "string", "nullable": False, "required": True}} - - def _to_json(self, pack): - res = { - "id": pack.id, - "name": pack.name, - "location": pack.location_id.name, - } - return res From d2e67927f64c61f7affdc0e74c74d763f1d726a0 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 16:48:38 +0100 Subject: [PATCH 030/940] Add docstrings on rest methods (they are shown in swagger) --- shopfloor/services/device.py | 33 +++++++++++++++++++-------------- shopfloor/services/menu.py | 1 + shopfloor/services/pack.py | 1 + 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/shopfloor/services/device.py b/shopfloor/services/device.py index 8e6ede204a..b2609f4245 100644 --- a/shopfloor/services/device.py +++ b/shopfloor/services/device.py @@ -1,7 +1,7 @@ from odoo import fields -from odoo.addons.component.core import Component from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import Component class ShopfloorDevice(Component): @@ -11,6 +11,7 @@ class ShopfloorDevice(Component): _expose_model = "shopfloor.device" def search(self, name_fragment=None): + """List available devices for current user""" domain = self._get_base_search_domain() if name_fragment: domain.append(("name", "ilike", name_fragment)) @@ -31,11 +32,7 @@ def _get_base_search_domain(self): def _validator_search(self): return { - "name_fragment": { - "type": "string", - "nullable": True, - "required": False, - } + "name_fragment": {"type": "string", "nullable": True, "required": False} } def _validator_return_search(self): @@ -51,13 +48,21 @@ def _validator_return_search(self): "warehouse": { "type": "dict", "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - } - } - } - } - } + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, + }, + }, + }, + }, + }, } def _convert_one_record(self, record): @@ -67,5 +72,5 @@ def _convert_one_record(self, record): "warehouse": { "id": record.warehouse_id.id, "name": record.warehouse_id.name, - } + }, } diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index c4270d1b9a..3fdf5182a0 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -17,6 +17,7 @@ def _get_base_search_domain(self): ] def search(self, name_fragment=None): + """List available menu entries for current user""" domain = self._get_base_search_domain() if name_fragment: domain.append(("name", "ilike", name_fragment)) diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py index e0dcd1980e..bac63d79bb 100644 --- a/shopfloor/services/pack.py +++ b/shopfloor/services/pack.py @@ -8,6 +8,7 @@ class ShopfloorPack(Component): _usage = "pack" def scan(self, pack_name): + """Scan a pack barcode""" pack = self.env["stock.quant.package"].search([("name", "=", pack_name)]) company = self.env.user.company_id # FIXME add logic to get proper company # FIXME add logic to get proper warehouse From 44c5d5b15a58f55fe3d3be92f645755538474b12 Mon Sep 17 00:00:00 2001 From: Benoit Date: Tue, 21 Jan 2020 16:56:31 +0100 Subject: [PATCH 031/940] ref header params make it extendable --- shopfloor/controllers/main.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py index 4a95642ab9..5909552712 100644 --- a/shopfloor/controllers/main.py +++ b/shopfloor/controllers/main.py @@ -8,16 +8,6 @@ class ShopfloorController(main.RestController): _collection_name = "shopfloor.services" _default_auth = "api_key" - @classmethod - def _get_process_from_headers(cls, headers): - process_name = headers.get("HTTP_SERVICE_CTX_PROCESS_NAME") - return process_name - - @classmethod - def _get_process_menu_from_headers(cls, headers): - process_menu = headers.get("HTTP_SERVICE_CTX_PROCESS_MENU") - return process_menu - def _get_component_context(self): """ This method adds the component context: @@ -26,6 +16,8 @@ def _get_component_context(self): """ res = super(ShopfloorController, self)._get_component_context() headers = request.httprequest.environ - res["process_name"] = self._get_process_from_headers(headers) - res["process_menu"] = self._get_process_menu_from_headers(headers) + for k, v in headers.items(): + if k.startswith('HTTP_SERVICE_CTX_'): + key_name = k[17:].lower() + res[key_name] = v return res From 17e2178229cd9776be53eb222a84f9ef48bb9181 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 17:14:42 +0100 Subject: [PATCH 032/940] Fix service test --- shopfloor/controllers/main.py | 6 +-- shopfloor/services/pack.py | 7 ++- shopfloor/services/service.py | 70 +++++++++++++------------ shopfloor/tests/test_putaway.py | 92 ++++++++++++++++++--------------- 4 files changed, 91 insertions(+), 84 deletions(-) diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py index 5909552712..4df8196e6e 100644 --- a/shopfloor/controllers/main.py +++ b/shopfloor/controllers/main.py @@ -1,11 +1,11 @@ -from odoo.addons.base_rest.controllers import main -from odoo.exceptions import MissingError from odoo.http import request +from odoo.addons.base_rest.controllers import main + class ShopfloorController(main.RestController): _root_path = "/shopfloor/" - _collection_name = "shopfloor.services" + _collection_name = "shopfloor.service" _default_auth = "api_key" def _get_component_context(self): diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py index bac63d79bb..bf098599a3 100644 --- a/shopfloor/services/pack.py +++ b/shopfloor/services/pack.py @@ -1,4 +1,4 @@ -from odoo.addons.base_rest.components.service import to_int +from odoo.addons.base_rest.components.service import skip_secure_response, to_int from odoo.addons.component.core import Component @@ -7,6 +7,8 @@ class ShopfloorPack(Component): _name = "shopfloor.pack" _usage = "pack" + # TODO define the return schema and add the validator method + @skip_secure_response def scan(self, pack_name): """Scan a pack barcode""" pack = self.env["stock.quant.package"].search([("name", "=", pack_name)]) @@ -80,9 +82,6 @@ def _validator_validate(self): def _validator_scan(self): return {"pack_name": {"type": "string", "nullable": False, "required": True}} - def _validator_return_scan(self): - return {"data": self._record_return_schema} - def get_by_name(self, pack_name): """ Get pack informations diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 7d6cdb05dd..d19ba3db27 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -1,13 +1,14 @@ from odoo import _ +from odoo.exceptions import MissingError from odoo.osv import expression + from odoo.addons.component.core import AbstractComponent -from odoo.exceptions import MissingError class BaseShopfloorService(AbstractComponent): _inherit = "base.rest.service" _name = "base.shopfloor.service" - _collection = "shopfloor.services" + _collection = "shopfloor.service" _expose_model = None def _get(self, _id): @@ -16,8 +17,7 @@ def _get(self, _id): record = self.env[self._expose_model].search(domain) if not record: raise MissingError( - _("The record %s %s does not exist") - % (self._expose_model, _id) + _("The record %s %s does not exist") % (self._expose_model, _id) ) else: return record @@ -25,7 +25,7 @@ def _get(self, _id): def _get_base_search_domain(self): return [] - def _convert_one_record(record): + def _convert_one_record(self, record): """To implement in service Components""" return {} @@ -40,33 +40,35 @@ def _get_openapi_default_parameters(self): demo_api_key = self.env.ref( "shopfloor.api_key_demo", raise_if_not_found=False ).key - defaults.extend([ - { - "name": "API-KEY", - "in": "header", - "description": "API key for Authorization", - "required": True, - "schema": {"type": "string"}, - "style": "simple", - "value": demo_api_key, - }, - { - "name": "SERVICE_CTX_PROCESS_NAME", - "in": "header", - "description": "Name of the current process", - "required": True, - "schema": {"type": "string"}, - "style": "simple", - "value": "Put-Away Reach Truck", - }, - { - "name": "SERVICE_CTX_PROCESS_MENU", - "in": "header", - "description": "Name of the current process menu", - "required": True, - "schema": {"type": "string"}, - "style": "simple", - "value": "Put-Away Reach Truck", - } - ]) + defaults.extend( + [ + { + "name": "API-KEY", + "in": "header", + "description": "API key for Authorization", + "required": True, + "schema": {"type": "string"}, + "style": "simple", + "value": demo_api_key, + }, + { + "name": "SERVICE_CTX_PROCESS_NAME", + "in": "header", + "description": "Name of the current process", + "required": True, + "schema": {"type": "string"}, + "style": "simple", + "value": "Put-Away Reach Truck", + }, + { + "name": "SERVICE_CTX_PROCESS_MENU", + "in": "header", + "description": "Name of the current process menu", + "required": True, + "schema": {"type": "string"}, + "style": "simple", + "value": "Put-Away Reach Truck", + }, + ] + ) return defaults diff --git a/shopfloor/tests/test_putaway.py b/shopfloor/tests/test_putaway.py index 45b87743d6..53c325cb0c 100644 --- a/shopfloor/tests/test_putaway.py +++ b/shopfloor/tests/test_putaway.py @@ -2,55 +2,61 @@ class PutawayCase(CommonCase): - def setUp(self, *args, **kwargs): super(PutawayCase, self).setUp(*args, **kwargs) - in_location = self.env.ref('stock.stock_location_company').child_ids[0] - stock_location = self.env.ref('stock.stock_location_stock') - self.productA = self.env['product.product'].create({'name': 'Product A', 'type': 'product'}) - self.packA = self.env['stock.quant.package'].create({ - 'location_id': in_location.id - }) - self.quantA = self.env['stock.quant'].create({ - 'product_id': self.productA.id, - 'location_id': in_location.id, - 'quantity': 1, - 'package_id': self.packA.id, - }) - self.env['stock.putaway.rule'].create({ - 'product_id': self.productA.id, - 'location_in_id': stock_location.id, - 'location_out_id': stock_location.child_ids[0].id, - }) - with self.work_on_services( - ) as work: - self.service = work.component(usage="shopfloor") + in_location = self.env.ref("stock.stock_location_company").child_ids[0] + stock_location = self.env.ref("stock.stock_location_stock") + self.productA = self.env["product.product"].create( + {"name": "Product A", "type": "product"} + ) + self.packA = self.env["stock.quant.package"].create( + {"location_id": in_location.id} + ) + self.quantA = self.env["stock.quant"].create( + { + "product_id": self.productA.id, + "location_id": in_location.id, + "quantity": 1, + "package_id": self.packA.id, + } + ) + self.env["stock.putaway.rule"].create( + { + "product_id": self.productA.id, + "location_in_id": stock_location.id, + "location_out_id": stock_location.child_ids[0].id, + } + ) + + with self.work_on_services() as work: + self.service = work.component(usage="pack") def test_scan_pack(self): pack_name = self.packA.name - params = { - 'pack_name': pack_name, - } - response = self.service.dispatch("scan_pack", params=params) - move_id = response['move_id'] - params ={ - 'move_id': move_id, - 'location_name': response['location_dest_name'], - } - location_dest_id = self.env['stock.location'].search([ - ('name', '=', params['location_name']) - ]).id - new_loc_quant = self.env['stock.quant'].search([ - ('product_id', '=', self.productA.id), - ('location_id', '=', location_dest_id) - ]) + params = {"pack_name": pack_name} + response = self.service.dispatch("scan", params=params) + move_id = response["move_id"] + params = {"move_id": move_id, "location_name": response["location_dest_name"]} + location_dest_id = ( + self.env["stock.location"] + .search([("name", "=", params["location_name"])]) + .id + ) + new_loc_quant = self.env["stock.quant"].search( + [ + ("product_id", "=", self.productA.id), + ("location_id", "=", location_dest_id), + ] + ) self.assertFalse(new_loc_quant) response = self.service.dispatch("validate", params=params) - new_loc_quant = self.env['stock.quant'].search([ - ('product_id', '=', self.productA.id), - ('location_id', '=', location_dest_id) - ]) - move = self.env['stock.move'].browse(move_id) - self.assertEquals(move.state, 'done') + new_loc_quant = self.env["stock.quant"].search( + [ + ("product_id", "=", self.productA.id), + ("location_id", "=", location_dest_id), + ] + ) + move = self.env["stock.move"].browse(move_id) + self.assertEquals(move.state, "done") self.assertEquals(self.quantA.quantity, 0) self.assertEquals(new_loc_quant.quantity, move.product_uom_qty) From 729d4e7fd7c9a4af374bc7cb5bc35912c45111cf Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 17:20:33 +0100 Subject: [PATCH 033/940] Apply pre-commit run -a --- shopfloor/__manifest__.py | 11 +++-------- shopfloor/controllers/main.py | 2 +- shopfloor/models/res_users.py | 4 +--- shopfloor/models/shopfloor_device.py | 12 ++++++++---- shopfloor/models/shopfloor_menu.py | 10 ++++------ shopfloor/models/shopfloor_operation_group.py | 4 +--- shopfloor/models/shopfloor_process.py | 2 +- shopfloor/models/stock_picking_type.py | 4 ++-- shopfloor/tests/common.py | 9 ++++----- 9 files changed, 25 insertions(+), 33 deletions(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index f94a70f42e..b4043333aa 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # © 2020 Camptocamp, Akretion, BCIM # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). @@ -10,13 +9,9 @@ "category": "Inventory", "website": "https://odoo-community.org", "author": "Akretion, BCIM, Camptocamp, Odoo Community Association (OCA)", - "licence": "AGPL-3", + "license": "AGPL-3", "application": True, - "depends": [ - "stock", - "base_rest", - "auth_api_key", - ], + "depends": ["stock", "base_rest", "auth_api_key"], "data": [ "security/ir.model.access.csv", "views/shopfloor_operation_group.xml", @@ -32,5 +27,5 @@ "demo/shopfloor_menu_demo.xml", "demo/shopfloor_operation_group_demo.xml", "demo/shopfloor_device_demo.xml", - ] + ], } diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py index 4df8196e6e..34be9aae06 100644 --- a/shopfloor/controllers/main.py +++ b/shopfloor/controllers/main.py @@ -17,7 +17,7 @@ def _get_component_context(self): res = super(ShopfloorController, self)._get_component_context() headers = request.httprequest.environ for k, v in headers.items(): - if k.startswith('HTTP_SERVICE_CTX_'): + if k.startswith("HTTP_SERVICE_CTX_"): key_name = k[17:].lower() res[key_name] = v return res diff --git a/shopfloor/models/res_users.py b/shopfloor/models/res_users.py index e86bb27d2c..6838b582ee 100644 --- a/shopfloor/models/res_users.py +++ b/shopfloor/models/res_users.py @@ -6,7 +6,5 @@ class ResUsers(models.Model): # in practice, it's a one2one shopfloor_device_ids = fields.One2many( - comodel_name="shopfloor.device", - inverse_name="user_id", - readonly=True, + comodel_name="shopfloor.device", inverse_name="user_id", readonly=True ) diff --git a/shopfloor/models/shopfloor_device.py b/shopfloor/models/shopfloor_device.py index f84c78322e..25acbe2dc6 100644 --- a/shopfloor/models/shopfloor_device.py +++ b/shopfloor/models/shopfloor_device.py @@ -22,18 +22,22 @@ class ShopfloorDevice(models.Model): copy=False, help="Optional user using the device. The device will" "use this configuration when the users logs in the client " - "application." + "application.", ) shopfloor_current_process = fields.Char(readonly=True) shopfloor_last_call = fields.Char(readonly=True) - shopfloor_picking_id = fields.Many2one('stock.picking', readonly=True) + shopfloor_picking_id = fields.Many2one("stock.picking", readonly=True) _sql_constraints = [ - ('user_id_uniq', 'unique(user_id)', 'A user can be assigned to only one device.'), + ( + "user_id_uniq", + "unique(user_id)", + "A user can be assigned to only one device.", + ) ] @api.model def _default_warehouse_id(self): - wh = self.env['stock.warehouse'].search([]) + wh = self.env["stock.warehouse"].search([]) if len(wh) == 1: return wh diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 48f836bd37..f95d37c382 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -2,15 +2,13 @@ class ShopfloorMenu(models.Model): - _name = 'shopfloor.menu' + _name = "shopfloor.menu" _description = "Menu displayed in the scanner application" - _order = 'sequence' + _order = "sequence" name = fields.Char(translate=True) sequence = fields.Integer() operation_group_ids = fields.Many2many( - 'shopfloor.operation.group', - string="Groups", - help="visible for these groups", + "shopfloor.operation.group", string="Groups", help="visible for these groups" ) - process_id = fields.Many2one('shopfloor.process', name="Process") + process_id = fields.Many2one("shopfloor.process", name="Process") diff --git a/shopfloor/models/shopfloor_operation_group.py b/shopfloor/models/shopfloor_operation_group.py index 1d78aeeb83..0dc47fe272 100644 --- a/shopfloor/models/shopfloor_operation_group.py +++ b/shopfloor/models/shopfloor_operation_group.py @@ -8,7 +8,5 @@ class ShopfloorOperationGroup(models.Model): name = fields.Char(required=True) user_ids = fields.Many2many("res.users", string="Members") menu_ids = fields.Many2many( - 'shopfloor.menu', - string="Menus", - help="Can see these menus", + "shopfloor.menu", string="Menus", help="Can see these menus" ) diff --git a/shopfloor/models/shopfloor_process.py b/shopfloor/models/shopfloor_process.py index accbc02155..b1daa88f4d 100644 --- a/shopfloor/models/shopfloor_process.py +++ b/shopfloor/models/shopfloor_process.py @@ -7,5 +7,5 @@ class ShopfloorProcess(models.Model): name = fields.Char(required=True) picking_type_ids = fields.One2many( - 'stock.picking.type', 'process_id', string="Operation types" + "stock.picking.type", "process_id", string="Operation types" ) diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py index 206b68c7fe..808bf50552 100644 --- a/shopfloor/models/stock_picking_type.py +++ b/shopfloor/models/stock_picking_type.py @@ -2,6 +2,6 @@ class StockPickingType(models.Model): - _inherit = 'stock.picking.type' + _inherit = "stock.picking.type" - process_id = fields.Many2one('shopfloor.process', string="Process") + process_id = fields.Many2one("shopfloor.process", string="Process") diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index f85f3c3f94..5ae4ec521d 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -1,17 +1,16 @@ from contextlib import contextmanager + +from odoo.tests.common import SavepointCase + from odoo.addons.base_rest.controllers.main import _PseudoCollection from odoo.addons.component.core import WorkContext -from odoo.tests.common import SavepointCase class CommonCase(SavepointCase): - @contextmanager def work_on_services(self, **params): params = params or {} collection = _PseudoCollection("shopfloor.service", self.env) yield WorkContext( - model_name="rest.service.registration", - collection=collection, - **params + model_name="rest.service.registration", collection=collection, **params ) From 4289176d46e3c3806d6763aee5aff9ff8b23f0b5 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 22 Jan 2020 12:54:45 +0100 Subject: [PATCH 034/940] Use expression.AND for combining domains --- shopfloor/services/device.py | 20 ++++++++++++++------ shopfloor/services/menu.py | 18 +++++++++++++----- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/shopfloor/services/device.py b/shopfloor/services/device.py index b2609f4245..f3605592bc 100644 --- a/shopfloor/services/device.py +++ b/shopfloor/services/device.py @@ -1,4 +1,5 @@ from odoo import fields +from odoo.osv import expression from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -20,15 +21,22 @@ def search(self, name_fragment=None): def _get_base_search_domain(self): # shopfloor_device_ids is a one2one + base_domain = super()._get_base_search_domain() user = self.env.user assigned_device = fields.first(user.shopfloor_device_ids) if assigned_device: - return [("id", "=", assigned_device.id)] - return [ - "|", - ("operation_group_ids", "=", False), - ("operation_group_ids.user_ids", "=", user.id), - ] + return expression.AND([base_domain, [("id", "=", assigned_device.id)]]) + + return expression.AND( + [ + base_domain, + [ + "|", + ("operation_group_ids", "=", False), + ("operation_group_ids.user_ids", "=", user.id), + ], + ] + ) def _validator_search(self): return { diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index 3fdf5182a0..8f8b863140 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -1,3 +1,5 @@ +from odoo.osv import expression + from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -9,12 +11,18 @@ class ShopfloorMenu(Component): _expose_model = "shopfloor.menu" def _get_base_search_domain(self): + base_domain = super()._get_base_search_domain() user = self.env.user - return [ - "|", - ("operation_group_ids", "=", False), - ("operation_group_ids.user_ids", "=", user.id), - ] + return expression.AND( + [ + base_domain, + [ + "|", + ("operation_group_ids", "=", False), + ("operation_group_ids.user_ids", "=", user.id), + ], + ] + ) def search(self, name_fragment=None): """List available menu entries for current user""" From 6d25facf50ce2d207cefd698a0f70284ac2b2227 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 22 Jan 2020 12:55:13 +0100 Subject: [PATCH 035/940] Add listing of locations --- shopfloor/services/__init__.py | 1 + shopfloor/services/location.py | 70 ++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 shopfloor/services/location.py diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 1fa6f94a43..7a379146da 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -2,3 +2,4 @@ from . import device from . import menu from . import pack +from . import location diff --git a/shopfloor/services/location.py b/shopfloor/services/location.py new file mode 100644 index 0000000000..2ed25b05a1 --- /dev/null +++ b/shopfloor/services/location.py @@ -0,0 +1,70 @@ +from odoo.osv import expression + +from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import Component + + +class ShopfloorLocation(Component): + _inherit = "base.shopfloor.service" + _name = "shopfloor.location" + _usage = "location" + _expose_model = "stock.location" + + def search(self, name_fragment=None): + """List available devices for current user""" + domain = self._get_base_search_domain() + if name_fragment: + domain = expression.AND( + [ + domain, + [ + "|", + ("name", "ilike", name_fragment), + ("barcode", "ilike", name_fragment), + ], + ] + ) + records = self.env[self._expose_model].search(domain) + return {"size": len(records), "data": self._to_json(records)} + + def _get_base_search_domain(self): + # TODO add filter on warehouse of the device + return super()._get_base_search_domain() + + def _validator_search(self): + return { + "name_fragment": {"type": "string", "nullable": True, "required": False} + } + + def _validator_return_search(self): + return { + "size": {"coerce": to_int, "required": True, "type": "integer"}, + "data": { + "type": "list", + "schema": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "complete_name": { + "type": "string", + "nullable": False, + "required": True, + }, + "barcode": { + "type": "string", + "nullable": False, + "required": False, + }, + }, + }, + }, + } + + def _convert_one_record(self, record): + return { + "id": record.id, + "name": record.name, + "complete_name": record.complete_name, + "barcode": record.barcode or "", + } From b2d3a67aad1180d59fb47c48e2e98c5968c08466 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 22 Jan 2020 13:20:09 +0100 Subject: [PATCH 036/940] Rename 'device' to 'profile' We don't want to force having one device record per hardware device, since adding a new scanner would require to create a new device on Odoo. Instead, we prefer to have profiles, which has to be selected at loading of the client application. The profile will hold the configuration for the interactions (warehouse, but maybe later printer, ...). The allowed profiles can be restricted to groups. A user can be forced to use a profile. --- shopfloor/__manifest__.py | 4 +-- ...ce_demo.xml => shopfloor_profile_demo.xml} | 6 ++-- shopfloor/models/__init__.py | 2 +- shopfloor/models/res_users.py | 4 +-- ...opfloor_device.py => shopfloor_profile.py} | 20 +++++------- shopfloor/security/ir.model.access.csv | 4 +-- shopfloor/services/__init__.py | 2 +- shopfloor/services/location.py | 4 +-- shopfloor/services/{device.py => profile.py} | 31 +++++++++++++------ shopfloor/views/menus.xml | 2 +- ..._views.xml => shopfloor_profile_views.xml} | 29 +++++++---------- 11 files changed, 56 insertions(+), 52 deletions(-) rename shopfloor/demo/{shopfloor_device_demo.xml => shopfloor_profile_demo.xml} (69%) rename shopfloor/models/{shopfloor_device.py => shopfloor_profile.py} (56%) rename shopfloor/services/{device.py => profile.py} (75%) rename shopfloor/views/{shopfloor_device_views.xml => shopfloor_profile_views.xml} (59%) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index b4043333aa..bfd999f5f0 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -18,7 +18,7 @@ "views/shopfloor_menu.xml", "views/shopfloor_process.xml", "views/stock_picking_type.xml", - "views/shopfloor_device_views.xml", + "views/shopfloor_profile_views.xml", "views/menus.xml", ], "demo": [ @@ -26,6 +26,6 @@ "demo/shopfloor_process_demo.xml", "demo/shopfloor_menu_demo.xml", "demo/shopfloor_operation_group_demo.xml", - "demo/shopfloor_device_demo.xml", + "demo/shopfloor_profile_demo.xml", ], } diff --git a/shopfloor/demo/shopfloor_device_demo.xml b/shopfloor/demo/shopfloor_profile_demo.xml similarity index 69% rename from shopfloor/demo/shopfloor_device_demo.xml rename to shopfloor/demo/shopfloor_profile_demo.xml index bd9ed3f250..918ad669e2 100644 --- a/shopfloor/demo/shopfloor_device_demo.xml +++ b/shopfloor/demo/shopfloor_profile_demo.xml @@ -1,18 +1,18 @@ - + Highbay Truck 1 - + Highbay Truck 2 - + Shelf 1 diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index 6878290486..1aa3c9fbc8 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -2,5 +2,5 @@ from . import shopfloor_operation_group from . import shopfloor_process from . import stock_picking_type -from . import shopfloor_device +from . import shopfloor_profile from . import res_users diff --git a/shopfloor/models/res_users.py b/shopfloor/models/res_users.py index 6838b582ee..8c136f725e 100644 --- a/shopfloor/models/res_users.py +++ b/shopfloor/models/res_users.py @@ -5,6 +5,6 @@ class ResUsers(models.Model): _inherit = "res.users" # in practice, it's a one2one - shopfloor_device_ids = fields.One2many( - comodel_name="shopfloor.device", inverse_name="user_id", readonly=True + shopfloor_profile_ids = fields.One2many( + comodel_name="shopfloor.profile", inverse_name="user_id", readonly=True ) diff --git a/shopfloor/models/shopfloor_device.py b/shopfloor/models/shopfloor_profile.py similarity index 56% rename from shopfloor/models/shopfloor_device.py rename to shopfloor/models/shopfloor_profile.py index 25acbe2dc6..f18d813b31 100644 --- a/shopfloor/models/shopfloor_device.py +++ b/shopfloor/models/shopfloor_profile.py @@ -1,9 +1,9 @@ from odoo import api, fields, models -class ShopfloorDevice(models.Model): - _name = "shopfloor.device" - _description = "Shopfloor device settings" +class ShopfloorProfile(models.Model): + _name = "shopfloor.profile" + _description = "Shopfloor profile settings" name = fields.Char(required=True) warehouse_id = fields.Many2one( @@ -14,25 +14,21 @@ class ShopfloorDevice(models.Model): operation_group_ids = fields.Many2many( "shopfloor.operation.group", string="Shopfloor Operation Groups", - help="When unset, all users can use the device. When set," - "only users belonging to at least one group can use the device.", + help="When unset, all users can use the profile. When set," + "only users belonging to at least one group can use the profile.", ) user_id = fields.Many2one( "res.users", copy=False, - help="Optional user using the device. The device will" - "use this configuration when the users logs in the client " - "application.", + help="Optional user using the profile. When a profile has a" + "user assigned to it, the user is not allowed to use another profile.", ) - shopfloor_current_process = fields.Char(readonly=True) - shopfloor_last_call = fields.Char(readonly=True) - shopfloor_picking_id = fields.Many2one("stock.picking", readonly=True) _sql_constraints = [ ( "user_id_uniq", "unique(user_id)", - "A user can be assigned to only one device.", + "A user can be assigned to only one profile.", ) ] diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv index 6db6c86799..a616c579ee 100644 --- a/shopfloor/security/ir.model.access.csv +++ b/shopfloor/security/ir.model.access.csv @@ -3,6 +3,6 @@ "access_shopfloor_menu_stock_manager","shopfloor menu inventory manager","model_shopfloor_menu","stock.group_stock_manager",1,1,1,1 "access_shopfloor_operation_group_users","shopfloor operation group","model_shopfloor_operation_group",,1,0,0,0 "access_shopfloor_operation_group_stock_manager","shopfloor operation group inventory manager","model_shopfloor_operation_group","stock.group_stock_manager",1,1,1,1 -"access_shopfloor_device_users","shopfloor device","model_shopfloor_device",,1,0,0,0 -"access_shopfloor_device_stock_manager","shopfloor device inventory manager","model_shopfloor_device","stock.group_stock_manager",1,1,1,1 +"access_shopfloor_profile_users","shopfloor profile","model_shopfloor_profile",,1,0,0,0 +"access_shopfloor_profile_stock_manager","shopfloor profile inventory manager","model_shopfloor_profile","stock.group_stock_manager",1,1,1,1 "access_shopfloor_process_users","shopfloor process","model_shopfloor_process",,1,0,0,0 diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 7a379146da..f72a999243 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -1,5 +1,5 @@ from . import service -from . import device +from . import profile from . import menu from . import pack from . import location diff --git a/shopfloor/services/location.py b/shopfloor/services/location.py index 2ed25b05a1..8f65a1febb 100644 --- a/shopfloor/services/location.py +++ b/shopfloor/services/location.py @@ -11,7 +11,7 @@ class ShopfloorLocation(Component): _expose_model = "stock.location" def search(self, name_fragment=None): - """List available devices for current user""" + """List available locations for current user""" domain = self._get_base_search_domain() if name_fragment: domain = expression.AND( @@ -28,7 +28,7 @@ def search(self, name_fragment=None): return {"size": len(records), "data": self._to_json(records)} def _get_base_search_domain(self): - # TODO add filter on warehouse of the device + # TODO add filter on warehouse of the current profile return super()._get_base_search_domain() def _validator_search(self): diff --git a/shopfloor/services/device.py b/shopfloor/services/profile.py similarity index 75% rename from shopfloor/services/device.py rename to shopfloor/services/profile.py index f3605592bc..001a31a52e 100644 --- a/shopfloor/services/device.py +++ b/shopfloor/services/profile.py @@ -5,14 +5,27 @@ from odoo.addons.component.core import Component -class ShopfloorDevice(Component): +class ShopfloorProfile(Component): + """Profile storing the configuration for the interaction from the client. + + A client application must use a profile, passed to every request in the + HTTP header (TODO put the name of the header). + + The list of profiles available for a user is restricted by 2 things: + + * If the profile has operation groups, the profile can be used only + if the user is at least in one of these groups. + * If the user has an assigned profile, the user can use only this profile. + + """ + _inherit = "base.shopfloor.service" - _name = "shopfloor.device" - _usage = "device" - _expose_model = "shopfloor.device" + _name = "shopfloor.profile" + _usage = "profile" + _expose_model = "shopfloor.profile" def search(self, name_fragment=None): - """List available devices for current user""" + """List available profiles for current user""" domain = self._get_base_search_domain() if name_fragment: domain.append(("name", "ilike", name_fragment)) @@ -20,12 +33,12 @@ def search(self, name_fragment=None): return {"size": len(records), "data": self._to_json(records)} def _get_base_search_domain(self): - # shopfloor_device_ids is a one2one + # shopfloor_profile_ids is a one2one in practice. base_domain = super()._get_base_search_domain() user = self.env.user - assigned_device = fields.first(user.shopfloor_device_ids) - if assigned_device: - return expression.AND([base_domain, [("id", "=", assigned_device.id)]]) + assigned_profile = fields.first(user.shopfloor_profile_ids) + if assigned_profile: + return expression.AND([base_domain, [("id", "=", assigned_profile.id)]]) return expression.AND( [ diff --git a/shopfloor/views/menus.xml b/shopfloor/views/menus.xml index f6bc4f105d..71f19f9f78 100644 --- a/shopfloor/views/menus.xml +++ b/shopfloor/views/menus.xml @@ -4,5 +4,5 @@ - + diff --git a/shopfloor/views/shopfloor_device_views.xml b/shopfloor/views/shopfloor_profile_views.xml similarity index 59% rename from shopfloor/views/shopfloor_device_views.xml rename to shopfloor/views/shopfloor_profile_views.xml index 1f6dc4cc25..26720fae2b 100644 --- a/shopfloor/views/shopfloor_device_views.xml +++ b/shopfloor/views/shopfloor_profile_views.xml @@ -1,8 +1,8 @@ - - shopfloor.device tree - shopfloor.device + + shopfloor.profile tree + shopfloor.profile @@ -13,9 +13,9 @@ - - shopfloor.device form - shopfloor.device + + shopfloor.profile form + shopfloor.profile
@@ -28,20 +28,15 @@ - - - - -
- - shopfloor.device search - shopfloor.device + + shopfloor.profile search + shopfloor.profile @@ -52,9 +47,9 @@ - - Devices - shopfloor.device + + Profiles + shopfloor.profile ir.actions.act_window tree,form From 8092992eda8f65f2bfc0c2820b842ecb42ac9ecd Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 22 Jan 2020 13:41:36 +0100 Subject: [PATCH 037/940] Fix initialization of tests Components have to be initalized (when running pytest-odoo, we don't need it, but to run odoo tests, we have to use the ComponentMixin to initialize the system). --- shopfloor/tests/common.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 5ae4ec521d..1f14f5ac93 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -4,9 +4,14 @@ from odoo.addons.base_rest.controllers.main import _PseudoCollection from odoo.addons.component.core import WorkContext +from odoo.addons.component.tests.common import ComponentMixin -class CommonCase(SavepointCase): +class CommonCase(SavepointCase, ComponentMixin): + + # by default disable tracking suite-wise, it's a time saver :) + tracking_disable = True + @contextmanager def work_on_services(self, **params): params = params or {} @@ -14,3 +19,20 @@ def work_on_services(self, **params): yield WorkContext( model_name="rest.service.registration", collection=collection, **params ) + + # pylint: disable=method-required-super + # super is called "the old-style way" to call both super classes in the + # order we want + def setUp(self): + # Have to initialize both odoo env and stuff + + # the Component registry of the mixin + SavepointCase.setUp(self) + ComponentMixin.setUp(self) + + @classmethod + def setUpClass(cls): + super(CommonCase, cls).setUpClass() + cls.env = cls.env( + context=dict(cls.env.context, tracking_disable=cls.tracking_disable) + ) + cls.setUpComponent() From 98efc9342340d05f3f639b528900a0c1cf558a92 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 22 Jan 2020 15:48:07 +0100 Subject: [PATCH 038/940] Add description on services (displayed on swagger) --- shopfloor/services/location.py | 3 +++ shopfloor/services/menu.py | 9 +++++++++ shopfloor/services/pack.py | 3 +++ shopfloor/services/profile.py | 5 +++-- shopfloor/services/service.py | 2 ++ 5 files changed, 20 insertions(+), 2 deletions(-) diff --git a/shopfloor/services/location.py b/shopfloor/services/location.py index 8f65a1febb..17aac7be05 100644 --- a/shopfloor/services/location.py +++ b/shopfloor/services/location.py @@ -5,10 +5,13 @@ class ShopfloorLocation(Component): + """Expose Stock Locations data for the current warehouse.""" + _inherit = "base.shopfloor.service" _name = "shopfloor.location" _usage = "location" _expose_model = "stock.location" + _description = __doc__ def search(self, name_fragment=None): """List available locations for current user""" diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index 8f8b863140..e9d7fbfc50 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -5,10 +5,19 @@ class ShopfloorMenu(Component): + """ + Menu Structure for the client application. + + The list of menus is restricted by the operation groups. A menu without + groups is visible for all users, a menu with group(s) is visible if the + user is in at least one of the groups. + """ + _inherit = "base.shopfloor.service" _name = "shopfloor.menu" _usage = "menu" _expose_model = "shopfloor.menu" + _description = __doc__ def _get_base_search_domain(self): base_domain = super()._get_base_search_domain() diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py index bf098599a3..32f7a1b20f 100644 --- a/shopfloor/services/pack.py +++ b/shopfloor/services/pack.py @@ -3,9 +3,12 @@ class ShopfloorPack(Component): + """Expose data about Stock Quant Packages""" + _inherit = "base.shopfloor.service" _name = "shopfloor.pack" _usage = "pack" + _description = __doc__ # TODO define the return schema and add the validator method @skip_secure_response diff --git a/shopfloor/services/profile.py b/shopfloor/services/profile.py index 001a31a52e..92f9d3eac3 100644 --- a/shopfloor/services/profile.py +++ b/shopfloor/services/profile.py @@ -6,7 +6,8 @@ class ShopfloorProfile(Component): - """Profile storing the configuration for the interaction from the client. + """ + Profile storing the configuration for the interaction from the client. A client application must use a profile, passed to every request in the HTTP header (TODO put the name of the header). @@ -16,13 +17,13 @@ class ShopfloorProfile(Component): * If the profile has operation groups, the profile can be used only if the user is at least in one of these groups. * If the user has an assigned profile, the user can use only this profile. - """ _inherit = "base.shopfloor.service" _name = "shopfloor.profile" _usage = "profile" _expose_model = "shopfloor.profile" + _description = __doc__ def search(self, name_fragment=None): """List available profiles for current user""" diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index d19ba3db27..d48cfe3f78 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -6,6 +6,8 @@ class BaseShopfloorService(AbstractComponent): + """Base class for REST services""" + _inherit = "base.rest.service" _name = "base.shopfloor.service" _collection = "shopfloor.service" From 03b31209b2d2e3b411adbf269fde8ef69d58db78 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 22 Jan 2020 16:50:27 +0100 Subject: [PATCH 039/940] Add a code for the process The code will be used for: * Customize the view depending of the code (for instance, we could have a field "allow to replace a lot" on a "Single Pack Transfer" process, and a second "Single Pack Transfer" without allowing this) * the code is the identifier of the method in the REST API (/shopfloor//) --- shopfloor/demo/shopfloor_menu_demo.xml | 6 ++++++ shopfloor/demo/shopfloor_process_demo.xml | 6 ++++++ shopfloor/demo/shopfloor_profile_demo.xml | 10 ++-------- shopfloor/models/shopfloor_menu.py | 3 ++- shopfloor/models/shopfloor_process.py | 8 ++++++++ shopfloor/services/__init__.py | 2 ++ shopfloor/services/menu.py | 7 ++++++- shopfloor/services/single_pack_putaway.py | 10 ++++++++++ shopfloor/services/single_pack_transfer.py | 10 ++++++++++ shopfloor/views/shopfloor_process.xml | 3 +++ 10 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 shopfloor/services/single_pack_putaway.py create mode 100644 shopfloor/services/single_pack_transfer.py diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml index 7d643a0e1f..6d0d511f79 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -6,4 +6,10 @@ + + Single Pallet Transfer + 20 + + +
diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml index 30964d7b23..3f869cb457 100644 --- a/shopfloor/demo/shopfloor_process_demo.xml +++ b/shopfloor/demo/shopfloor_process_demo.xml @@ -2,6 +2,12 @@ Put-Away Reach Truck + single_pack_putaway + + + + Single Pallet Transfer + single_pack_transfer diff --git a/shopfloor/demo/shopfloor_profile_demo.xml b/shopfloor/demo/shopfloor_profile_demo.xml index 918ad669e2..5ef9df4179 100644 --- a/shopfloor/demo/shopfloor_profile_demo.xml +++ b/shopfloor/demo/shopfloor_profile_demo.xml @@ -1,13 +1,7 @@ - - Highbay Truck 1 - - - - - - Highbay Truck 2 + + Highbay Truck diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index f95d37c382..0f0a56e2bb 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -11,4 +11,5 @@ class ShopfloorMenu(models.Model): operation_group_ids = fields.Many2many( "shopfloor.operation.group", string="Groups", help="visible for these groups" ) - process_id = fields.Many2one("shopfloor.process", name="Process") + process_id = fields.Many2one("shopfloor.process", name="Process", required=True) + process_code = fields.Selection(related="process_id.code", readonly=True) diff --git a/shopfloor/models/shopfloor_process.py b/shopfloor/models/shopfloor_process.py index b1daa88f4d..db4759c2d0 100644 --- a/shopfloor/models/shopfloor_process.py +++ b/shopfloor/models/shopfloor_process.py @@ -6,6 +6,14 @@ class ShopfloorProcess(models.Model): _description = "a process to be run from the scanners" name = fields.Char(required=True) + code = fields.Selection(selection="_selection_code", required=True) picking_type_ids = fields.One2many( "stock.picking.type", "process_id", string="Operation types" ) + + def _selection_code(self): + return [ + # these must match a REST service + ("single_pack_putaway", "Single Pack Put-away"), + ("single_pack_transfer", "Single Pack Transfer"), + ] diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index f72a999243..bb82a1df2e 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -3,3 +3,5 @@ from . import menu from . import pack from . import location +from . import single_pack_putaway +from . import single_pack_transfer diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index e9d7fbfc50..220003a559 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -56,10 +56,15 @@ def _validator_return_search(self): "schema": { "id": {"coerce": to_int, "required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, + "process": { + "type": "string", + "nullable": False, + "required": True, + }, }, }, }, } def _convert_one_record(self, record): - return {"id": record.id, "name": record.name} + return {"id": record.id, "name": record.name, "process": record.process_code} diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py new file mode 100644 index 0000000000..f46024bd0d --- /dev/null +++ b/shopfloor/services/single_pack_putaway.py @@ -0,0 +1,10 @@ +from odoo.addons.component.core import Component + + +class SinglePackPutaway(Component): + """Methods for the Single Pack Put-Away Process""" + + _inherit = "base.shopfloor.service" + _name = "shopfloor.single.pack.putaway" + _usage = "single_pack_putaway" + _description = __doc__ diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py new file mode 100644 index 0000000000..a22659a1cb --- /dev/null +++ b/shopfloor/services/single_pack_transfer.py @@ -0,0 +1,10 @@ +from odoo.addons.component.core import Component + + +class SinglePackTransfer(Component): + """Methods for the Single Pack Transfer Process""" + + _inherit = "base.shopfloor.service" + _name = "shopfloor.single.pack.transfer" + _usage = "single_pack_transfer" + _description = __doc__ diff --git a/shopfloor/views/shopfloor_process.xml b/shopfloor/views/shopfloor_process.xml index 67afa35bc8..db58ed0b34 100644 --- a/shopfloor/views/shopfloor_process.xml +++ b/shopfloor/views/shopfloor_process.xml @@ -6,6 +6,7 @@ + @@ -20,6 +21,7 @@ + @@ -36,6 +38,7 @@ + From 75734d3089c1512bc9572b0ff1f16ac62aae90bf Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 22 Jan 2020 17:27:15 +0100 Subject: [PATCH 040/940] Rework HTTP Header parameters * We need the menu id and the profile id, from there, we can find the configuration for the processes * Get recordsets in the work context, so we can conveniently use them from service components (and no actual SELECT is issued until we use them) * Return 400 BadRequest if a parameter is missing, but do not check existence of the record to avoid queries before we actually need the records, considering we won't need them for every request (a method toinspect a pack for instance does not care about the menu or the profile) --- shopfloor/controllers/main.py | 23 +++++++++++++++++------ shopfloor/services/service.py | 16 ++++++++-------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py index 34be9aae06..297b0e4033 100644 --- a/shopfloor/controllers/main.py +++ b/shopfloor/controllers/main.py @@ -1,3 +1,5 @@ +from werkzeug.exceptions import BadRequest + from odoo.http import request from odoo.addons.base_rest.controllers import main @@ -11,13 +13,22 @@ class ShopfloorController(main.RestController): def _get_component_context(self): """ This method adds the component context: - * the process name - * the process menu + * the shopfloor menu in ``self.work.menu`` from the service Components + * the shopfloor profile in ``self.work.profile`` from the service + Components """ res = super(ShopfloorController, self)._get_component_context() headers = request.httprequest.environ - for k, v in headers.items(): - if k.startswith("HTTP_SERVICE_CTX_"): - key_name = k[17:].lower() - res[key_name] = v + try: + menu_id = int(headers.get("HTTP_SERVICE_CTX_MENU_ID")) + except (TypeError, ValueError): + raise BadRequest("HTTP_SERVICE_CTX_MENU_ID must be set with an integer") + res["menu"] = request.env["shopfloor.menu"].browse(menu_id) + + try: + profile_id = int(headers.get("HTTP_SERVICE_CTX_PROFILE_ID")) + except (TypeError, ValueError): + raise BadRequest("HTTP_SERVICE_CTX_PROFILE_ID must be set with an integer") + res["profile"] = request.env["shopfloor.profile"].browse(profile_id) + return res diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index d48cfe3f78..2363b60af4 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -54,22 +54,22 @@ def _get_openapi_default_parameters(self): "value": demo_api_key, }, { - "name": "SERVICE_CTX_PROCESS_NAME", + "name": "SERVICE_CTX_MENU_ID", "in": "header", - "description": "Name of the current process", + "description": "ID of the current menu", "required": True, - "schema": {"type": "string"}, + "schema": {"type": "integer"}, "style": "simple", - "value": "Put-Away Reach Truck", + "value": "1", }, { - "name": "SERVICE_CTX_PROCESS_MENU", + "name": "SERVICE_CTX_PROFILE_ID", "in": "header", - "description": "Name of the current process menu", + "description": "ID of the current profile", "required": True, - "schema": {"type": "string"}, + "schema": {"type": "integer"}, "style": "simple", - "value": "Put-Away Reach Truck", + "value": "1", }, ] ) From ef8ded0d1d7b3445014d368f1c3d186452d74be4 Mon Sep 17 00:00:00 2001 From: Benoit Date: Thu, 23 Jan 2020 10:04:30 +0100 Subject: [PATCH 041/940] refactor of pack scan service need to be improved again --- shopfloor/services/pack.py | 257 ++++++++++++++++++++++++++++---- shopfloor/tests/test_putaway.py | 17 ++- 2 files changed, 242 insertions(+), 32 deletions(-) diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py index 32f7a1b20f..9cca11de74 100644 --- a/shopfloor/services/pack.py +++ b/shopfloor/services/pack.py @@ -12,15 +12,88 @@ class ShopfloorPack(Component): # TODO define the return schema and add the validator method @skip_secure_response - def scan(self, pack_name): + def scan(self, barcode): """Scan a pack barcode""" - pack = self.env["stock.quant.package"].search([("name", "=", pack_name)]) + company = self.env.user.company_id # FIXME add logic to get proper company # FIXME add logic to get proper warehouse warehouse = self.env["stock.warehouse"].search([])[0] picking_type = ( warehouse.int_type_id ) # FIXME add logic to get picking type properly + + # TODO define on what we search (pack name, pack barcode ...) + pack = self.env["stock.quant.package"].search([("name", "=", barcode)]) + if not pack: + return { + "success": False, + "code": "not_found", + "message": { + "title": "Pack not found", + "body": "the pack %s doesn't exists" % barcode, + }, + } + allowed_locations = self.env["stock.location"].search( + [("id", "child_of", picking_type.default_location_src_id.id)] + ) + if pack.location_id not in allowed_locations: + return { + "success": False, + "code": "forbidden", + "message": { + "title": "do not process", + "body": "pack %s is not in %s location" + % (barcode, picking_type.default_location_src_id.name), + }, + } + quantity = pack.quant_ids[0].quantity + existing_operations = self.env["stock.move.line"].search( + [("qty_done", "=", quantity), ("package_id", "=", pack.id)] + ) + if ( + existing_operations + and existing_operations[0].picking_id.picking_type_id != picking_type + ): + return { + "success": False, + "code": "forbidden", + "message": { + "title": "do not process", + "body": "An operation exists in %s %s. " + "You cannot process it with this shopfloor process." + % ( + existing_operations[0].picking_id.picking_type_id.name, + existing_operations[0].picking_id.name, + ), + }, + } + elif existing_operations: + move = existing_operations.move_id + return { + "data": { + "id": move.move_line_ids[0].package_level_id.id, + "location_src": { + "id": pack.location_id.id, + "name": pack.location_id.name, + }, + "location_dst": { + "id": move.move_line_ids[0].location_dest_id.id, + "name": move.move_line_ids[0].location_dest_id.name, + }, + "product": { + "id": move.product_id.name, + "name": move.product_id.name, + }, + "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, + }, + "success": False, + "code": "need_confirmation", + "message": { + "title": "Already started", + "body": "Operation already running. " + "Would you like to take it over ?", + }, + } product = pack.quant_ids[ 0 ].product_id # FIXME we consider only one product per pack @@ -45,45 +118,179 @@ def scan(self, pack_name): ) move.picking_id.action_assign() return_vals = { - "name": pack.name, - "location_name": pack.location_id.name, - "location_dest_name": move.move_line_ids[0].location_dest_id.name, - "product_name": move.name, - "picking_name": move.picking_id.name, - "location_id": pack.location_id.id, - "location_dest_id": move.move_line_ids[0].location_dest_id.id, - "move_id": move.id, - # 'allow_change_destination': True, #TODO + "success": True, + "data": { + "id": move.move_line_ids[0].package_level_id.id, + "location_src": { + "id": pack.location_id.id, + "name": pack.location_id.name, + }, + "location_dst": { + "id": move.move_line_ids[0].location_dest_id.id, + "name": move.move_line_ids[0].location_dest_id.name, + }, + "product": {"id": move.product_id.name, "name": move.product_id.name}, + "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, + }, } return return_vals - def validate(self, move_id, location_name): - move = self.env["stock.move"].browse(move_id) + def validate(self, package_level_id, location_name, confirmation=False): + package = self.env["stock.package_level"].browse(package_level_id) + move = package.move_line_ids[0].move_id dest_location = self.env["stock.location"].search( [("name", "=", location_name)] ) - if move.move_line_ids[0].location_dest_id.id != dest_location.id: - move.move_line_ids[0].location_dest_id = dest_location.id - move.move_line_ids[0].qty_done = move.move_line_ids[0].product_uom_qty - move.picking_id.button_validate() - return True + move_dest_location = move.move_line_ids[0].location_dest_id + allowed_locations = self.env["stock.location"].search( + [ + ( + "id", + "child_of", + move.picking_id.picking_type_id.default_location_dest_id.id, + ) + ] + ) + zone_locations = self.env["stock.location"].search( + [("id", "child_of", move_dest_location.id)] + ) + if dest_location not in allowed_locations: + return { + "success": False, + "code": "forbidden", + "message": {"title": "Forbidden", "body": "You cannot place it here"}, + } + elif ( + dest_location in allowed_locations + and dest_location not in zone_locations + and confirmation + ): + return { + "success": False, + "code": "need_confirmation", + "message": {"title": "Confirm", "body": "Are you sure ?"}, + } + if move.state == "cancel": + return { + "success": False, + "code": "restart", + "message": { + "title": "Restart", + "body": "Restart the operation someone has canceld it.", + }, + } + move.move_line_ids[0].location_dest_id = dest_location.id + move._action_done() + return {"success": True} - def cancel(self, move_id): - move = self.env["stock.move"].browse(move_id) - move.picking_id.cancel() - return True + def cancel(self, package_level_id): + package = self.env["stock.package_level"].browse(package_level_id) + package.move_ids[0].cancel() + return {"success": True} def _validator_cancel(self): - return {"move_id": {"coerce": to_int, "required": True, "type": "integer"}} + return { + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"} + } def _validator_validate(self): return { - "move_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, "location_name": {"type": "string", "nullable": False, "required": True}, } + def _validator_return_validate(self): + return { + "success": {"type": "boolean", "nullable": True, "required": True}, + "code": {"type": "string", "nullable": True, "required": False}, + "message": { + "type": "dict", + "schema": { + "body": {"type": "string", "nullable": False, "required": True} + }, + }, + } + def _validator_scan(self): - return {"pack_name": {"type": "string", "nullable": False, "required": True}} + return {"barcode": {"type": "string", "nullable": False, "required": True}} + + def _validator_return_scan(self): + return { + "success": {"type": "boolean", "nullable": True, "required": True}, + "code": {"type": "string", "nullable": True, "required": False}, + "message": { + "type": "dict", + "schema": { + "body": {"type": "string", "nullable": False, "required": True} + }, + }, + "data": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "location_src": { + "type": "dict", + "schema": { + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, + }, + }, + "location_dst": { + "type": "dict", + "schema": { + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, + }, + }, + "product": { + "type": "dict", + "schema": { + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, + }, + }, + "picking": { + "type": "dict", + "schema": { + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, + }, + }, + }, + }, + } def get_by_name(self, pack_name): """ diff --git a/shopfloor/tests/test_putaway.py b/shopfloor/tests/test_putaway.py index 53c325cb0c..5b09c33241 100644 --- a/shopfloor/tests/test_putaway.py +++ b/shopfloor/tests/test_putaway.py @@ -4,18 +4,17 @@ class PutawayCase(CommonCase): def setUp(self, *args, **kwargs): super(PutawayCase, self).setUp(*args, **kwargs) - in_location = self.env.ref("stock.stock_location_company").child_ids[0] stock_location = self.env.ref("stock.stock_location_stock") self.productA = self.env["product.product"].create( {"name": "Product A", "type": "product"} ) self.packA = self.env["stock.quant.package"].create( - {"location_id": in_location.id} + {"location_id": stock_location.id} ) self.quantA = self.env["stock.quant"].create( { "product_id": self.productA.id, - "location_id": in_location.id, + "location_id": stock_location.id, "quantity": 1, "package_id": self.packA.id, } @@ -32,11 +31,15 @@ def setUp(self, *args, **kwargs): self.service = work.component(usage="pack") def test_scan_pack(self): - pack_name = self.packA.name - params = {"pack_name": pack_name} + barcode = self.packA.name + params = {"barcode": barcode} response = self.service.dispatch("scan", params=params) - move_id = response["move_id"] - params = {"move_id": move_id, "location_name": response["location_dest_name"]} + package_level = self.env["stock.package_level"].browse(response["data"]["id"]) + move_id = package_level.move_line_ids[0].move_id.id + params = { + "package_level_id": package_level.id, + "location_name": response["data"]["location_dst"]["name"], + } location_dest_id = ( self.env["stock.location"] .search([("name", "=", params["location_name"])]) From d6356ca7db19dd2804534239463a6a9577aebec6 Mon Sep 17 00:00:00 2001 From: Benoit Date: Thu, 23 Jan 2020 10:13:44 +0100 Subject: [PATCH 042/940] move single pack putaway service --- shopfloor/services/pack.py | 284 +----------------- shopfloor/services/single_pack_putaway.py | 281 +++++++++++++++++ ...putaway.py => test_single_pack_putaway.py} | 0 3 files changed, 282 insertions(+), 283 deletions(-) rename shopfloor/tests/{test_putaway.py => test_single_pack_putaway.py} (100%) diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py index 9cca11de74..46b7014764 100644 --- a/shopfloor/services/pack.py +++ b/shopfloor/services/pack.py @@ -1,4 +1,4 @@ -from odoo.addons.base_rest.components.service import skip_secure_response, to_int +from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -10,288 +10,6 @@ class ShopfloorPack(Component): _usage = "pack" _description = __doc__ - # TODO define the return schema and add the validator method - @skip_secure_response - def scan(self, barcode): - """Scan a pack barcode""" - - company = self.env.user.company_id # FIXME add logic to get proper company - # FIXME add logic to get proper warehouse - warehouse = self.env["stock.warehouse"].search([])[0] - picking_type = ( - warehouse.int_type_id - ) # FIXME add logic to get picking type properly - - # TODO define on what we search (pack name, pack barcode ...) - pack = self.env["stock.quant.package"].search([("name", "=", barcode)]) - if not pack: - return { - "success": False, - "code": "not_found", - "message": { - "title": "Pack not found", - "body": "the pack %s doesn't exists" % barcode, - }, - } - allowed_locations = self.env["stock.location"].search( - [("id", "child_of", picking_type.default_location_src_id.id)] - ) - if pack.location_id not in allowed_locations: - return { - "success": False, - "code": "forbidden", - "message": { - "title": "do not process", - "body": "pack %s is not in %s location" - % (barcode, picking_type.default_location_src_id.name), - }, - } - quantity = pack.quant_ids[0].quantity - existing_operations = self.env["stock.move.line"].search( - [("qty_done", "=", quantity), ("package_id", "=", pack.id)] - ) - if ( - existing_operations - and existing_operations[0].picking_id.picking_type_id != picking_type - ): - return { - "success": False, - "code": "forbidden", - "message": { - "title": "do not process", - "body": "An operation exists in %s %s. " - "You cannot process it with this shopfloor process." - % ( - existing_operations[0].picking_id.picking_type_id.name, - existing_operations[0].picking_id.name, - ), - }, - } - elif existing_operations: - move = existing_operations.move_id - return { - "data": { - "id": move.move_line_ids[0].package_level_id.id, - "location_src": { - "id": pack.location_id.id, - "name": pack.location_id.name, - }, - "location_dst": { - "id": move.move_line_ids[0].location_dest_id.id, - "name": move.move_line_ids[0].location_dest_id.name, - }, - "product": { - "id": move.product_id.name, - "name": move.product_id.name, - }, - "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, - }, - "success": False, - "code": "need_confirmation", - "message": { - "title": "Already started", - "body": "Operation already running. " - "Would you like to take it over ?", - }, - } - product = pack.quant_ids[ - 0 - ].product_id # FIXME we consider only one product per pack - move_vals = { - "picking_type_id": picking_type.id, - "product_id": product.id, - "location_id": pack.location_id.id, - "location_dest_id": picking_type.default_location_dest_id.id, - "name": product.name, - "product_uom": product.uom_id.id, - "product_uom_qty": pack.quant_ids[0].quantity, - "company_id": company.id, - } - move = self.env["stock.move"].create(move_vals) - move._action_confirm() - self.env["stock.package_level"].create( - { - "package_id": pack.id, - "move_ids": [(6, 0, [move.id])], - "company_id": company.id, - } - ) - move.picking_id.action_assign() - return_vals = { - "success": True, - "data": { - "id": move.move_line_ids[0].package_level_id.id, - "location_src": { - "id": pack.location_id.id, - "name": pack.location_id.name, - }, - "location_dst": { - "id": move.move_line_ids[0].location_dest_id.id, - "name": move.move_line_ids[0].location_dest_id.name, - }, - "product": {"id": move.product_id.name, "name": move.product_id.name}, - "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, - }, - } - return return_vals - - def validate(self, package_level_id, location_name, confirmation=False): - package = self.env["stock.package_level"].browse(package_level_id) - move = package.move_line_ids[0].move_id - dest_location = self.env["stock.location"].search( - [("name", "=", location_name)] - ) - move_dest_location = move.move_line_ids[0].location_dest_id - allowed_locations = self.env["stock.location"].search( - [ - ( - "id", - "child_of", - move.picking_id.picking_type_id.default_location_dest_id.id, - ) - ] - ) - zone_locations = self.env["stock.location"].search( - [("id", "child_of", move_dest_location.id)] - ) - if dest_location not in allowed_locations: - return { - "success": False, - "code": "forbidden", - "message": {"title": "Forbidden", "body": "You cannot place it here"}, - } - elif ( - dest_location in allowed_locations - and dest_location not in zone_locations - and confirmation - ): - return { - "success": False, - "code": "need_confirmation", - "message": {"title": "Confirm", "body": "Are you sure ?"}, - } - if move.state == "cancel": - return { - "success": False, - "code": "restart", - "message": { - "title": "Restart", - "body": "Restart the operation someone has canceld it.", - }, - } - move.move_line_ids[0].location_dest_id = dest_location.id - move._action_done() - return {"success": True} - - def cancel(self, package_level_id): - package = self.env["stock.package_level"].browse(package_level_id) - package.move_ids[0].cancel() - return {"success": True} - - def _validator_cancel(self): - return { - "package_level_id": {"coerce": to_int, "required": True, "type": "integer"} - } - - def _validator_validate(self): - return { - "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, - "location_name": {"type": "string", "nullable": False, "required": True}, - } - - def _validator_return_validate(self): - return { - "success": {"type": "boolean", "nullable": True, "required": True}, - "code": {"type": "string", "nullable": True, "required": False}, - "message": { - "type": "dict", - "schema": { - "body": {"type": "string", "nullable": False, "required": True} - }, - }, - } - - def _validator_scan(self): - return {"barcode": {"type": "string", "nullable": False, "required": True}} - - def _validator_return_scan(self): - return { - "success": {"type": "boolean", "nullable": True, "required": True}, - "code": {"type": "string", "nullable": True, "required": False}, - "message": { - "type": "dict", - "schema": { - "body": {"type": "string", "nullable": False, "required": True} - }, - }, - "data": { - "type": "dict", - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "location_src": { - "type": "dict", - "schema": { - "id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, - }, - }, - "location_dst": { - "type": "dict", - "schema": { - "id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, - }, - }, - "product": { - "type": "dict", - "schema": { - "id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, - }, - }, - "picking": { - "type": "dict", - "schema": { - "id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, - }, - }, - }, - }, - } - def get_by_name(self, pack_name): """ Get pack informations diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index f46024bd0d..3c68068915 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -1,3 +1,4 @@ +from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -8,3 +9,283 @@ class SinglePackPutaway(Component): _name = "shopfloor.single.pack.putaway" _usage = "single_pack_putaway" _description = __doc__ + + def scan(self, barcode): + """Scan a pack barcode""" + + company = self.env.user.company_id # FIXME add logic to get proper company + # FIXME add logic to get proper warehouse + warehouse = self.env["stock.warehouse"].search([])[0] + picking_type = ( + warehouse.int_type_id + ) # FIXME add logic to get picking type properly + + # TODO define on what we search (pack name, pack barcode ...) + pack = self.env["stock.quant.package"].search([("name", "=", barcode)]) + if not pack: + return { + "success": False, + "code": "not_found", + "message": { + "title": "Pack not found", + "body": "the pack %s doesn't exists" % barcode, + }, + } + allowed_locations = self.env["stock.location"].search( + [("id", "child_of", picking_type.default_location_src_id.id)] + ) + if pack.location_id not in allowed_locations: + return { + "success": False, + "code": "forbidden", + "message": { + "title": "do not process", + "body": "pack %s is not in %s location" + % (barcode, picking_type.default_location_src_id.name), + }, + } + quantity = pack.quant_ids[0].quantity + existing_operations = self.env["stock.move.line"].search( + [("qty_done", "=", quantity), ("package_id", "=", pack.id)] + ) + if ( + existing_operations + and existing_operations[0].picking_id.picking_type_id != picking_type + ): + return { + "success": False, + "code": "forbidden", + "message": { + "title": "do not process", + "body": "An operation exists in %s %s. " + "You cannot process it with this shopfloor process." + % ( + existing_operations[0].picking_id.picking_type_id.name, + existing_operations[0].picking_id.name, + ), + }, + } + elif existing_operations: + move = existing_operations.move_id + return { + "data": { + "id": move.move_line_ids[0].package_level_id.id, + "location_src": { + "id": pack.location_id.id, + "name": pack.location_id.name, + }, + "location_dst": { + "id": move.move_line_ids[0].location_dest_id.id, + "name": move.move_line_ids[0].location_dest_id.name, + }, + "product": { + "id": move.product_id.name, + "name": move.product_id.name, + }, + "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, + }, + "success": False, + "code": "need_confirmation", + "message": { + "title": "Already started", + "body": "Operation already running. " + "Would you like to take it over ?", + }, + } + product = pack.quant_ids[ + 0 + ].product_id # FIXME we consider only one product per pack + move_vals = { + "picking_type_id": picking_type.id, + "product_id": product.id, + "location_id": pack.location_id.id, + "location_dest_id": picking_type.default_location_dest_id.id, + "name": product.name, + "product_uom": product.uom_id.id, + "product_uom_qty": pack.quant_ids[0].quantity, + "company_id": company.id, + } + move = self.env["stock.move"].create(move_vals) + move._action_confirm() + self.env["stock.package_level"].create( + { + "package_id": pack.id, + "move_ids": [(6, 0, [move.id])], + "company_id": company.id, + } + ) + move.picking_id.action_assign() + return_vals = { + "success": True, + "data": { + "id": move.move_line_ids[0].package_level_id.id, + "location_src": { + "id": pack.location_id.id, + "name": pack.location_id.name, + }, + "location_dst": { + "id": move.move_line_ids[0].location_dest_id.id, + "name": move.move_line_ids[0].location_dest_id.name, + }, + "product": {"id": move.product_id.name, "name": move.product_id.name}, + "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, + }, + } + return return_vals + + def validate(self, package_level_id, location_name, confirmation=False): + package = self.env["stock.package_level"].browse(package_level_id) + move = package.move_line_ids[0].move_id + dest_location = self.env["stock.location"].search( + [("name", "=", location_name)] + ) + move_dest_location = move.move_line_ids[0].location_dest_id + allowed_locations = self.env["stock.location"].search( + [ + ( + "id", + "child_of", + move.picking_id.picking_type_id.default_location_dest_id.id, + ) + ] + ) + zone_locations = self.env["stock.location"].search( + [("id", "child_of", move_dest_location.id)] + ) + if dest_location not in allowed_locations: + return { + "success": False, + "code": "forbidden", + "message": {"title": "Forbidden", "body": "You cannot place it here"}, + } + elif ( + dest_location in allowed_locations + and dest_location not in zone_locations + and confirmation + ): + return { + "success": False, + "code": "need_confirmation", + "message": {"title": "Confirm", "body": "Are you sure ?"}, + } + if move.state == "cancel": + return { + "success": False, + "code": "restart", + "message": { + "title": "Restart", + "body": "Restart the operation someone has canceld it.", + }, + } + move.move_line_ids[0].location_dest_id = dest_location.id + move._action_done() + return {"success": True} + + def cancel(self, package_level_id): + package = self.env["stock.package_level"].browse(package_level_id) + package.move_ids[0].cancel() + return {"success": True} + + def _validator_cancel(self): + return { + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"} + } + + def _validator_validate(self): + return { + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + "location_name": {"type": "string", "nullable": False, "required": True}, + } + + def _validator_return_validate(self): + return { + "success": {"type": "boolean", "nullable": True, "required": True}, + "code": {"type": "string", "nullable": True, "required": False}, + "message": { + "type": "dict", + "schema": { + "body": {"type": "string", "nullable": False, "required": True} + }, + }, + } + + def _validator_scan(self): + return {"barcode": {"type": "string", "nullable": False, "required": True}} + + def _validator_return_scan(self): + return { + "success": {"type": "boolean", "nullable": True, "required": True}, + "code": {"type": "string", "nullable": True, "required": False}, + "message": { + "type": "dict", + "schema": { + "body": {"type": "string", "nullable": False, "required": True} + }, + }, + "data": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "location_src": { + "type": "dict", + "schema": { + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, + }, + }, + "location_dst": { + "type": "dict", + "schema": { + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, + }, + }, + "product": { + "type": "dict", + "schema": { + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, + }, + }, + "picking": { + "type": "dict", + "schema": { + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, + }, + }, + }, + }, + } diff --git a/shopfloor/tests/test_putaway.py b/shopfloor/tests/test_single_pack_putaway.py similarity index 100% rename from shopfloor/tests/test_putaway.py rename to shopfloor/tests/test_single_pack_putaway.py From 2fdb0e16854064325b262beb2a29ab6885a9389e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jan 2020 11:41:42 +0100 Subject: [PATCH 043/940] Use a unified response format (use self._response_schema() now) --- shopfloor/services/location.py | 50 ++++++---- shopfloor/services/menu.py | 41 +++++--- shopfloor/services/pack.py | 19 ++-- shopfloor/services/profile.py | 56 ++++++----- shopfloor/services/service.py | 21 +++++ shopfloor/services/single_pack_putaway.py | 110 ++++++---------------- 6 files changed, 147 insertions(+), 150 deletions(-) diff --git a/shopfloor/services/location.py b/shopfloor/services/location.py index 17aac7be05..2a3f5ea355 100644 --- a/shopfloor/services/location.py +++ b/shopfloor/services/location.py @@ -28,7 +28,7 @@ def search(self, name_fragment=None): ] ) records = self.env[self._expose_model].search(domain) - return {"size": len(records), "data": self._to_json(records)} + return {"data": {"size": len(records), "records": self._to_json(records)}} def _get_base_search_domain(self): # TODO add filter on warehouse of the current profile @@ -40,29 +40,39 @@ def _validator_search(self): } def _validator_return_search(self): - return { - "size": {"coerce": to_int, "required": True, "type": "integer"}, - "data": { - "type": "list", - "schema": { - "type": "dict", + return self._response_schema( + { + "size": {"coerce": to_int, "required": True, "type": "integer"}, + "records": { + "type": "list", "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "complete_name": { - "type": "string", - "nullable": False, - "required": True, - }, - "barcode": { - "type": "string", - "nullable": False, - "required": False, + "type": "dict", + "schema": { + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, + "complete_name": { + "type": "string", + "nullable": False, + "required": True, + }, + "barcode": { + "type": "string", + "nullable": False, + "required": False, + }, }, }, }, - }, - } + } + ) def _convert_one_record(self, record): return { diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index 220003a559..374772d9c2 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -39,7 +39,7 @@ def search(self, name_fragment=None): if name_fragment: domain.append(("name", "ilike", name_fragment)) records = self.env[self._expose_model].search(domain) - return {"size": len(records), "data": self._to_json(records)} + return {"data": {"size": len(records), "records": self._to_json(records)}} def _validator_search(self): return { @@ -47,24 +47,35 @@ def _validator_search(self): } def _validator_return_search(self): - return { - "size": {"coerce": to_int, "required": True, "type": "integer"}, - "data": { - "type": "list", - "schema": { - "type": "dict", + return self._response_schema( + { + "size": {"coerce": to_int, "required": True, "type": "integer"}, + "records": { + "type": "list", + "required": True, "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "process": { - "type": "string", - "nullable": False, - "required": True, + "type": "dict", + "schema": { + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, + "process": { + "type": "string", + "nullable": False, + "required": True, + }, }, }, }, - }, - } + } + ) def _convert_one_record(self, record): return {"id": record.id, "name": record.name, "process": record.process_code} diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py index 46b7014764..2f8a591621 100644 --- a/shopfloor/services/pack.py +++ b/shopfloor/services/pack.py @@ -25,7 +25,7 @@ def _validator_get_by_name(self): return {"pack_name": {"type": "string", "nullable": False, "required": True}} def _validator_return_get_by_name(self): - return {"data": self._record_return_schema} + return self._response_schema(self._record_return_schema) def _convert_one_record(self, record): return { @@ -37,16 +37,13 @@ def _convert_one_record(self, record): @property def _record_return_schema(self): return { - "type": "dict", - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "location": { - "type": "dict", - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "location": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, }, }, } diff --git a/shopfloor/services/profile.py b/shopfloor/services/profile.py index 92f9d3eac3..2ccf78be0f 100644 --- a/shopfloor/services/profile.py +++ b/shopfloor/services/profile.py @@ -31,7 +31,7 @@ def search(self, name_fragment=None): if name_fragment: domain.append(("name", "ilike", name_fragment)) records = self.env[self._expose_model].search(domain) - return {"size": len(records), "data": self._to_json(records)} + return {"data": {"size": len(records), "records": self._to_json(records)}} def _get_base_search_domain(self): # shopfloor_profile_ids is a one2one in practice. @@ -58,34 +58,44 @@ def _validator_search(self): } def _validator_return_search(self): - return { - "size": {"coerce": to_int, "required": True, "type": "integer"}, - "data": { - "type": "list", - "schema": { - "type": "dict", + return self._response_schema( + { + "size": {"coerce": to_int, "required": True, "type": "integer"}, + "records": { + "type": "list", "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "warehouse": { - "type": "dict", - "schema": { - "id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "name": { - "type": "string", - "nullable": False, - "required": True, + "type": "dict", + "schema": { + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, + "warehouse": { + "type": "dict", + "schema": { + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, }, }, }, }, }, - }, - } + } + ) def _convert_one_record(self, record): return { diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 2363b60af4..91620efc21 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -37,6 +37,27 @@ def _to_json(self, records): res.append(self._convert_one_record(record)) return res + def _response_schema(self, data_schema=None): + if not data_schema: + data_schema = {} + return { + "data": {"type": "dict", "required": False, "schema": data_schema}, + "state": {"type": "string", "required": False}, + "message": { + "type": "dict", + "required": False, + "schema": { + "message_type": { + "type": "string", + "required": True, + "allowed": ["info", "warning", "error"], + }, + "title": {"type": "string", "required": False}, + "message": {"type": "string", "required": True}, + }, + }, + } + def _get_openapi_default_parameters(self): defaults = super()._get_openapi_default_parameters() demo_api_key = self.env.ref( diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 3c68068915..a5a982c370 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -198,94 +198,42 @@ def _validator_validate(self): } def _validator_return_validate(self): - return { - "success": {"type": "boolean", "nullable": True, "required": True}, - "code": {"type": "string", "nullable": True, "required": False}, - "message": { - "type": "dict", - "schema": { - "body": {"type": "string", "nullable": False, "required": True} - }, - }, - } + return self._response_schema() def _validator_scan(self): return {"barcode": {"type": "string", "nullable": False, "required": True}} def _validator_return_scan(self): - return { - "success": {"type": "boolean", "nullable": True, "required": True}, - "code": {"type": "string", "nullable": True, "required": False}, - "message": { - "type": "dict", - "schema": { - "body": {"type": "string", "nullable": False, "required": True} - }, - }, - "data": { - "type": "dict", - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "location_src": { - "type": "dict", - "schema": { - "id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, - }, + return self._response_schema( + { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "location_src": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, }, - "location_dst": { - "type": "dict", - "schema": { - "id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, - }, + }, + "location_dst": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, }, - "product": { - "type": "dict", - "schema": { - "id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, - }, + }, + "product": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, }, - "picking": { - "type": "dict", - "schema": { - "id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, - }, + }, + "picking": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, }, }, - }, - } + } + ) From 651b4a34dcddcdbc8f208446283aa1e8bb774265 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jan 2020 12:01:51 +0100 Subject: [PATCH 044/940] Add method to generate the response body (use self._response()) --- shopfloor/services/location.py | 4 +++- shopfloor/services/menu.py | 4 +++- shopfloor/services/pack.py | 2 +- shopfloor/services/profile.py | 4 +++- shopfloor/services/service.py | 26 ++++++++++++++++++++++++++ 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/shopfloor/services/location.py b/shopfloor/services/location.py index 2a3f5ea355..a789869120 100644 --- a/shopfloor/services/location.py +++ b/shopfloor/services/location.py @@ -28,7 +28,9 @@ def search(self, name_fragment=None): ] ) records = self.env[self._expose_model].search(domain) - return {"data": {"size": len(records), "records": self._to_json(records)}} + return self._response( + data={"size": len(records), "records": self._to_json(records)} + ) def _get_base_search_domain(self): # TODO add filter on warehouse of the current profile diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index 374772d9c2..33ea065b67 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -39,7 +39,9 @@ def search(self, name_fragment=None): if name_fragment: domain.append(("name", "ilike", name_fragment)) records = self.env[self._expose_model].search(domain) - return {"data": {"size": len(records), "records": self._to_json(records)}} + return self._response( + data={"size": len(records), "records": self._to_json(records)} + ) def _validator_search(self): return { diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py index 2f8a591621..e13ea02c5e 100644 --- a/shopfloor/services/pack.py +++ b/shopfloor/services/pack.py @@ -19,7 +19,7 @@ def get_by_name(self, pack_name): # TODO, is it what we want? error if not found? limit=1, ) - return self._to_json(pack)[:1] + return self._response(data=self._to_json(pack)[:1]) def _validator_get_by_name(self): return {"pack_name": {"type": "string", "nullable": False, "required": True}} diff --git a/shopfloor/services/profile.py b/shopfloor/services/profile.py index 2ccf78be0f..86e2ed1175 100644 --- a/shopfloor/services/profile.py +++ b/shopfloor/services/profile.py @@ -31,7 +31,9 @@ def search(self, name_fragment=None): if name_fragment: domain.append(("name", "ilike", name_fragment)) records = self.env[self._expose_model].search(domain) - return {"data": {"size": len(records), "records": self._to_json(records)}} + return self._response( + data={"size": len(records), "records": self._to_json(records)} + ) def _get_base_search_domain(self): # shopfloor_profile_ids is a one2one in practice. diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 91620efc21..d96822cb43 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -37,7 +37,33 @@ def _to_json(self, records): res.append(self._convert_one_record(record)) return res + def _response(self, data=None, state=None, message=None): + """Base "envelope" for the responses + + All the keys are optional. + + :param data: dictionary of values + :param state: string describing the next state that the client + application must reach + :param message: dictionary for the message to show in the client + application (see ``_response_schema`` for the keys) + """ + response = {} + if data: + response["data"] = data + if state: + response["state"] = state + if message: + response["message"] = message + return response + def _response_schema(self, data_schema=None): + """Schema for the return validator + + Must be used for the schema of all responses. + The "data" part can be customized and is optional, + it must be a dictionary. + """ if not data_schema: data_schema = {} return { From a43a4c5d8298289e19d5992879fb60284b48e467 Mon Sep 17 00:00:00 2001 From: Benoit Date: Thu, 23 Jan 2020 11:53:04 +0100 Subject: [PATCH 045/940] fix tests --- shopfloor/services/single_pack_putaway.py | 14 +++++++++++--- shopfloor/tests/__init__.py | 2 +- shopfloor/tests/test_single_pack_putaway.py | 6 +++--- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index a5a982c370..9192526ebc 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -95,11 +95,12 @@ def scan(self, barcode): product = pack.quant_ids[ 0 ].product_id # FIXME we consider only one product per pack + default_location_dest = picking_type.default_location_dest_id move_vals = { "picking_type_id": picking_type.id, "product_id": product.id, "location_id": pack.location_id.id, - "location_dest_id": picking_type.default_location_dest_id.id, + "location_dest_id": default_location_dest.id, "name": product.name, "product_uom": product.uom_id.id, "product_uom_qty": pack.quant_ids[0].quantity, @@ -107,11 +108,18 @@ def scan(self, barcode): } move = self.env["stock.move"].create(move_vals) move._action_confirm() + location_dest_id = ( + default_location_dest._get_putaway_strategy(product).id + or default_location_dest.id + ) self.env["stock.package_level"].create( { "package_id": pack.id, - "move_ids": [(6, 0, [move.id])], + "move_ids": [(4, move.id)], "company_id": company.id, + "is_done": True, + "location_id": pack.location_id.id, + "location_dest_id": location_dest_id, } ) move.picking_id.action_assign() @@ -127,7 +135,7 @@ def scan(self, barcode): "id": move.move_line_ids[0].location_dest_id.id, "name": move.move_line_ids[0].location_dest_id.name, }, - "product": {"id": move.product_id.name, "name": move.product_id.name}, + "product": {"id": move.product_id.id, "name": move.product_id.name}, "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, }, } diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 3a80e1ce96..188a3788ab 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -1 +1 @@ -from . import test_putaway +from . import test_single_pack_putaway diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 5b09c33241..82a8e6c6c2 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -5,6 +5,7 @@ class PutawayCase(CommonCase): def setUp(self, *args, **kwargs): super(PutawayCase, self).setUp(*args, **kwargs) stock_location = self.env.ref("stock.stock_location_stock") + out_location = stock_location.child_ids[1] self.productA = self.env["product.product"].create( {"name": "Product A", "type": "product"} ) @@ -23,12 +24,11 @@ def setUp(self, *args, **kwargs): { "product_id": self.productA.id, "location_in_id": stock_location.id, - "location_out_id": stock_location.child_ids[0].id, + "location_out_id": out_location.id, } ) - with self.work_on_services() as work: - self.service = work.component(usage="pack") + self.service = work.component(usage="single_pack_putaway") def test_scan_pack(self): barcode = self.packA.name From 6166b4a66c0e6cf66807e8901e19c8ddb088651a Mon Sep 17 00:00:00 2001 From: Benoit Date: Thu, 23 Jan 2020 12:20:53 +0100 Subject: [PATCH 046/940] fix return format --- shopfloor/services/single_pack_putaway.py | 78 +++++++++++++++-------- 1 file changed, 51 insertions(+), 27 deletions(-) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 9192526ebc..18de0687df 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -24,11 +24,11 @@ def scan(self, barcode): pack = self.env["stock.quant.package"].search([("name", "=", barcode)]) if not pack: return { - "success": False, - "code": "not_found", + "state": "start", "message": { + "message_type": "error", "title": "Pack not found", - "body": "the pack %s doesn't exists" % barcode, + "message": "the pack %s doesn't exists" % barcode, }, } allowed_locations = self.env["stock.location"].search( @@ -36,11 +36,11 @@ def scan(self, barcode): ) if pack.location_id not in allowed_locations: return { - "success": False, - "code": "forbidden", + "state": "start", "message": { + "message_type": "error", "title": "do not process", - "body": "pack %s is not in %s location" + "message": "pack %s is not in %s location" % (barcode, picking_type.default_location_src_id.name), }, } @@ -53,11 +53,11 @@ def scan(self, barcode): and existing_operations[0].picking_id.picking_type_id != picking_type ): return { - "success": False, - "code": "forbidden", + "state": "start", "message": { + "message_type": "error", "title": "do not process", - "body": "An operation exists in %s %s. " + "message": "An operation exists in %s %s. " "You cannot process it with this shopfloor process." % ( existing_operations[0].picking_id.picking_type_id.name, @@ -84,11 +84,11 @@ def scan(self, barcode): }, "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, }, - "success": False, - "code": "need_confirmation", + "state": "confirm_start", "message": { + "message_type": "warning", "title": "Already started", - "body": "Operation already running. " + "message": "Operation already running. " "Would you like to take it over ?", }, } @@ -107,7 +107,7 @@ def scan(self, barcode): "company_id": company.id, } move = self.env["stock.move"].create(move_vals) - move._action_confirm() + move._action_confirm(merge=False) location_dest_id = ( default_location_dest._get_putaway_strategy(product).id or default_location_dest.id @@ -123,8 +123,13 @@ def scan(self, barcode): } ) move.picking_id.action_assign() - return_vals = { - "success": True, + return { + "state": "scan_location", + "message": { + "message_type": "info", + "title": "Start", + "message": "The move is ready, you can scan the destination location.", + }, "data": { "id": move.move_line_ids[0].package_level_id.id, "location_src": { @@ -139,7 +144,6 @@ def scan(self, barcode): "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, }, } - return return_vals def validate(self, package_level_id, location_name, confirmation=False): package = self.env["stock.package_level"].browse(package_level_id) @@ -162,9 +166,12 @@ def validate(self, package_level_id, location_name, confirmation=False): ) if dest_location not in allowed_locations: return { - "success": False, - "code": "forbidden", - "message": {"title": "Forbidden", "body": "You cannot place it here"}, + "state": "scan_location", + "message": { + "message_type": "error", + "title": "Forbidden", + "message": "You cannot place it here", + }, } elif ( dest_location in allowed_locations @@ -172,27 +179,44 @@ def validate(self, package_level_id, location_name, confirmation=False): and confirmation ): return { - "success": False, - "code": "need_confirmation", - "message": {"title": "Confirm", "body": "Are you sure ?"}, + "state": "confirm_location", + "message": { + "message_type": "warning", + "title": "Confirm", + "message": "Are you sure ?", + }, } if move.state == "cancel": return { - "success": False, - "code": "restart", + "state": "start", "message": { + "message_type": "warning", "title": "Restart", - "body": "Restart the operation someone has canceld it.", + "message": "Restart the operation someone has canceled it.", }, } move.move_line_ids[0].location_dest_id = dest_location.id move._action_done() - return {"success": True} + return { + "state": "start", + "message": { + "message_type": "info", + "title": "Start", + "message": "The pack has been moved, you can scan a new pack.", + }, + } def cancel(self, package_level_id): package = self.env["stock.package_level"].browse(package_level_id) package.move_ids[0].cancel() - return {"success": True} + return { + "state": "start", + "message": { + "message_type": "info", + "title": "Start", + "message": "The move has been canceled, you can scan a new pack.", + }, + } def _validator_cancel(self): return { From c90c8c4b25569d306619e9e37b5dc25491c3744c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jan 2020 13:30:37 +0100 Subject: [PATCH 047/940] Add translations gettext on messages --- shopfloor/services/single_pack_putaway.py | 144 ++++++++++++---------- 1 file changed, 80 insertions(+), 64 deletions(-) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 18de0687df..80d20d5a3d 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -1,3 +1,5 @@ +from odoo import _ + from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -23,27 +25,27 @@ def scan(self, barcode): # TODO define on what we search (pack name, pack barcode ...) pack = self.env["stock.quant.package"].search([("name", "=", barcode)]) if not pack: - return { - "state": "start", - "message": { + return self._response( + state="start", + message={ "message_type": "error", - "title": "Pack not found", - "message": "the pack %s doesn't exists" % barcode, + "title": _("Pack not found"), + "message": _("The pack %s doesn't exist") % barcode, }, - } + ) allowed_locations = self.env["stock.location"].search( [("id", "child_of", picking_type.default_location_src_id.id)] ) if pack.location_id not in allowed_locations: - return { - "state": "start", - "message": { + return self._response( + state="start", + message={ "message_type": "error", - "title": "do not process", - "message": "pack %s is not in %s location" + "title": _("Cannot proceed"), + "message": _("pack %s is not in %s location") % (barcode, picking_type.default_location_src_id.name), }, - } + ) quantity = pack.quant_ids[0].quantity existing_operations = self.env["stock.move.line"].search( [("qty_done", "=", quantity), ("package_id", "=", pack.id)] @@ -52,23 +54,25 @@ def scan(self, barcode): existing_operations and existing_operations[0].picking_id.picking_type_id != picking_type ): - return { - "state": "start", - "message": { + return self._response( + state="start", + message={ "message_type": "error", - "title": "do not process", - "message": "An operation exists in %s %s. " - "You cannot process it with this shopfloor process." + "title": _("Cannot proceed"), + "message": _( + "An operation exists in %s %s. " + "You cannot process it with this shopfloor process." + ) % ( existing_operations[0].picking_id.picking_type_id.name, existing_operations[0].picking_id.name, ), }, - } + ) elif existing_operations: move = existing_operations.move_id - return { - "data": { + return self._response( + data={ "id": move.move_line_ids[0].package_level_id.id, "location_src": { "id": pack.location_id.id, @@ -84,14 +88,15 @@ def scan(self, barcode): }, "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, }, - "state": "confirm_start", - "message": { + state="confirm_start", + message={ "message_type": "warning", - "title": "Already started", - "message": "Operation already running. " - "Would you like to take it over ?", + "title": _("Already started"), + "message": _( + "Operation already running. " "Would you like to take it over ?" + ), }, - } + ) product = pack.quant_ids[ 0 ].product_id # FIXME we consider only one product per pack @@ -123,14 +128,16 @@ def scan(self, barcode): } ) move.picking_id.action_assign() - return { - "state": "scan_location", - "message": { + return self._response( + state="scan_location", + message={ "message_type": "info", - "title": "Start", - "message": "The move is ready, you can scan the destination location.", + "title": _("Start"), + "message": _( + "The move is ready, you can scan the destination location." + ), }, - "data": { + data={ "id": move.move_line_ids[0].package_level_id.id, "location_src": { "id": pack.location_id.id, @@ -143,7 +150,7 @@ def scan(self, barcode): "product": {"id": move.product_id.id, "name": move.product_id.name}, "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, }, - } + ) def validate(self, package_level_id, location_name, confirmation=False): package = self.env["stock.package_level"].browse(package_level_id) @@ -165,58 +172,67 @@ def validate(self, package_level_id, location_name, confirmation=False): [("id", "child_of", move_dest_location.id)] ) if dest_location not in allowed_locations: - return { - "state": "scan_location", - "message": { + return self._response( + state="scan_location", + message={ "message_type": "error", - "title": "Forbidden", - "message": "You cannot place it here", + "title": _("Forbidden"), + "message": _("You cannot place it here"), }, - } + ) elif ( dest_location in allowed_locations and dest_location not in zone_locations and confirmation ): - return { - "state": "confirm_location", - "message": { + return self._response( + state="confirm_location", + message={ "message_type": "warning", - "title": "Confirm", - "message": "Are you sure ?", + "title": _("Confirm"), + "message": _("Are you sure?"), }, - } + ) if move.state == "cancel": - return { - "state": "start", - "message": { + return self._response( + state="start", + message={ "message_type": "warning", - "title": "Restart", - "message": "Restart the operation someone has canceled it.", + "title": _("Restart"), + "message": _("Restart the operation, someone has canceled it."), }, - } + ) move.move_line_ids[0].location_dest_id = dest_location.id move._action_done() - return { - "state": "start", - "message": { + return self._response( + state="start", + message={ "message_type": "info", - "title": "Start", - "message": "The pack has been moved, you can scan a new pack.", + "title": _("Start"), + "message": _("The pack has been moved, you can scan a new pack."), }, - } + ) def cancel(self, package_level_id): package = self.env["stock.package_level"].browse(package_level_id) + if not package.exists(): + return self._response( + state="start", + message={ + "message_type": "error", + "title": _("Start again"), + "message": _("This operation does not exist anymore."), + }, + ) package.move_ids[0].cancel() - return { - "state": "start", - "message": { + return self._response( + state="start", + message={ "message_type": "info", - "title": "Start", - "message": "The move has been canceled, you can scan a new pack.", + "title": _("Start"), + "message": _("The move has been canceled, you can scan a new pack."), }, - } + ) def _validator_cancel(self): return { From 02d1fd7d5dfbd2ed858961ac0e71caa9ea15f0a1 Mon Sep 17 00:00:00 2001 From: Benoit Date: Thu, 23 Jan 2020 14:09:48 +0100 Subject: [PATCH 048/940] use location barcode instead of name --- shopfloor/services/single_pack_putaway.py | 49 ++++++++++----------- shopfloor/tests/test_single_pack_putaway.py | 22 +++++---- 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 80d20d5a3d..52f3994b3e 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -152,11 +152,20 @@ def scan(self, barcode): }, ) - def validate(self, package_level_id, location_name, confirmation=False): + def validate(self, package_level_id, location_barcode, confirmation=False): package = self.env["stock.package_level"].browse(package_level_id) move = package.move_line_ids[0].move_id + if move.state == "cancel": + return self._response( + state="start", + message={ + "message_type": "warning", + "title": _("Restart"), + "message": _("Restart the operation, someone has canceled it."), + }, + ) dest_location = self.env["stock.location"].search( - [("name", "=", location_name)] + [("barcode", "=", location_barcode)] ) move_dest_location = move.move_line_ids[0].location_dest_id allowed_locations = self.env["stock.location"].search( @@ -180,28 +189,18 @@ def validate(self, package_level_id, location_name, confirmation=False): "message": _("You cannot place it here"), }, ) - elif ( - dest_location in allowed_locations - and dest_location not in zone_locations - and confirmation - ): - return self._response( - state="confirm_location", - message={ - "message_type": "warning", - "title": _("Confirm"), - "message": _("Are you sure?"), - }, - ) - if move.state == "cancel": - return self._response( - state="start", - message={ - "message_type": "warning", - "title": _("Restart"), - "message": _("Restart the operation, someone has canceled it."), - }, - ) + elif dest_location in allowed_locations and dest_location not in zone_locations: + if confirmation: + move.location_dest_id = dest_location.id + else: + return self._response( + state="confirm_location", + message={ + "message_type": "warning", + "title": _("Confirm"), + "message": _("Are you sure?"), + }, + ) move.move_line_ids[0].location_dest_id = dest_location.id move._action_done() return self._response( @@ -242,7 +241,7 @@ def _validator_cancel(self): def _validator_validate(self): return { "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, - "location_name": {"type": "string", "nullable": False, "required": True}, + "location_barcode": {"type": "string", "nullable": False, "required": True}, } def _validator_return_validate(self): diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 82a8e6c6c2..7d2057b370 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -5,7 +5,13 @@ class PutawayCase(CommonCase): def setUp(self, *args, **kwargs): super(PutawayCase, self).setUp(*args, **kwargs) stock_location = self.env.ref("stock.stock_location_stock") - out_location = stock_location.child_ids[1] + out_location = self.env["stock.location"].search( + [ + ("location_id", "=", stock_location.id), + ("barcode", "!=", False), + ("usage", "=", "internal"), + ] + )[0] self.productA = self.env["product.product"].create( {"name": "Product A", "type": "product"} ) @@ -36,19 +42,17 @@ def test_scan_pack(self): response = self.service.dispatch("scan", params=params) package_level = self.env["stock.package_level"].browse(response["data"]["id"]) move_id = package_level.move_line_ids[0].move_id.id + location_dest = self.env["stock.location"].browse( + response["data"]["location_dst"]["id"] + ) params = { "package_level_id": package_level.id, - "location_name": response["data"]["location_dst"]["name"], + "location_barcode": location_dest.barcode, } - location_dest_id = ( - self.env["stock.location"] - .search([("name", "=", params["location_name"])]) - .id - ) new_loc_quant = self.env["stock.quant"].search( [ ("product_id", "=", self.productA.id), - ("location_id", "=", location_dest_id), + ("location_id", "=", location_dest.id), ] ) self.assertFalse(new_loc_quant) @@ -56,7 +60,7 @@ def test_scan_pack(self): new_loc_quant = self.env["stock.quant"].search( [ ("product_id", "=", self.productA.id), - ("location_id", "=", location_dest_id), + ("location_id", "=", location_dest.id), ] ) move = self.env["stock.move"].browse(move_id) From 04ce7f5415dde617641ed8286def313ded48f77e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jan 2020 14:39:48 +0100 Subject: [PATCH 049/940] Add Actions Components to share business logic Service Components are methods exposed to the REST API. They need to share common methods for their business logic, so enter the Actions Components to hold them. Alternatively, we could have put the methods directly on the Models, but at least here we don't crowd the models with many methods. To create a new Action Component, the bare minimum is: from odoo.addons.component.core import Component class StockPackageLevelAction(Component): _name = "shopfloor.stock.package_level.action" _inherit = "shopfloor.process.action" _apply_on = "stock.package_level" def hello(self): return "world" And to use it from any Service or Action component: self.actions_for("stock.package_level").hello() --- shopfloor/__init__.py | 1 + shopfloor/actions/__init__.py | 21 ++++ shopfloor/actions/base_action.py | 10 ++ shopfloor/actions/stock_package_level.py | 10 ++ shopfloor/services/service.py | 34 +++++- shopfloor/services/single_pack_putaway.py | 122 +++++++++++++++++---- shopfloor/services/single_pack_transfer.py | 12 ++ 7 files changed, 187 insertions(+), 23 deletions(-) create mode 100644 shopfloor/actions/__init__.py create mode 100644 shopfloor/actions/base_action.py create mode 100644 shopfloor/actions/stock_package_level.py diff --git a/shopfloor/__init__.py b/shopfloor/__init__.py index c312a8487c..6a34e5681f 100644 --- a/shopfloor/__init__.py +++ b/shopfloor/__init__.py @@ -1,3 +1,4 @@ from . import controllers from . import models +from . import actions from . import services diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py new file mode 100644 index 0000000000..b9b5c7e551 --- /dev/null +++ b/shopfloor/actions/__init__.py @@ -0,0 +1,21 @@ +""" +Support actions available from any Service Components. + +To use an Action Component, a Service component + +Difference with Service components: + +* Public methods of a Service Components are exposed in the REST API, + Action Components are never exposed +* Action Components will generally have an existing Odoo model name + +An Action component can be get from Service or Action Components using +``self.actions_for(model_name)``. + +The goal of the Action Components is to share common actions +and processes between Services, avoid having too much logic in +Services. + +""" +from . import base_action +from . import stock_package_level diff --git a/shopfloor/actions/base_action.py b/shopfloor/actions/base_action.py new file mode 100644 index 0000000000..e7d5bbda5b --- /dev/null +++ b/shopfloor/actions/base_action.py @@ -0,0 +1,10 @@ +from odoo.addons.component.core import AbstractComponent + + +class ShopFloorProcessAction(AbstractComponent): + _name = "shopfloor.process.action" + _collection = "shopfloor.action" + _usage = "actions" + + def actions_for(self, model_name): + return self.component(usage="actions", model_name=model_name) diff --git a/shopfloor/actions/stock_package_level.py b/shopfloor/actions/stock_package_level.py new file mode 100644 index 0000000000..dd48a12da6 --- /dev/null +++ b/shopfloor/actions/stock_package_level.py @@ -0,0 +1,10 @@ +from odoo.addons.component.core import Component + + +class StockPackageLevelAction(Component): + _name = "shopfloor.stock.package_level.action" + _inherit = "shopfloor.process.action" + _apply_on = "stock.package_level" + + def hello(self): + return "world" diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index d96822cb43..f9bfd90518 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -2,7 +2,8 @@ from odoo.exceptions import MissingError from odoo.osv import expression -from odoo.addons.component.core import AbstractComponent +from odoo.addons.base_rest.controllers.main import _PseudoCollection +from odoo.addons.component.core import AbstractComponent, WorkContext class BaseShopfloorService(AbstractComponent): @@ -11,6 +12,7 @@ class BaseShopfloorService(AbstractComponent): _inherit = "base.rest.service" _name = "base.shopfloor.service" _collection = "shopfloor.service" + _actions_collection_name = "shopfloor.action" _expose_model = None def _get(self, _id): @@ -121,3 +123,33 @@ def _get_openapi_default_parameters(self): ] ) return defaults + + @property + def actions_collection(self): + return _PseudoCollection(self._actions_collection_name, self.env) + + def actions_for(self, model_name): + """Return an Action Component for the model + + Action Components are the components supporting the business logic of + the processes, so we can limit the code in Services to the minimum and + share methods. + """ + # propagate custom arguments (such as menu ID/profile ID) + kwargs = { + attr_name: getattr(self.work, attr_name) + for attr_name in self.work._propagate_kwargs + if attr_name + not in ("collection", "model_name", "components_registry", "model_name") + } + work = WorkContext( + model_name=model_name, collection=self.actions_collection, **kwargs + ) + return work.component(usage="actions") + + def _is_public_api_method(self, method_name): + # do not "hide" the "actions_for" method as internal since, we'll use + # it in components, so exclude it from the rest API + if method_name == "actions_for": + return False + return super()._is_public_api_method(method_name) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 52f3994b3e..cbf6154eb3 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -15,7 +15,9 @@ class SinglePackPutaway(Component): def scan(self, barcode): """Scan a pack barcode""" - company = self.env.user.company_id # FIXME add logic to get proper company + company = ( + self.env.user.company_id + ) # FIXME add logic to get proper company # FIXME add logic to get proper warehouse warehouse = self.env["stock.warehouse"].search([])[0] picking_type = ( @@ -52,7 +54,8 @@ def scan(self, barcode): ) if ( existing_operations - and existing_operations[0].picking_id.picking_type_id != picking_type + and existing_operations[0].picking_id.picking_type_id + != picking_type ): return self._response( state="start", @@ -86,14 +89,18 @@ def scan(self, barcode): "id": move.product_id.name, "name": move.product_id.name, }, - "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, + "picking": { + "id": move.picking_id.id, + "name": move.picking_id.name, + }, }, state="confirm_start", message={ "message_type": "warning", "title": _("Already started"), "message": _( - "Operation already running. " "Would you like to take it over ?" + "Operation already running. " + "Would you like to take it over ?" ), }, ) @@ -147,13 +154,29 @@ def scan(self, barcode): "id": move.move_line_ids[0].location_dest_id.id, "name": move.move_line_ids[0].location_dest_id.name, }, - "product": {"id": move.product_id.id, "name": move.product_id.name}, - "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, + "product": { + "id": move.product_id.id, + "name": move.product_id.name, + }, + "picking": { + "id": move.picking_id.id, + "name": move.picking_id.name, + }, }, ) def validate(self, package_level_id, location_barcode, confirmation=False): + """Validate the transfer""" package = self.env["stock.package_level"].browse(package_level_id) + if not package.exists(): + return self._response( + state="start", + message={ + "message_type": "error", + "title": _("Start again"), + "message": _("This operation does not exist anymore."), + }, + ) move = package.move_line_ids[0].move_id if move.state == "cancel": return self._response( @@ -161,7 +184,9 @@ def validate(self, package_level_id, location_barcode, confirmation=False): message={ "message_type": "warning", "title": _("Restart"), - "message": _("Restart the operation, someone has canceled it."), + "message": _( + "Restart the operation, someone has canceled it." + ), }, ) dest_location = self.env["stock.location"].search( @@ -189,7 +214,10 @@ def validate(self, package_level_id, location_barcode, confirmation=False): "message": _("You cannot place it here"), }, ) - elif dest_location in allowed_locations and dest_location not in zone_locations: + elif ( + dest_location in allowed_locations + and dest_location not in zone_locations + ): if confirmation: move.location_dest_id = dest_location.id else: @@ -208,7 +236,9 @@ def validate(self, package_level_id, location_barcode, confirmation=False): message={ "message_type": "info", "title": _("Start"), - "message": _("The pack has been moved, you can scan a new pack."), + "message": _( + "The pack has been moved, you can scan a new pack." + ), }, ) @@ -229,26 +259,42 @@ def cancel(self, package_level_id): message={ "message_type": "info", "title": _("Start"), - "message": _("The move has been canceled, you can scan a new pack."), + "message": _( + "The move has been canceled, you can scan a new pack." + ), }, ) def _validator_cancel(self): return { - "package_level_id": {"coerce": to_int, "required": True, "type": "integer"} + "package_level_id": { + "coerce": to_int, + "required": True, + "type": "integer", + } } def _validator_validate(self): return { - "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, - "location_barcode": {"type": "string", "nullable": False, "required": True}, + "package_level_id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "location_barcode": { + "type": "string", + "nullable": False, + "required": True, + }, } def _validator_return_validate(self): return self._response_schema() def _validator_scan(self): - return {"barcode": {"type": "string", "nullable": False, "required": True}} + return { + "barcode": {"type": "string", "nullable": False, "required": True} + } def _validator_return_scan(self): return self._response_schema( @@ -257,29 +303,61 @@ def _validator_return_scan(self): "location_src": { "type": "dict", "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, }, }, "location_dst": { "type": "dict", "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, }, }, "product": { "type": "dict", "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, }, }, "picking": { "type": "dict", "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, + "id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, }, }, } diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index a22659a1cb..e29fdbf56c 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -1,3 +1,4 @@ +from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -8,3 +9,14 @@ class SinglePackTransfer(Component): _name = "shopfloor.single.pack.transfer" _usage = "single_pack_transfer" _description = __doc__ + + def validate(self, package_level_id, location_name, confirmation=False): + """Validate the transfer""" + return self.actions_for("stock.package_level").hello() + + def _validator_validate(self): + return { + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + "location_name": {"type": "string", "nullable": False, "required": True}, + "confirmation": {"type": "boolean", "required": False}, + } From 6a8f068eb0daacb7d813a1900d5afa64c274ad3b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jan 2020 15:11:17 +0100 Subject: [PATCH 050/940] Add /app/load_config endpoint (load menus+profiles at once) --- shopfloor/services/__init__.py | 1 + shopfloor/services/app.py | 42 ++++++++++++++++++++++++++ shopfloor/services/menu.py | 41 ++++++++++--------------- shopfloor/services/profile.py | 55 ++++++++++++++-------------------- 4 files changed, 81 insertions(+), 58 deletions(-) create mode 100644 shopfloor/services/app.py diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index bb82a1df2e..5eb564a6a6 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -1,4 +1,5 @@ from . import service +from . import app from . import profile from . import menu from . import pack diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py new file mode 100644 index 0000000000..ed1bbf4880 --- /dev/null +++ b/shopfloor/services/app.py @@ -0,0 +1,42 @@ +from odoo.addons.component.core import Component + + +class ShopfloorApp(Component): + """Generic endpoints for the Application.""" + + _inherit = "base.shopfloor.service" + _name = "shopfloor.app" + _usage = "app" + _description = __doc__ + + def user_config(self): + menus = self.component("menu")._search() + profiles = self.component("profile")._search() + return self._response(data={"menus": menus, "profiles": profiles}) + + def _validator_user_config(self): + return {} + + def _validator_return_user_config(self): + menu_service = self.component("menu") + profile_service = self.component("profile") + return self._response_schema( + { + "menus": { + "type": "list", + "required": True, + "schema": { + "type": "dict", + "schema": menu_service._record_return_schema, + }, + }, + "profiles": { + "type": "list", + "required": True, + "schema": { + "type": "dict", + "schema": profile_service._record_return_schema, + }, + }, + } + ) diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index 33ea065b67..96847ce7c4 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -33,15 +33,17 @@ def _get_base_search_domain(self): ] ) - def search(self, name_fragment=None): - """List available menu entries for current user""" + def _search(self, name_fragment=None): domain = self._get_base_search_domain() if name_fragment: domain.append(("name", "ilike", name_fragment)) records = self.env[self._expose_model].search(domain) - return self._response( - data={"size": len(records), "records": self._to_json(records)} - ) + return self._to_json(records) + + def search(self, name_fragment=None): + """List available menu entries for current user""" + json_records = self._search(name_fragment=name_fragment) + return self._response(data={"size": len(json_records), "records": json_records}) def _validator_search(self): return { @@ -55,29 +57,18 @@ def _validator_return_search(self): "records": { "type": "list", "required": True, - "schema": { - "type": "dict", - "schema": { - "id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, - "process": { - "type": "string", - "nullable": False, - "required": True, - }, - }, - }, + "schema": {"type": "dict", "schema": self._record_return_schema}, }, } ) + @property + def _record_return_schema(self): + return { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "process": {"type": "string", "nullable": False, "required": True}, + } + def _convert_one_record(self, record): return {"id": record.id, "name": record.name, "process": record.process_code} diff --git a/shopfloor/services/profile.py b/shopfloor/services/profile.py index 86e2ed1175..9f59ef0706 100644 --- a/shopfloor/services/profile.py +++ b/shopfloor/services/profile.py @@ -25,14 +25,18 @@ class ShopfloorProfile(Component): _expose_model = "shopfloor.profile" _description = __doc__ - def search(self, name_fragment=None): - """List available profiles for current user""" + def _search(self, name_fragment=None): domain = self._get_base_search_domain() if name_fragment: domain.append(("name", "ilike", name_fragment)) records = self.env[self._expose_model].search(domain) + return self._to_json(records) + + def search(self, name_fragment=None): + """List available profiles for current user""" + json_records = self._search(name_fragment=name_fragment) return self._response( - data={"size": len(records), "records": self._to_json(records)} + data={"size": len(json_records), "records": self._to_json(json_records)} ) def _get_base_search_domain(self): @@ -65,40 +69,25 @@ def _validator_return_search(self): "size": {"coerce": to_int, "required": True, "type": "integer"}, "records": { "type": "list", - "schema": { - "type": "dict", - "schema": { - "id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, - "warehouse": { - "type": "dict", - "schema": { - "id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, - }, - }, - }, - }, + "schema": {"type": "dict", "schema": self._record_return_schema}, }, } ) + @property + def _record_return_schema(self): + return { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "warehouse": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + } + def _convert_one_record(self, record): return { "id": record.id, From 5c141cbb26ea54e78869121aba74b2ad9a1c04d0 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jan 2020 15:14:20 +0100 Subject: [PATCH 051/940] pre-commit run -a --- shopfloor/services/single_pack_putaway.py | 112 +++++----------------- 1 file changed, 22 insertions(+), 90 deletions(-) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index cbf6154eb3..b22e779748 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -15,9 +15,7 @@ class SinglePackPutaway(Component): def scan(self, barcode): """Scan a pack barcode""" - company = ( - self.env.user.company_id - ) # FIXME add logic to get proper company + company = self.env.user.company_id # FIXME add logic to get proper company # FIXME add logic to get proper warehouse warehouse = self.env["stock.warehouse"].search([])[0] picking_type = ( @@ -54,8 +52,7 @@ def scan(self, barcode): ) if ( existing_operations - and existing_operations[0].picking_id.picking_type_id - != picking_type + and existing_operations[0].picking_id.picking_type_id != picking_type ): return self._response( state="start", @@ -89,18 +86,14 @@ def scan(self, barcode): "id": move.product_id.name, "name": move.product_id.name, }, - "picking": { - "id": move.picking_id.id, - "name": move.picking_id.name, - }, + "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, }, state="confirm_start", message={ "message_type": "warning", "title": _("Already started"), "message": _( - "Operation already running. " - "Would you like to take it over ?" + "Operation already running. " "Would you like to take it over ?" ), }, ) @@ -154,14 +147,8 @@ def scan(self, barcode): "id": move.move_line_ids[0].location_dest_id.id, "name": move.move_line_ids[0].location_dest_id.name, }, - "product": { - "id": move.product_id.id, - "name": move.product_id.name, - }, - "picking": { - "id": move.picking_id.id, - "name": move.picking_id.name, - }, + "product": {"id": move.product_id.id, "name": move.product_id.name}, + "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, }, ) @@ -184,9 +171,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False): message={ "message_type": "warning", "title": _("Restart"), - "message": _( - "Restart the operation, someone has canceled it." - ), + "message": _("Restart the operation, someone has canceled it."), }, ) dest_location = self.env["stock.location"].search( @@ -214,10 +199,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False): "message": _("You cannot place it here"), }, ) - elif ( - dest_location in allowed_locations - and dest_location not in zone_locations - ): + elif dest_location in allowed_locations and dest_location not in zone_locations: if confirmation: move.location_dest_id = dest_location.id else: @@ -236,9 +218,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False): message={ "message_type": "info", "title": _("Start"), - "message": _( - "The pack has been moved, you can scan a new pack." - ), + "message": _("The pack has been moved, you can scan a new pack."), }, ) @@ -259,42 +239,26 @@ def cancel(self, package_level_id): message={ "message_type": "info", "title": _("Start"), - "message": _( - "The move has been canceled, you can scan a new pack." - ), + "message": _("The move has been canceled, you can scan a new pack."), }, ) def _validator_cancel(self): return { - "package_level_id": { - "coerce": to_int, - "required": True, - "type": "integer", - } + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"} } def _validator_validate(self): return { - "package_level_id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "location_barcode": { - "type": "string", - "nullable": False, - "required": True, - }, + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + "location_barcode": {"type": "string", "nullable": False, "required": True}, } def _validator_return_validate(self): return self._response_schema() def _validator_scan(self): - return { - "barcode": {"type": "string", "nullable": False, "required": True} - } + return {"barcode": {"type": "string", "nullable": False, "required": True}} def _validator_return_scan(self): return self._response_schema( @@ -303,61 +267,29 @@ def _validator_return_scan(self): "location_src": { "type": "dict", "schema": { - "id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, }, }, "location_dst": { "type": "dict", "schema": { - "id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, }, }, "product": { "type": "dict", "schema": { - "id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, }, }, "picking": { "type": "dict", "schema": { - "id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, }, }, } From 05b1de74e6daab7fe5dd378ebbdfa454709c7e7f Mon Sep 17 00:00:00 2001 From: Benoit Date: Thu, 23 Jan 2020 15:19:35 +0100 Subject: [PATCH 052/940] get picking type properly from menu and profile --- shopfloor/services/service.py | 9 +++++++++ shopfloor/services/single_pack_putaway.py | 21 +++++++++++++-------- shopfloor/tests/test_single_pack_putaway.py | 5 ++++- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index f9bfd90518..7b47800d1b 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -15,6 +15,15 @@ class BaseShopfloorService(AbstractComponent): _actions_collection_name = "shopfloor.action" _expose_model = None + @property + def picking_types(self): + """ + Get the current picking type based on the menu and the warehouse of the profile. + """ + return self.work.menu.process_id.picking_type_ids.filtered( + lambda p: p.warehouse_id == self.work.profile.warehouse_id + ) + def _get(self, _id): domain = expression.normalize_domain(self._get_base_search_domain()) domain = expression.AND([domain, [("id", "=", _id)]]) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index b22e779748..38dbea6ce0 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -14,14 +14,19 @@ class SinglePackPutaway(Component): def scan(self, barcode): """Scan a pack barcode""" - + picking_type = self.picking_types + if len(picking_type) > 1: + return self._response( + state="start", + message={ + "message_type": "error", + "title": _("Configuration error"), + "message": _( + "Several picking types found for this menu and profile" + ), + }, + ) company = self.env.user.company_id # FIXME add logic to get proper company - # FIXME add logic to get proper warehouse - warehouse = self.env["stock.warehouse"].search([])[0] - picking_type = ( - warehouse.int_type_id - ) # FIXME add logic to get picking type properly - # TODO define on what we search (pack name, pack barcode ...) pack = self.env["stock.quant.package"].search([("name", "=", barcode)]) if not pack: @@ -93,7 +98,7 @@ def scan(self, barcode): "message_type": "warning", "title": _("Already started"), "message": _( - "Operation already running. " "Would you like to take it over ?" + "Operation already running. Would you like to take it over ?" ), }, ) diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 7d2057b370..7e72d52939 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -33,7 +33,10 @@ def setUp(self, *args, **kwargs): "location_out_id": out_location.id, } ) - with self.work_on_services() as work: + menu = self.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck") + profile = self.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + profile.warehouse_id.int_type_id.process_id = menu.process_id.id + with self.work_on_services(menu=menu, profile=profile) as work: self.service = work.component(usage="single_pack_putaway") def test_scan_pack(self): From 6e0b780314ebd00e182d8ff26420d55f008cac63 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jan 2020 16:28:24 +0100 Subject: [PATCH 053/940] Use a common Action Component for pack transfer 'validate' The Action Copmonent contains all the support methods, used in the workflow for the "validate" method in the Service Component. The validate method is currently the same in the Service Component for pack transfer and put-away, but it can make sense if we consider it deals mainly with the responses to do for the client application: if we want to, for instance, add a step for one process but not the other, we want to change this is the Service Component, reusing the methods of the Actions Component. --- shopfloor/actions/__init__.py | 5 +- shopfloor/actions/base_action.py | 4 +- shopfloor/actions/pack_transfer_validate.py | 39 +++++++ shopfloor/actions/stock_package_level.py | 10 -- shopfloor/services/service.py | 13 +-- shopfloor/services/single_pack_putaway.py | 123 +++++++++++--------- shopfloor/services/single_pack_transfer.py | 81 ++++++++++++- 7 files changed, 191 insertions(+), 84 deletions(-) create mode 100644 shopfloor/actions/pack_transfer_validate.py delete mode 100644 shopfloor/actions/stock_package_level.py diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index b9b5c7e551..af4a9151a5 100644 --- a/shopfloor/actions/__init__.py +++ b/shopfloor/actions/__init__.py @@ -7,10 +7,9 @@ * Public methods of a Service Components are exposed in the REST API, Action Components are never exposed -* Action Components will generally have an existing Odoo model name An Action component can be get from Service or Action Components using -``self.actions_for(model_name)``. +``self.actions_for(usage)``. The goal of the Action Components is to share common actions and processes between Services, avoid having too much logic in @@ -18,4 +17,4 @@ """ from . import base_action -from . import stock_package_level +from . import pack_transfer_validate diff --git a/shopfloor/actions/base_action.py b/shopfloor/actions/base_action.py index e7d5bbda5b..e026e1c1e1 100644 --- a/shopfloor/actions/base_action.py +++ b/shopfloor/actions/base_action.py @@ -6,5 +6,5 @@ class ShopFloorProcessAction(AbstractComponent): _collection = "shopfloor.action" _usage = "actions" - def actions_for(self, model_name): - return self.component(usage="actions", model_name=model_name) + def actions_for(self, usage): + return self.component(usage=usage) diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py new file mode 100644 index 0000000000..bbfe013aca --- /dev/null +++ b/shopfloor/actions/pack_transfer_validate.py @@ -0,0 +1,39 @@ +from odoo.addons.component.core import Component + + +class PackTransferValidateAction(Component): + _name = "shopfloor.pack.transfer.validate.action" + _inherit = "shopfloor.process.action" + _usage = "pack.transfer.validate" + + def location_from_scan(self, barcode): + return self.env["stock.location"].search([("barcode", "=", barcode)]) + + def is_move_state_valid(self, move): + return move.state != "cancel" + + def is_dest_location_valid(self, move, scanned_location): + """Forbid a dest location to be used""" + allowed_locations = self.env["stock.location"].search( + [ + ( + "id", + "child_of", + move.picking_id.picking_type_id.default_location_dest_id.id, + ) + ] + ) + return scanned_location in allowed_locations + + def is_dest_location_to_confirm(self, move, scanned_location): + """Destination that could be used but need confirmation""" + move_dest_location = move.move_line_ids[0].location_dest_id + zone_locations = self.env["stock.location"].search( + [("id", "child_of", move_dest_location.id)] + ) + return scanned_location in zone_locations + + def set_destination_and_done(self, move, scanned_location): + + move.move_line_ids[0].location_dest_id = scanned_location.id + move._action_done() diff --git a/shopfloor/actions/stock_package_level.py b/shopfloor/actions/stock_package_level.py deleted file mode 100644 index dd48a12da6..0000000000 --- a/shopfloor/actions/stock_package_level.py +++ /dev/null @@ -1,10 +0,0 @@ -from odoo.addons.component.core import Component - - -class StockPackageLevelAction(Component): - _name = "shopfloor.stock.package_level.action" - _inherit = "shopfloor.process.action" - _apply_on = "stock.package_level" - - def hello(self): - return "world" diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 7b47800d1b..0b85c49958 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -137,8 +137,8 @@ def _get_openapi_default_parameters(self): def actions_collection(self): return _PseudoCollection(self._actions_collection_name, self.env) - def actions_for(self, model_name): - """Return an Action Component for the model + def actions_for(self, usage): + """Return an Action Component for a usage Action Components are the components supporting the business logic of the processes, so we can limit the code in Services to the minimum and @@ -148,13 +148,10 @@ def actions_for(self, model_name): kwargs = { attr_name: getattr(self.work, attr_name) for attr_name in self.work._propagate_kwargs - if attr_name - not in ("collection", "model_name", "components_registry", "model_name") + if attr_name not in ("collection", "components_registry") } - work = WorkContext( - model_name=model_name, collection=self.actions_collection, **kwargs - ) - return work.component(usage="actions") + work = WorkContext(collection=self.actions_collection, **kwargs) + return work.component(usage=usage) def _is_public_api_method(self, method_name): # do not "hide" the "actions_for" method as internal since, we'll use diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 38dbea6ce0..f0c28df1b8 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -157,67 +157,47 @@ def scan(self, barcode): }, ) - def validate(self, package_level_id, location_barcode, confirmation=False): - """Validate the transfer""" - package = self.env["stock.package_level"].browse(package_level_id) - if not package.exists(): - return self._response( - state="start", - message={ - "message_type": "error", - "title": _("Start again"), - "message": _("This operation does not exist anymore."), - }, - ) - move = package.move_line_ids[0].move_id - if move.state == "cancel": - return self._response( - state="start", - message={ - "message_type": "warning", - "title": _("Restart"), - "message": _("Restart the operation, someone has canceled it."), - }, - ) - dest_location = self.env["stock.location"].search( - [("barcode", "=", location_barcode)] + def _response_for_package_not_found(self): + return self._response( + state="start", + message={ + "message_type": "error", + "title": _("Start again"), + "message": _("This operation does not exist anymore."), + }, ) - move_dest_location = move.move_line_ids[0].location_dest_id - allowed_locations = self.env["stock.location"].search( - [ - ( - "id", - "child_of", - move.picking_id.picking_type_id.default_location_dest_id.id, - ) - ] + + def _response_for_move_canceled(self): + return self._response( + state="start", + message={ + "message_type": "warning", + "title": _("Restart"), + "message": _("Restart the operation, someone has canceled it."), + }, ) - zone_locations = self.env["stock.location"].search( - [("id", "child_of", move_dest_location.id)] + + def _response_for_forbidden_location(self): + return self._response( + state="scan_location", + message={ + "message_type": "error", + "title": _("Forbidden"), + "message": _("You cannot place it here"), + }, ) - if dest_location not in allowed_locations: - return self._response( - state="scan_location", - message={ - "message_type": "error", - "title": _("Forbidden"), - "message": _("You cannot place it here"), - }, - ) - elif dest_location in allowed_locations and dest_location not in zone_locations: - if confirmation: - move.location_dest_id = dest_location.id - else: - return self._response( - state="confirm_location", - message={ - "message_type": "warning", - "title": _("Confirm"), - "message": _("Are you sure?"), - }, - ) - move.move_line_ids[0].location_dest_id = dest_location.id - move._action_done() + + def _response_for_location_need_confirm(self): + return self._response( + state="confirm_location", + message={ + "message_type": "warning", + "title": _("Confirm"), + "message": _("Are you sure?"), + }, + ) + + def _response_for_validate_success(self): return self._response( state="start", message={ @@ -227,6 +207,33 @@ def validate(self, package_level_id, location_barcode, confirmation=False): }, ) + def validate(self, package_level_id, location_barcode, confirmation=False): + """Validate the transfer""" + pack_transfer = self.actions_for("pack.transfer.validate") + + package = self.env["stock.package_level"].browse(package_level_id) + if not package.exists(): + return self._response_for_package_not_found() + + move = package.move_line_ids[0].move_id + if not pack_transfer.is_move_state_valid(move): + return self._response_for_move_canceled() + + scanned_location = pack_transfer.location_from_scan(location_barcode) + if not pack_transfer.is_dest_location_valid(move, scanned_location): + return self._response_for_forbidden_location() + + if not pack_transfer.is_dest_location_to_confirm(move, scanned_location): + if confirmation: + # keep the move in sync otherwise we would have a move line outside + # the dest location of the move + move.location_dest_id = scanned_location.id + else: + return self._response_for_location_need_confirm() + + pack_transfer.set_destination_and_done() + return self._response_for_validate_success() + def cancel(self, package_level_id): package = self.env["stock.package_level"].browse(package_level_id) if not package.exists(): diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index e29fdbf56c..204e9656ec 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -1,3 +1,5 @@ +from odoo import _ + from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -10,13 +12,86 @@ class SinglePackTransfer(Component): _usage = "single_pack_transfer" _description = __doc__ - def validate(self, package_level_id, location_name, confirmation=False): + def _response_for_package_not_found(self): + return self._response( + state="start", + message={ + "message_type": "error", + "title": _("Start again"), + "message": _("This operation does not exist anymore."), + }, + ) + + def _response_for_move_canceled(self): + return self._response( + state="start", + message={ + "message_type": "warning", + "title": _("Restart"), + "message": _("Restart the operation, someone has canceled it."), + }, + ) + + def _response_for_forbidden_location(self): + return self._response( + state="scan_location", + message={ + "message_type": "error", + "title": _("Forbidden"), + "message": _("You cannot place it here"), + }, + ) + + def _response_for_location_need_confirm(self): + return self._response( + state="confirm_location", + message={ + "message_type": "warning", + "title": _("Confirm"), + "message": _("Are you sure?"), + }, + ) + + def _response_for_validate_success(self): + return self._response( + state="start", + message={ + "message_type": "info", + "title": _("Start"), + "message": _("The pack has been moved, you can scan a new pack."), + }, + ) + + def validate(self, package_level_id, location_barcode, confirmation=False): """Validate the transfer""" - return self.actions_for("stock.package_level").hello() + pack_transfer = self.actions_for("pack.transfer.validate") + + package = self.env["stock.package_level"].browse(package_level_id) + if not package.exists(): + return self._response_for_package_not_found() + + move = package.move_line_ids[0].move_id + if not pack_transfer.is_move_state_valid(move): + return self._response_for_move_canceled() + + scanned_location = pack_transfer.location_from_scan(location_barcode) + if not pack_transfer.is_dest_location_valid(move, scanned_location): + return self._response_for_forbidden_location() + + if not pack_transfer.is_dest_location_to_confirm(move, scanned_location): + if confirmation: + # keep the move in sync otherwise we would have a move line outside + # the dest location of the move + move.location_dest_id = scanned_location.id + else: + return self._response_for_location_need_confirm() + + pack_transfer.set_destination_and_done() + return self._response_for_validate_success() def _validator_validate(self): return { "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, - "location_name": {"type": "string", "nullable": False, "required": True}, + "location_barcode": {"type": "string", "nullable": False, "required": True}, "confirmation": {"type": "boolean", "required": False}, } From 66cb05be5cb5ba70f1ec7263b1d1bbe22db8758a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jan 2020 16:53:06 +0100 Subject: [PATCH 054/940] fixup! Use a common Action Component for pack transfer 'validate' --- shopfloor/services/single_pack_transfer.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 204e9656ec..6aee17f2a4 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -95,3 +95,6 @@ def _validator_validate(self): "location_barcode": {"type": "string", "nullable": False, "required": True}, "confirmation": {"type": "boolean", "required": False}, } + + def _validator_return_validate(self): + return self._response_schema() From fc9ba6958618b8915f77b0ed2c7cf05ad72c1eb4 Mon Sep 17 00:00:00 2001 From: Benoit Date: Thu, 23 Jan 2020 18:07:24 +0100 Subject: [PATCH 055/940] add response methods for start action --- shopfloor/services/single_pack_putaway.py | 216 +++++++++++--------- shopfloor/services/single_pack_transfer.py | 168 ++++++++++++++- shopfloor/tests/test_single_pack_putaway.py | 4 +- 3 files changed, 285 insertions(+), 103 deletions(-) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index f0c28df1b8..8647f9089f 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -12,45 +12,124 @@ class SinglePackPutaway(Component): _usage = "single_pack_putaway" _description = __doc__ - def scan(self, barcode): + def _response_for_several_picking_types(self): + return self._response( + state="start", + message={ + "message_type": "error", + "title": _("Configuration error"), + "message": _("Several picking types found for this menu and profile"), + }, + ) + + def _response_for_package_not_found(self, barcode): + return self._response( + state="start", + message={ + "message_type": "error", + "title": _("Pack not found"), + "message": _("The pack %s doesn't exist") % barcode, + }, + ) + + def _response_for_forbidden_package(self, barcode, picking_type): + return self._response( + state="start", + message={ + "message_type": "error", + "title": _("Cannot proceed"), + "message": _("pack %s is not in %s location") + % (barcode, picking_type.default_location_src_id.name), + }, + ) + + def _response_for_forbidden_start(self, existing_operations): + return self._response( + state="start", + message={ + "message_type": "error", + "title": _("Cannot proceed"), + "message": _( + "An operation exists in %s %s. " + "You cannot process it with this shopfloor process." + ) + % ( + existing_operations[0].picking_id.picking_type_id.name, + existing_operations[0].picking_id.name, + ), + }, + ) + + def _response_for_start_to_confirm(self, existing_operations, pack): + move = existing_operations.move_id + return self._response( + data={ + "id": existing_operations[0].package_level_id.id, + "location_src": { + "id": pack.location_id.id, + "name": pack.location_id.name, + }, + "location_dst": { + "id": existing_operations[0].location_dest_id.id, + "name": existing_operations[0].location_dest_id.name, + }, + "product": {"id": move.product_id.name, "name": move.product_id.name}, + "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, + }, + state="confirm_start", + message={ + "message_type": "warning", + "title": _("Already started"), + "message": _( + "Operation already running. Would you like to take it over ?" + ), + }, + ) + + def _response_for_start_success(self, move, pack): + return self._response( + state="scan_location", + message={ + "message_type": "info", + "title": _("Start"), + "message": _( + "The move is ready, you can scan the destination location." + ), + }, + data={ + "id": move.move_line_ids[0].package_level_id.id, + "location_src": { + "id": pack.location_id.id, + "name": pack.location_id.name, + }, + "location_dst": { + "id": move.move_line_ids[0].location_dest_id.id, + "name": move.move_line_ids[0].location_dest_id.name, + }, + "product": {"id": move.product_id.id, "name": move.product_id.name}, + "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, + }, + ) + + def start(self, barcode): """Scan a pack barcode""" + picking_type = self.picking_types if len(picking_type) > 1: - return self._response( - state="start", - message={ - "message_type": "error", - "title": _("Configuration error"), - "message": _( - "Several picking types found for this menu and profile" - ), - }, - ) + return self._response_from_several_picking_types() company = self.env.user.company_id # FIXME add logic to get proper company # TODO define on what we search (pack name, pack barcode ...) + pack = self.env["stock.quant.package"].search([("name", "=", barcode)]) if not pack: - return self._response( - state="start", - message={ - "message_type": "error", - "title": _("Pack not found"), - "message": _("The pack %s doesn't exist") % barcode, - }, - ) + return self._response_for_package_not_found(barcode) + allowed_locations = self.env["stock.location"].search( [("id", "child_of", picking_type.default_location_src_id.id)] ) if pack.location_id not in allowed_locations: - return self._response( - state="start", - message={ - "message_type": "error", - "title": _("Cannot proceed"), - "message": _("pack %s is not in %s location") - % (barcode, picking_type.default_location_src_id.name), - }, - ) + return self._response_for_forbidden_package(barcode, picking_type) + quantity = pack.quant_ids[0].quantity existing_operations = self.env["stock.move.line"].search( [("qty_done", "=", quantity), ("package_id", "=", pack.id)] @@ -59,49 +138,9 @@ def scan(self, barcode): existing_operations and existing_operations[0].picking_id.picking_type_id != picking_type ): - return self._response( - state="start", - message={ - "message_type": "error", - "title": _("Cannot proceed"), - "message": _( - "An operation exists in %s %s. " - "You cannot process it with this shopfloor process." - ) - % ( - existing_operations[0].picking_id.picking_type_id.name, - existing_operations[0].picking_id.name, - ), - }, - ) + return self._response_for_forbidden_start(existing_operations) elif existing_operations: - move = existing_operations.move_id - return self._response( - data={ - "id": move.move_line_ids[0].package_level_id.id, - "location_src": { - "id": pack.location_id.id, - "name": pack.location_id.name, - }, - "location_dst": { - "id": move.move_line_ids[0].location_dest_id.id, - "name": move.move_line_ids[0].location_dest_id.name, - }, - "product": { - "id": move.product_id.name, - "name": move.product_id.name, - }, - "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, - }, - state="confirm_start", - message={ - "message_type": "warning", - "title": _("Already started"), - "message": _( - "Operation already running. Would you like to take it over ?" - ), - }, - ) + return self._response_for_start_to_confirm(existing_operations, pack) product = pack.quant_ids[ 0 ].product_id # FIXME we consider only one product per pack @@ -130,34 +169,13 @@ def scan(self, barcode): "is_done": True, "location_id": pack.location_id.id, "location_dest_id": location_dest_id, + "picking_id": move.picking_id.id, } ) - move.picking_id.action_assign() - return self._response( - state="scan_location", - message={ - "message_type": "info", - "title": _("Start"), - "message": _( - "The move is ready, you can scan the destination location." - ), - }, - data={ - "id": move.move_line_ids[0].package_level_id.id, - "location_src": { - "id": pack.location_id.id, - "name": pack.location_id.name, - }, - "location_dst": { - "id": move.move_line_ids[0].location_dest_id.id, - "name": move.move_line_ids[0].location_dest_id.name, - }, - "product": {"id": move.product_id.id, "name": move.product_id.name}, - "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, - }, - ) + move._action_assign() + return self._response_for_start_success(move, pack) - def _response_for_package_not_found(self): + def _response_for_package_level_not_found(self): return self._response( state="start", message={ @@ -213,7 +231,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False): package = self.env["stock.package_level"].browse(package_level_id) if not package.exists(): - return self._response_for_package_not_found() + return self._response_for_package_level_not_found() move = package.move_line_ids[0].move_id if not pack_transfer.is_move_state_valid(move): @@ -231,7 +249,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False): else: return self._response_for_location_need_confirm() - pack_transfer.set_destination_and_done() + pack_transfer.set_destination_and_done(move, scanned_location) return self._response_for_validate_success() def cancel(self, package_level_id): @@ -269,10 +287,10 @@ def _validator_validate(self): def _validator_return_validate(self): return self._response_schema() - def _validator_scan(self): + def _validator_start(self): return {"barcode": {"type": "string", "nullable": False, "required": True}} - def _validator_return_scan(self): + def _validator_return_start(self): return self._response_schema( { "id": {"coerce": to_int, "required": True, "type": "integer"}, diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 6aee17f2a4..dbf0784e5e 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -12,7 +12,171 @@ class SinglePackTransfer(Component): _usage = "single_pack_transfer" _description = __doc__ - def _response_for_package_not_found(self): + def _response_for_empty_location(self, location): + return self._response( + state="start", + message={ + "message_type": "error", + "title": _("Scan package"), + "message": _("Location %s doesn’t contain any PACK." % location.name), + }, + ) + + def _response_for_several_packages(self, location): + return self._response( + state="start", + message={ + "message_type": "error", + "title": _("Scan package"), + "message": _( + "Several PACKs found in %s, please scan one PACK." % location.name + ), + }, + ) + + def _response_for_package_not_found(self, barcode): + return self._response( + state="start", + message={ + "message_type": "error", + "title": _("Pack not found"), + "message": _("The pack %s doesn't exist") % barcode, + }, + ) + + def _response_for_operation_not_found(self): + return self._response( + state="start", + message={ + "message_type": "error", + "title": _("Not found"), + "message": _( + "No pending operation exists for this PACK you cannot process it." + ), + }, + ) + + def _response_for_start_to_confirm(self, existing_operations, pack): + move = existing_operations.move_id + return self._response( + data={ + "id": existing_operations[0].package_level_id.id, + "location_src": { + "id": pack.location_id.id, + "name": pack.location_id.name, + }, + "location_dst": { + "id": existing_operations[0].location_dest_id.id, + "name": existing_operations[0].location_dest_id.name, + }, + "product": {"id": move.product_id.name, "name": move.product_id.name}, + "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, + }, + state="confirm_start", + message={ + "message_type": "warning", + "title": _("Already started"), + "message": _( + "Operation already running. Would you like to take it over ?" + ), + }, + ) + + def _response_for_start_success(self, move, pack): + return self._response( + state="scan_location", + message={ + "message_type": "info", + "title": _("Start"), + "message": _( + "The move is ready, you can scan the destination location." + ), + }, + data={ + "id": move.move_line_ids[0].package_level_id.id, + "location_src": { + "id": pack.location_id.id, + "name": pack.location_id.name, + }, + "location_dst": { + "id": move.move_line_ids[0].location_dest_id.id, + "name": move.move_line_ids[0].location_dest_id.name, + }, + "product": {"id": move.product_id.id, "name": move.product_id.name}, + "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, + }, + ) + + def start(self, barcode): + location = self.env["stock.location"].search([("barcode", "=", barcode)]) + pack = self.env["stock.quant.package"] + if location: + pack = self.env["stock.quant.package"].search( + [("location_id", "=", location.id)] + ) + if not pack: + return self._response_for_empty_location(location) + if len(pack) > 1: + return self._response_for_several_packages(self, location) + if not pack: + pack = self.env["stock.quant.package"].search([("name", "=", barcode)]) + if not pack: + return self._response_for_package_not_found(barcode) + + existing_operations = self.env["stock.move.line"].search( + [ + ("package_id", "=", pack.id), + ("picking_id.picking_type_id", "in", self.picking_types.ids), + ] + ) + if not existing_operations: + return self._response_for_operation_not_found() + move = existing_operations.move_id + if existing_operations[0].package_level_id.is_done: + return self._response_for_start_to_confirm(existing_operations, pack) + + existing_operations[0].package_level_id.is_done = True + return self._response_for_start_success(move, pack) + + def _validator_start(self): + return {"barcode": {"type": "string", "nullable": False, "required": True}} + + def _validator_return_start(self): + return self._response_schema( + { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "location_src": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + "location_dst": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + "product": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + "picking": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + } + ) + + def _response_for_package_level_not_found(self): return self._response( state="start", message={ @@ -68,7 +232,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False): package = self.env["stock.package_level"].browse(package_level_id) if not package.exists(): - return self._response_for_package_not_found() + return self._response_for_package_level_not_found() move = package.move_line_ids[0].move_id if not pack_transfer.is_move_state_valid(move): diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 7e72d52939..5347ba99c2 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -39,10 +39,10 @@ def setUp(self, *args, **kwargs): with self.work_on_services(menu=menu, profile=profile) as work: self.service = work.component(usage="single_pack_putaway") - def test_scan_pack(self): + def test_single_pack_putaway(self): barcode = self.packA.name params = {"barcode": barcode} - response = self.service.dispatch("scan", params=params) + response = self.service.dispatch("start", params=params) package_level = self.env["stock.package_level"].browse(response["data"]["id"]) move_id = package_level.move_line_ids[0].move_id.id location_dest = self.env["stock.location"].browse( From 9b1106b619b7bb32ea7c6870d844d3214f4156b6 Mon Sep 17 00:00:00 2001 From: Benoit Date: Fri, 24 Jan 2020 09:25:43 +0100 Subject: [PATCH 056/940] add name in start return --- shopfloor/services/single_pack_putaway.py | 3 +++ shopfloor/services/single_pack_transfer.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 8647f9089f..a4c5851cd0 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -65,6 +65,7 @@ def _response_for_start_to_confirm(self, existing_operations, pack): return self._response( data={ "id": existing_operations[0].package_level_id.id, + "name": pack.name, "location_src": { "id": pack.location_id.id, "name": pack.location_id.name, @@ -98,6 +99,7 @@ def _response_for_start_success(self, move, pack): }, data={ "id": move.move_line_ids[0].package_level_id.id, + "name": pack.name, "location_src": { "id": pack.location_id.id, "name": pack.location_id.name, @@ -294,6 +296,7 @@ def _validator_return_start(self): return self._response_schema( { "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, "location_src": { "type": "dict", "schema": { diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index dbf0784e5e..26abb6e18d 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -61,6 +61,7 @@ def _response_for_start_to_confirm(self, existing_operations, pack): return self._response( data={ "id": existing_operations[0].package_level_id.id, + "name": pack.name, "location_src": { "id": pack.location_id.id, "name": pack.location_id.name, @@ -94,6 +95,7 @@ def _response_for_start_success(self, move, pack): }, data={ "id": move.move_line_ids[0].package_level_id.id, + "name": pack.name, "location_src": { "id": pack.location_id.id, "name": pack.location_id.name, @@ -145,6 +147,7 @@ def _validator_return_start(self): return self._response_schema( { "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, "location_src": { "type": "dict", "schema": { From 68eb4e51e414a2823cc63141cbb1470abf9ea48d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 24 Jan 2020 12:02:30 +0100 Subject: [PATCH 057/940] Add a test for validation of pack put-away Also, the test case is a subclass of SavepointCase, so take benefit of this by creating the setup in setUpClass. --- shopfloor/actions/pack_transfer_validate.py | 9 +++ shopfloor/services/single_pack_putaway.py | 2 +- shopfloor/tests/test_single_pack_putaway.py | 87 +++++++++++++++++---- 3 files changed, 82 insertions(+), 16 deletions(-) diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py index bbfe013aca..8caef8bfc1 100644 --- a/shopfloor/actions/pack_transfer_validate.py +++ b/shopfloor/actions/pack_transfer_validate.py @@ -2,6 +2,15 @@ class PackTransferValidateAction(Component): + """Pack Transfer shared business logic + + + This component is shared by the "validate" action of the processes: + + * single_pack_putaway + * single_pack_transfer + """ + _name = "shopfloor.pack.transfer.validate.action" _inherit = "shopfloor.process.action" _usage = "pack.transfer.validate" diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index a4c5851cd0..15483cc152 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -243,7 +243,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False): if not pack_transfer.is_dest_location_valid(move, scanned_location): return self._response_for_forbidden_location() - if not pack_transfer.is_dest_location_to_confirm(move, scanned_location): + if pack_transfer.is_dest_location_to_confirm(move, scanned_location): if confirmation: # keep the move in sync otherwise we would have a move line outside # the dest location of the move diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 5347ba99c2..8bb1181ca7 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -1,42 +1,52 @@ +from odoo.tests.common import Form + from .common import CommonCase class PutawayCase(CommonCase): - def setUp(self, *args, **kwargs): - super(PutawayCase, self).setUp(*args, **kwargs) - stock_location = self.env.ref("stock.stock_location_stock") - out_location = self.env["stock.location"].search( + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + stock_location = cls.env.ref("stock.stock_location_stock") + cls.stock_location = stock_location + out_location = cls.env["stock.location"].search( [ ("location_id", "=", stock_location.id), ("barcode", "!=", False), ("usage", "=", "internal"), ] )[0] - self.productA = self.env["product.product"].create( + cls.productA = cls.env["product.product"].create( {"name": "Product A", "type": "product"} ) - self.packA = self.env["stock.quant.package"].create( + cls.packA = cls.env["stock.quant.package"].create( {"location_id": stock_location.id} ) - self.quantA = self.env["stock.quant"].create( + cls.quantA = cls.env["stock.quant"].create( { - "product_id": self.productA.id, + "product_id": cls.productA.id, "location_id": stock_location.id, "quantity": 1, - "package_id": self.packA.id, + "package_id": cls.packA.id, } ) - self.env["stock.putaway.rule"].create( + cls.env["stock.putaway.rule"].create( { - "product_id": self.productA.id, + "product_id": cls.productA.id, "location_in_id": stock_location.id, "location_out_id": out_location.id, } ) - menu = self.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck") - profile = self.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") - profile.warehouse_id.int_type_id.process_id = menu.process_id.id - with self.work_on_services(menu=menu, profile=profile) as work: + cls.shelf1 = cls.env.ref("stock.stock_location_components") + cls.shelf2 = cls.env.ref("stock.stock_location_14") + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck") + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + cls.wh = cls.profile.warehouse_id + cls.wh.int_type_id.process_id = cls.menu.process_id.id + + def setUp(self): + super().setUp() + with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="single_pack_putaway") def test_single_pack_putaway(self): @@ -70,3 +80,50 @@ def test_single_pack_putaway(self): self.assertEquals(move.state, "done") self.assertEquals(self.quantA.quantity, 0) self.assertEquals(new_loc_quant.quantity, move.product_uom_qty) + + def test_validate(self): + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + picking_form = Form(self.env["stock.picking"]) + picking_form.picking_type_id = self.wh.int_type_id + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.productA + move.product_uom_qty = 1 + picking = picking_form.save() + picking.action_confirm() + picking.action_assign() + package_level = picking.move_line_ids.package_level_id + self.assertEqual(package_level.package_id, self.packA) + # at this point, the package level is already set to "done", by the + # "start" method of the pack transfer putaway + package_level.is_done = True + + # now, call the service to proceed with validation of the + # movement + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": self.shelf2.barcode, + }, + ) + self.assertDictEqual( + response, + { + "message": { + "message": "The pack has been moved," " you can scan a new pack.", + "message_type": "info", + "title": "Start", + }, + "state": "start", + }, + ) + + self.assertRecordValues( + package_level.move_line_ids, + [{"qty_done": 1.0, "location_dest_id": self.shelf2.id, "state": "done"}], + ) + self.assertRecordValues( + package_level.move_line_ids.move_id, + [{"location_dest_id": self.stock_location.id, "state": "done"}], + ) From 65249bdf83e6d1ea29cd2b5182fb0096b0176573 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 24 Jan 2020 15:00:29 +0100 Subject: [PATCH 058/940] Improve pack putaway tests --- shopfloor/__manifest__.py | 1 + shopfloor/actions/pack_transfer_validate.py | 2 +- shopfloor/demo/stock_picking_type_demo.xml | 35 +++++++++++++ shopfloor/services/single_pack_putaway.py | 19 +++++-- shopfloor/services/single_pack_transfer.py | 2 +- shopfloor/tests/test_single_pack_putaway.py | 58 ++++++--------------- 6 files changed, 71 insertions(+), 46 deletions(-) create mode 100644 shopfloor/demo/stock_picking_type_demo.xml diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index bfd999f5f0..69f064b2c2 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -24,6 +24,7 @@ "demo": [ "demo/auth_api_key_demo.xml", "demo/shopfloor_process_demo.xml", + "demo/stock_picking_type_demo.xml", "demo/shopfloor_menu_demo.xml", "demo/shopfloor_operation_group_demo.xml", "demo/shopfloor_profile_demo.xml", diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py index 8caef8bfc1..1404f10d49 100644 --- a/shopfloor/actions/pack_transfer_validate.py +++ b/shopfloor/actions/pack_transfer_validate.py @@ -40,7 +40,7 @@ def is_dest_location_to_confirm(self, move, scanned_location): zone_locations = self.env["stock.location"].search( [("id", "child_of", move_dest_location.id)] ) - return scanned_location in zone_locations + return scanned_location not in zone_locations def set_destination_and_done(self, move, scanned_location): diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml new file mode 100644 index 0000000000..c6bb3bae22 --- /dev/null +++ b/shopfloor/demo/stock_picking_type_demo.xml @@ -0,0 +1,35 @@ + + + + Put-Away Reach Truck + PART + + + + + + + + + internal + + + + + + Single Pallet Transfer + SPT + + + + + + + + + internal + + + + + diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 15483cc152..8f4fc93a23 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -12,6 +12,16 @@ class SinglePackPutaway(Component): _usage = "single_pack_putaway" _description = __doc__ + def _response_for_no_picking_type(self): + return self._response( + state="start", + message={ + "message_type": "error", + "title": _("Configuration error"), + "message": _("No picking types found for this menu and profile"), + }, + ) + def _response_for_several_picking_types(self): return self._response( state="start", @@ -118,10 +128,12 @@ def start(self, barcode): picking_type = self.picking_types if len(picking_type) > 1: - return self._response_from_several_picking_types() - company = self.env.user.company_id # FIXME add logic to get proper company - # TODO define on what we search (pack name, pack barcode ...) + return self._response_for_several_picking_types() + elif not picking_type: + return self._response_for_no_picking_type() + company = self.env.company + # TODO define on what we search (pack name, pack barcode ...) pack = self.env["stock.quant.package"].search([("name", "=", barcode)]) if not pack: return self._response_for_package_not_found(barcode) @@ -265,6 +277,7 @@ def cancel(self, package_level_id): "message": _("This operation does not exist anymore."), }, ) + # TODO cancel() does not exist package.move_ids[0].cancel() return self._response( state="start", diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 26abb6e18d..2aea9d7ae7 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -245,7 +245,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False): if not pack_transfer.is_dest_location_valid(move, scanned_location): return self._response_for_forbidden_location() - if not pack_transfer.is_dest_location_to_confirm(move, scanned_location): + if pack_transfer.is_dest_location_to_confirm(move, scanned_location): if confirmation: # keep the move in sync otherwise we would have a move line outside # the dest location of the move diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 8bb1181ca7..8986341e59 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -9,13 +9,9 @@ def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) stock_location = cls.env.ref("stock.stock_location_stock") cls.stock_location = stock_location - out_location = cls.env["stock.location"].search( - [ - ("location_id", "=", stock_location.id), - ("barcode", "!=", False), - ("usage", "=", "internal"), - ] - )[0] + cls.dispatch_location = cls.env.ref("stock.location_dispatch_zone") + cls.input_location = cls.env.ref("stock.stock_location_company") + cls.out_location = cls.env.ref("stock.stock_location_output") cls.productA = cls.env["product.product"].create( {"name": "Product A", "type": "product"} ) @@ -25,24 +21,16 @@ def setUpClass(cls, *args, **kwargs): cls.quantA = cls.env["stock.quant"].create( { "product_id": cls.productA.id, - "location_id": stock_location.id, + "location_id": cls.dispatch_location.id, "quantity": 1, "package_id": cls.packA.id, } ) - cls.env["stock.putaway.rule"].create( - { - "product_id": cls.productA.id, - "location_in_id": stock_location.id, - "location_out_id": out_location.id, - } - ) cls.shelf1 = cls.env.ref("stock.stock_location_components") cls.shelf2 = cls.env.ref("stock.stock_location_14") cls.menu = cls.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck") cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id - cls.wh.int_type_id.process_id = cls.menu.process_id.id def setUp(self): super().setUp() @@ -52,40 +40,28 @@ def setUp(self): def test_single_pack_putaway(self): barcode = self.packA.name params = {"barcode": barcode} + # Simulate the client scanning a package's barcode, which + # in turns should start the operation in odoo response = self.service.dispatch("start", params=params) + # the response + + # Checks: package_level = self.env["stock.package_level"].browse(response["data"]["id"]) - move_id = package_level.move_line_ids[0].move_id.id - location_dest = self.env["stock.location"].browse( - response["data"]["location_dst"]["id"] - ) - params = { - "package_level_id": package_level.id, - "location_barcode": location_dest.barcode, - } - new_loc_quant = self.env["stock.quant"].search( - [ - ("product_id", "=", self.productA.id), - ("location_id", "=", location_dest.id), - ] + move_line = package_level.move_line_ids + move = move_line.move_id + + self.assertRecordValues( + move_line, [{"qty_done": 1.0, "location_dest_id": self.stock_location.id}] ) - self.assertFalse(new_loc_quant) - response = self.service.dispatch("validate", params=params) - new_loc_quant = self.env["stock.quant"].search( - [ - ("product_id", "=", self.productA.id), - ("location_id", "=", location_dest.id), - ] + self.assertRecordValues( + move, [{"state": "assigned", "location_dest_id": self.stock_location.id}] ) - move = self.env["stock.move"].browse(move_id) - self.assertEquals(move.state, "done") - self.assertEquals(self.quantA.quantity, 0) - self.assertEquals(new_loc_quant.quantity, move.product_uom_qty) def test_validate(self): # setup the picking as we need, like if the move line # was already started by the first step (start operation) picking_form = Form(self.env["stock.picking"]) - picking_form.picking_type_id = self.wh.int_type_id + picking_form.picking_type_id = self.menu.process_id.picking_type_ids with picking_form.move_ids_without_package.new() as move: move.product_id = self.productA move.product_uom_qty = 1 From 3b5954989106558813ecf951b593c1f3eecab3e6 Mon Sep 17 00:00:00 2001 From: Benoit Date: Fri, 24 Jan 2020 15:44:16 +0100 Subject: [PATCH 059/940] simplified single pack putaway --- shopfloor/services/single_pack_putaway.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 8f4fc93a23..6e287dc986 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -171,22 +171,16 @@ def start(self, barcode): } move = self.env["stock.move"].create(move_vals) move._action_confirm(merge=False) - location_dest_id = ( - default_location_dest._get_putaway_strategy(product).id - or default_location_dest.id - ) - self.env["stock.package_level"].create( + package_level = self.env["stock.package_level"].create( { "package_id": pack.id, "move_ids": [(4, move.id)], "company_id": company.id, - "is_done": True, - "location_id": pack.location_id.id, - "location_dest_id": location_dest_id, "picking_id": move.picking_id.id, } ) move._action_assign() + package_level.is_done = True return self._response_for_start_success(move, pack) def _response_for_package_level_not_found(self): From 7f241fb2222a308ac17a00d29388c606170e3873 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 31 Jan 2020 14:25:57 +0100 Subject: [PATCH 060/940] shopfloor: adapt state names and enpoints --- shopfloor/services/single_pack_putaway.py | 40 +++++++++++------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 6e287dc986..ef890af2ac 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -14,7 +14,7 @@ class SinglePackPutaway(Component): def _response_for_no_picking_type(self): return self._response( - state="start", + state="scan_pack", message={ "message_type": "error", "title": _("Configuration error"), @@ -24,7 +24,7 @@ def _response_for_no_picking_type(self): def _response_for_several_picking_types(self): return self._response( - state="start", + state="scan_pack", message={ "message_type": "error", "title": _("Configuration error"), @@ -34,7 +34,7 @@ def _response_for_several_picking_types(self): def _response_for_package_not_found(self, barcode): return self._response( - state="start", + state="scan_pack", message={ "message_type": "error", "title": _("Pack not found"), @@ -44,7 +44,7 @@ def _response_for_package_not_found(self, barcode): def _response_for_forbidden_package(self, barcode, picking_type): return self._response( - state="start", + state="scan_pack", message={ "message_type": "error", "title": _("Cannot proceed"), @@ -53,9 +53,9 @@ def _response_for_forbidden_package(self, barcode, picking_type): }, ) - def _response_for_forbidden_start(self, existing_operations): + def _response_for_forbidden_scan_pack(self, existing_operations): return self._response( - state="start", + state="scan_pack", message={ "message_type": "error", "title": _("Cannot proceed"), @@ -70,7 +70,7 @@ def _response_for_forbidden_start(self, existing_operations): }, ) - def _response_for_start_to_confirm(self, existing_operations, pack): + def _response_for_scan_pack_to_confirm(self, existing_operations, pack): move = existing_operations.move_id return self._response( data={ @@ -87,7 +87,7 @@ def _response_for_start_to_confirm(self, existing_operations, pack): "product": {"id": move.product_id.name, "name": move.product_id.name}, "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, }, - state="confirm_start", + state="takeover", message={ "message_type": "warning", "title": _("Already started"), @@ -97,7 +97,7 @@ def _response_for_start_to_confirm(self, existing_operations, pack): }, ) - def _response_for_start_success(self, move, pack): + def _response_for_scan_pack_success(self, move, pack): return self._response( state="scan_location", message={ @@ -123,7 +123,7 @@ def _response_for_start_success(self, move, pack): }, ) - def start(self, barcode): + def scan_pack(self, barcode): """Scan a pack barcode""" picking_type = self.picking_types @@ -152,9 +152,9 @@ def start(self, barcode): existing_operations and existing_operations[0].picking_id.picking_type_id != picking_type ): - return self._response_for_forbidden_start(existing_operations) + return self._response_for_forbidden_scan_pack(existing_operations) elif existing_operations: - return self._response_for_start_to_confirm(existing_operations, pack) + return self._response_for_scan_pack_to_confirm(existing_operations, pack) product = pack.quant_ids[ 0 ].product_id # FIXME we consider only one product per pack @@ -181,11 +181,11 @@ def start(self, barcode): ) move._action_assign() package_level.is_done = True - return self._response_for_start_success(move, pack) + return self._response_for_scan_pack_success(move, pack) def _response_for_package_level_not_found(self): return self._response( - state="start", + state="scan_pack", message={ "message_type": "error", "title": _("Start again"), @@ -195,7 +195,7 @@ def _response_for_package_level_not_found(self): def _response_for_move_canceled(self): return self._response( - state="start", + state="scan_pack", message={ "message_type": "warning", "title": _("Restart"), @@ -225,7 +225,7 @@ def _response_for_location_need_confirm(self): def _response_for_validate_success(self): return self._response( - state="start", + state="scan_pack", message={ "message_type": "info", "title": _("Start"), @@ -264,7 +264,7 @@ def cancel(self, package_level_id): package = self.env["stock.package_level"].browse(package_level_id) if not package.exists(): return self._response( - state="start", + state="scan_pack", message={ "message_type": "error", "title": _("Start again"), @@ -274,7 +274,7 @@ def cancel(self, package_level_id): # TODO cancel() does not exist package.move_ids[0].cancel() return self._response( - state="start", + state="scan_pack", message={ "message_type": "info", "title": _("Start"), @@ -296,10 +296,10 @@ def _validator_validate(self): def _validator_return_validate(self): return self._response_schema() - def _validator_start(self): + def _validator_scan_pack(self): return {"barcode": {"type": "string", "nullable": False, "required": True}} - def _validator_return_start(self): + def _validator_return_scan_pack(self): return self._response_schema( { "id": {"coerce": to_int, "required": True, "type": "integer"}, From bba2a4113dc75ec3fcd9a1be26c2156339548210 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 31 Jan 2020 15:12:19 +0100 Subject: [PATCH 061/940] shopfloor: message has `body` --- shopfloor/services/service.py | 2 +- shopfloor/services/single_pack_putaway.py | 28 ++++++++++----------- shopfloor/tests/test_single_pack_putaway.py | 2 +- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 0b85c49958..bfed200be3 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -90,7 +90,7 @@ def _response_schema(self, data_schema=None): "allowed": ["info", "warning", "error"], }, "title": {"type": "string", "required": False}, - "message": {"type": "string", "required": True}, + "body": {"type": "string", "required": True}, }, }, } diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index ef890af2ac..22ab2976c2 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -18,7 +18,7 @@ def _response_for_no_picking_type(self): message={ "message_type": "error", "title": _("Configuration error"), - "message": _("No picking types found for this menu and profile"), + "body": _("No picking types found for this menu and profile"), }, ) @@ -28,7 +28,7 @@ def _response_for_several_picking_types(self): message={ "message_type": "error", "title": _("Configuration error"), - "message": _("Several picking types found for this menu and profile"), + "body": _("Several picking types found for this menu and profile"), }, ) @@ -38,7 +38,7 @@ def _response_for_package_not_found(self, barcode): message={ "message_type": "error", "title": _("Pack not found"), - "message": _("The pack %s doesn't exist") % barcode, + "body": _("The pack %s doesn't exist") % barcode, }, ) @@ -48,7 +48,7 @@ def _response_for_forbidden_package(self, barcode, picking_type): message={ "message_type": "error", "title": _("Cannot proceed"), - "message": _("pack %s is not in %s location") + "body": _("pack %s is not in %s location") % (barcode, picking_type.default_location_src_id.name), }, ) @@ -59,7 +59,7 @@ def _response_for_forbidden_scan_pack(self, existing_operations): message={ "message_type": "error", "title": _("Cannot proceed"), - "message": _( + "body": _( "An operation exists in %s %s. " "You cannot process it with this shopfloor process." ) @@ -91,7 +91,7 @@ def _response_for_scan_pack_to_confirm(self, existing_operations, pack): message={ "message_type": "warning", "title": _("Already started"), - "message": _( + "body": _( "Operation already running. Would you like to take it over ?" ), }, @@ -103,7 +103,7 @@ def _response_for_scan_pack_success(self, move, pack): message={ "message_type": "info", "title": _("Start"), - "message": _( + "body": _( "The move is ready, you can scan the destination location." ), }, @@ -189,7 +189,7 @@ def _response_for_package_level_not_found(self): message={ "message_type": "error", "title": _("Start again"), - "message": _("This operation does not exist anymore."), + "body": _("This operation does not exist anymore."), }, ) @@ -199,7 +199,7 @@ def _response_for_move_canceled(self): message={ "message_type": "warning", "title": _("Restart"), - "message": _("Restart the operation, someone has canceled it."), + "body": _("Restart the operation, someone has canceled it."), }, ) @@ -209,7 +209,7 @@ def _response_for_forbidden_location(self): message={ "message_type": "error", "title": _("Forbidden"), - "message": _("You cannot place it here"), + "body": _("You cannot place it here"), }, ) @@ -219,7 +219,7 @@ def _response_for_location_need_confirm(self): message={ "message_type": "warning", "title": _("Confirm"), - "message": _("Are you sure?"), + "body": _("Are you sure?"), }, ) @@ -229,7 +229,7 @@ def _response_for_validate_success(self): message={ "message_type": "info", "title": _("Start"), - "message": _("The pack has been moved, you can scan a new pack."), + "body": _("The pack has been moved, you can scan a new pack."), }, ) @@ -268,7 +268,7 @@ def cancel(self, package_level_id): message={ "message_type": "error", "title": _("Start again"), - "message": _("This operation does not exist anymore."), + "body": _("This operation does not exist anymore."), }, ) # TODO cancel() does not exist @@ -278,7 +278,7 @@ def cancel(self, package_level_id): message={ "message_type": "info", "title": _("Start"), - "message": _("The move has been canceled, you can scan a new pack."), + "body": _("The move has been canceled, you can scan a new pack."), }, ) diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 8986341e59..4ef3078e22 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -87,7 +87,7 @@ def test_validate(self): response, { "message": { - "message": "The pack has been moved," " you can scan a new pack.", + "body": "The pack has been moved," " you can scan a new pack.", "message_type": "info", "title": "Start", }, From f6f29e15c024e279d2b51f94ca34c2fc0ea3c0a2 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 4 Feb 2020 16:54:48 +0100 Subject: [PATCH 062/940] Extend tests coverage for single pack put-away --- shopfloor/actions/base_action.py | 2 + shopfloor/actions/pack_transfer_validate.py | 1 - shopfloor/services/single_pack_putaway.py | 93 +++--- shopfloor/tests/test_single_pack_putaway.py | 310 +++++++++++++++++++- 4 files changed, 355 insertions(+), 51 deletions(-) diff --git a/shopfloor/actions/base_action.py b/shopfloor/actions/base_action.py index e026e1c1e1..76eac66eba 100644 --- a/shopfloor/actions/base_action.py +++ b/shopfloor/actions/base_action.py @@ -2,6 +2,8 @@ class ShopFloorProcessAction(AbstractComponent): + """Base Component for actions""" + _name = "shopfloor.process.action" _collection = "shopfloor.action" _usage = "actions" diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py index 1404f10d49..19c5f25ccb 100644 --- a/shopfloor/actions/pack_transfer_validate.py +++ b/shopfloor/actions/pack_transfer_validate.py @@ -4,7 +4,6 @@ class PackTransferValidateAction(Component): """Pack Transfer shared business logic - This component is shared by the "validate" action of the processes: * single_pack_putaway diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 22ab2976c2..61ee7a2476 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -14,52 +14,52 @@ class SinglePackPutaway(Component): def _response_for_no_picking_type(self): return self._response( - state="scan_pack", + state="start", message={ "message_type": "error", "title": _("Configuration error"), - "body": _("No picking types found for this menu and profile"), + "message": _("No picking types found for this menu and profile"), }, ) def _response_for_several_picking_types(self): return self._response( - state="scan_pack", + state="start", message={ "message_type": "error", "title": _("Configuration error"), - "body": _("Several picking types found for this menu and profile"), + "message": _("Several picking types found for this menu and profile"), }, ) def _response_for_package_not_found(self, barcode): return self._response( - state="scan_pack", + state="start", message={ "message_type": "error", "title": _("Pack not found"), - "body": _("The pack %s doesn't exist") % barcode, + "message": _("The pack %s doesn't exist") % barcode, }, ) def _response_for_forbidden_package(self, barcode, picking_type): return self._response( - state="scan_pack", + state="start", message={ "message_type": "error", "title": _("Cannot proceed"), - "body": _("pack %s is not in %s location") + "message": _("pack %s is not in %s location") % (barcode, picking_type.default_location_src_id.name), }, ) - def _response_for_forbidden_scan_pack(self, existing_operations): + def _response_for_forbidden_start(self, existing_operations): return self._response( - state="scan_pack", + state="start", message={ "message_type": "error", "title": _("Cannot proceed"), - "body": _( + "message": _( "An operation exists in %s %s. " "You cannot process it with this shopfloor process." ) @@ -70,7 +70,7 @@ def _response_for_forbidden_scan_pack(self, existing_operations): }, ) - def _response_for_scan_pack_to_confirm(self, existing_operations, pack): + def _response_for_start_to_confirm(self, existing_operations, pack): move = existing_operations.move_id return self._response( data={ @@ -87,23 +87,23 @@ def _response_for_scan_pack_to_confirm(self, existing_operations, pack): "product": {"id": move.product_id.name, "name": move.product_id.name}, "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, }, - state="takeover", + state="confirm_start", message={ "message_type": "warning", "title": _("Already started"), - "body": _( + "message": _( "Operation already running. Would you like to take it over ?" ), }, ) - def _response_for_scan_pack_success(self, move, pack): + def _response_for_start_success(self, move, pack): return self._response( state="scan_location", message={ "message_type": "info", "title": _("Start"), - "body": _( + "message": _( "The move is ready, you can scan the destination location." ), }, @@ -123,7 +123,7 @@ def _response_for_scan_pack_success(self, move, pack): }, ) - def scan_pack(self, barcode): + def start(self, barcode): """Scan a pack barcode""" picking_type = self.picking_types @@ -134,6 +134,7 @@ def scan_pack(self, barcode): company = self.env.company # TODO define on what we search (pack name, pack barcode ...) + # we can create a component with the search methods (easy to override) pack = self.env["stock.quant.package"].search([("name", "=", barcode)]) if not pack: return self._response_for_package_not_found(barcode) @@ -152,9 +153,9 @@ def scan_pack(self, barcode): existing_operations and existing_operations[0].picking_id.picking_type_id != picking_type ): - return self._response_for_forbidden_scan_pack(existing_operations) + return self._response_for_forbidden_start(existing_operations) elif existing_operations: - return self._response_for_scan_pack_to_confirm(existing_operations, pack) + return self._response_for_start_to_confirm(existing_operations, pack) product = pack.quant_ids[ 0 ].product_id # FIXME we consider only one product per pack @@ -181,25 +182,35 @@ def scan_pack(self, barcode): ) move._action_assign() package_level.is_done = True - return self._response_for_scan_pack_success(move, pack) + return self._response_for_start_success(move, pack) def _response_for_package_level_not_found(self): return self._response( - state="scan_pack", + state="start", message={ "message_type": "error", "title": _("Start again"), - "body": _("This operation does not exist anymore."), + "message": _("This operation does not exist anymore."), }, ) def _response_for_move_canceled(self): return self._response( - state="scan_pack", + state="start", message={ "message_type": "warning", "title": _("Restart"), - "body": _("Restart the operation, someone has canceled it."), + "message": _("Restart the operation, someone has canceled it."), + }, + ) + + def _response_for_location_not_found(self): + return self._response( + state="scan_location", + message={ + "message_type": "error", + "title": _("Scan"), + "message": _("No location found for this barcode."), }, ) @@ -209,7 +220,7 @@ def _response_for_forbidden_location(self): message={ "message_type": "error", "title": _("Forbidden"), - "body": _("You cannot place it here"), + "message": _("You cannot place it here"), }, ) @@ -219,17 +230,17 @@ def _response_for_location_need_confirm(self): message={ "message_type": "warning", "title": _("Confirm"), - "body": _("Are you sure?"), + "message": _("Are you sure?"), }, ) def _response_for_validate_success(self): return self._response( - state="scan_pack", + state="start", message={ "message_type": "info", "title": _("Start"), - "body": _("The pack has been moved, you can scan a new pack."), + "message": _("The pack has been moved, you can scan a new pack."), }, ) @@ -245,7 +256,10 @@ def validate(self, package_level_id, location_barcode, confirmation=False): if not pack_transfer.is_move_state_valid(move): return self._response_for_move_canceled() + # TODO move scan search in a component for this? scanned_location = pack_transfer.location_from_scan(location_barcode) + if not scanned_location: + return self._response_for_location_not_found() if not pack_transfer.is_dest_location_valid(move, scanned_location): return self._response_for_forbidden_location() @@ -263,22 +277,25 @@ def validate(self, package_level_id, location_barcode, confirmation=False): def cancel(self, package_level_id): package = self.env["stock.package_level"].browse(package_level_id) if not package.exists(): + return self._response_for_package_level_not_found() + # package.move_ids may be empty, it seems + move = package.move_line_ids.move_id + if move.state == "done": return self._response( - state="scan_pack", + state="start", message={ - "message_type": "error", - "title": _("Start again"), - "body": _("This operation does not exist anymore."), + "message_type": "info", + "title": _("Start"), + "message": _("Move already processed."), }, ) - # TODO cancel() does not exist - package.move_ids[0].cancel() + package.move_line_ids.move_id._action_cancel() return self._response( - state="scan_pack", + state="start", message={ "message_type": "info", "title": _("Start"), - "body": _("The move has been canceled, you can scan a new pack."), + "message": _("The move has been canceled, you can scan a new pack."), }, ) @@ -296,10 +313,10 @@ def _validator_validate(self): def _validator_return_validate(self): return self._response_schema() - def _validator_scan_pack(self): + def _validator_start(self): return {"barcode": {"type": "string", "nullable": False, "required": True}} - def _validator_return_scan_pack(self): + def _validator_return_start(self): return self._response_schema( { "id": {"coerce": to_int, "required": True, "type": "integer"}, diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 4ef3078e22..a5997feac1 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -10,14 +10,23 @@ def setUpClass(cls, *args, **kwargs): stock_location = cls.env.ref("stock.stock_location_stock") cls.stock_location = stock_location cls.dispatch_location = cls.env.ref("stock.location_dispatch_zone") + cls.dispatch_location.barcode = "DISPATCH" cls.input_location = cls.env.ref("stock.stock_location_company") - cls.out_location = cls.env.ref("stock.stock_location_output") + cls.shelf1 = cls.env.ref("stock.stock_location_components") + cls.shelf2 = cls.env.ref("stock.stock_location_14") cls.productA = cls.env["product.product"].create( {"name": "Product A", "type": "product"} ) cls.packA = cls.env["stock.quant.package"].create( {"location_id": stock_location.id} ) + cls.env["stock.putaway.rule"].create( + { + "product_id": cls.productA.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) cls.quantA = cls.env["stock.quant"].create( { "product_id": cls.productA.id, @@ -26,8 +35,6 @@ def setUpClass(cls, *args, **kwargs): "package_id": cls.packA.id, } ) - cls.shelf1 = cls.env.ref("stock.stock_location_components") - cls.shelf2 = cls.env.ref("stock.stock_location_14") cls.menu = cls.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck") cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id @@ -37,29 +44,49 @@ def setUp(self): with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="single_pack_putaway") - def test_single_pack_putaway(self): + def test_start(self): + """Test the happy path for single pack putaway /start endpoint + + The pre-conditions: + + * A Pack exists in the Input Location (presumably brought there by a + reception for a PO) + * A put-away rule moves the product of the Pack from Stock to Stock/Shelf 1 + + Expected result: + + * A move is created from Input to Stock/Shelf 1. It is assigned and the package + level is set to Done. + + The next step in the workflow is to call /validate with the created + package level that will set the move and picking to done. + """ barcode = self.packA.name params = {"barcode": barcode} # Simulate the client scanning a package's barcode, which # in turns should start the operation in odoo response = self.service.dispatch("start", params=params) - # the response # Checks: package_level = self.env["stock.package_level"].browse(response["data"]["id"]) move_line = package_level.move_line_ids move = move_line.move_id + # the put-away rule should have set the shelf1 for the move line self.assertRecordValues( - move_line, [{"qty_done": 1.0, "location_dest_id": self.stock_location.id}] + move_line, [{"qty_done": 1.0, "location_dest_id": self.shelf1.id}] ) self.assertRecordValues( move, [{"state": "assigned", "location_dest_id": self.stock_location.id}] ) + # self.assertDictEqual( + # response, + # { + # # TODO (mock any for ids?) + # }, + # ) - def test_validate(self): - # setup the picking as we need, like if the move line - # was already started by the first step (start operation) + def _simulate_started(self): picking_form = Form(self.env["stock.picking"]) picking_form.picking_type_id = self.menu.process_id.picking_type_ids with picking_form.move_ids_without_package.new() as move: @@ -73,6 +100,22 @@ def test_validate(self): # at this point, the package level is already set to "done", by the # "start" method of the pack transfer putaway package_level.is_done = True + return package_level + + def test_validate(self): + """Test the happy path for single pack putaway /validate endpoint + + The pre-conditions: + + * /start has been called + + Expected result: + + * The move associated to the package level is 'done' + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() # now, call the service to proceed with validation of the # movement @@ -80,14 +123,14 @@ def test_validate(self): "validate", params={ "package_level_id": package_level.id, - "location_barcode": self.shelf2.barcode, + "location_barcode": self.shelf1.barcode, }, ) self.assertDictEqual( response, { "message": { - "body": "The pack has been moved," " you can scan a new pack.", + "message": "The pack has been moved, you can scan a new pack.", "message_type": "info", "title": "Start", }, @@ -97,9 +140,252 @@ def test_validate(self): self.assertRecordValues( package_level.move_line_ids, - [{"qty_done": 1.0, "location_dest_id": self.shelf2.id, "state": "done"}], + [{"qty_done": 1.0, "location_dest_id": self.shelf1.id, "state": "done"}], ) self.assertRecordValues( package_level.move_line_ids.move_id, [{"location_dest_id": self.stock_location.id, "state": "done"}], ) + + def test_validate_not_found(self): + """Test a call on /validate on package level not found + + Expected result: + + * No change in odoo, Transition with a message + """ + response = self.service.dispatch( + "validate", + params={"package_level_id": -1, "location_barcode": self.shelf1.barcode}, + ) + self.assertDictEqual( + response, + { + "message": { + "message": "This operation does not exist anymore.", + "message_type": "error", + "title": "Start again", + }, + "state": "start", + }, + ) + + def test_validate_location_not_found(self): + """Test a call on /validate on location not found + + The pre-conditions: + + * /start has been called + + Expected result: + + * No change in odoo, Transition with a message + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() + + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": "THIS_BARCODE_DOES_NOT_EXISTS", + }, + ) + self.assertDictEqual( + response, + { + "message": { + "message": "No location found for this barcode.", + "message_type": "error", + "title": "Scan", + }, + "state": "scan_location", + }, + ) + + def test_validate_location_forbidden(self): + """Test a call on /validate on a forbidden location + + The pre-conditions: + + * /start has been called + + Expected result: + + * No change in odoo, Transition with a message + + Note: a forbidden location is when a location is not a child + of the destination location of the picking type used for the process + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() + + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + # this location is outside of the expected destination + "location_barcode": self.dispatch_location.barcode, + }, + ) + self.assertDictEqual( + response, + { + "message": { + "message": "You cannot place it here", + "message_type": "error", + "title": "Forbidden", + }, + "state": "scan_location", + }, + ) + + def test_cancel(self): + """Test the happy path for single pack putaway /cancel endpoint + + The pre-conditions: + + * /start has been called + + Expected result: + + * The move associated to the package level is 'cancel' + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() + + # keep references for later checks + move = package_level.move_line_ids.move_id + move_lines = package_level.move_line_ids + picking = move.picking_id + + # now, call the service to cancel + response = self.service.dispatch( + "cancel", params={"package_level_id": package_level.id} + ) + self.assertRecordValues(move, [{"state": "cancel"}]) + self.assertRecordValues(picking, [{"state": "cancel"}]) + self.assertFalse(package_level.move_line_ids) + self.assertFalse(move_lines.exists()) + + self.assertDictEqual( + response, + { + "message": { + "message": "The move has been canceled, you can scan a new pack.", + "message_type": "info", + "title": "Start", + }, + "state": "start", + }, + ) + + def test_cancel_already_canceled(self): + """Test a call on /cancel for already canceled move + + The pre-conditions: + + * /start has been called + * /cancel has been called elsewhere or the move canceled on Odoo + + Expected result: + + * Nothing happens, transition with a message + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() + + # keep references for later checks + move = package_level.move_line_ids.move_id + move_lines = package_level.move_line_ids + picking = move.picking_id + + # someone cancel the work started by our operator + move._action_cancel() + + # now, call the service to cancel + response = self.service.dispatch( + "cancel", params={"package_level_id": package_level.id} + ) + self.assertRecordValues(move, [{"state": "cancel"}]) + self.assertRecordValues(picking, [{"state": "cancel"}]) + self.assertFalse(package_level.move_line_ids) + self.assertFalse(move_lines.exists()) + + self.assertDictEqual( + response, + { + "message": { + "message": "The move has been canceled, you can scan a new pack.", + "message_type": "info", + "title": "Start", + }, + "state": "start", + }, + ) + + def test_cancel_already_done(self): + """Test a call on /cancel on move already done + + The pre-conditions: + + * /start has been called + * /validate has been called or move set to done in odoo + + Expected result: + + * No change in odoo, Transition with a message + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() + + # keep references for later checks + move = package_level.move_line_ids.move_id + picking = move.picking_id + + # someone cancel the work started by our operator + move._action_done() + + # now, call the service to cancel + response = self.service.dispatch( + "cancel", params={"package_level_id": package_level.id} + ) + self.assertRecordValues(move, [{"state": "done"}]) + self.assertRecordValues(picking, [{"state": "done"}]) + + self.assertDictEqual( + response, + { + "message": { + "message": "Move already processed.", + "message_type": "info", + "title": "Start", + }, + "state": "start", + }, + ) + + def test_cancel_not_found(self): + """Test a call on /cancel on package level not found + + Expected result: + + * No change in odoo, Transition with a message + """ + response = self.service.dispatch("cancel", params={"package_level_id": -1}) + self.assertDictEqual( + response, + { + "message": { + "message": "This operation does not exist anymore.", + "message_type": "error", + "title": "Start again", + }, + "state": "start", + }, + ) From 27aae154a62fc47399aac9b879543e904ea3e406 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 6 Feb 2020 09:47:28 +0100 Subject: [PATCH 063/940] Add component to share search methods So every scenario search records from a barcode the same way. --- shopfloor/actions/__init__.py | 1 + shopfloor/actions/pack_transfer_validate.py | 4 ---- shopfloor/actions/search.py | 19 ++++++++++++++++++ shopfloor/services/pack.py | 13 ++++-------- shopfloor/services/single_pack_putaway.py | 22 ++++++++++++--------- shopfloor/services/single_pack_transfer.py | 12 ++++++++--- 6 files changed, 46 insertions(+), 25 deletions(-) create mode 100644 shopfloor/actions/search.py diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index af4a9151a5..0a39e1f9af 100644 --- a/shopfloor/actions/__init__.py +++ b/shopfloor/actions/__init__.py @@ -18,3 +18,4 @@ """ from . import base_action from . import pack_transfer_validate +from . import search diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py index 19c5f25ccb..74af3071db 100644 --- a/shopfloor/actions/pack_transfer_validate.py +++ b/shopfloor/actions/pack_transfer_validate.py @@ -14,9 +14,6 @@ class PackTransferValidateAction(Component): _inherit = "shopfloor.process.action" _usage = "pack.transfer.validate" - def location_from_scan(self, barcode): - return self.env["stock.location"].search([("barcode", "=", barcode)]) - def is_move_state_valid(self, move): return move.state != "cancel" @@ -42,6 +39,5 @@ def is_dest_location_to_confirm(self, move, scanned_location): return scanned_location not in zone_locations def set_destination_and_done(self, move, scanned_location): - move.move_line_ids[0].location_dest_id = scanned_location.id move._action_done() diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py new file mode 100644 index 0000000000..706b6c057e --- /dev/null +++ b/shopfloor/actions/search.py @@ -0,0 +1,19 @@ +from odoo.addons.component.core import Component + + +class SearchAction(Component): + """Provide methods to search records from scanner + + The methods should be used in Service Components, so a search will always + have the same result in all scenarios. + """ + + _name = "shopfloor.search.action" + _inherit = "shopfloor.process.action" + _usage = "search" + + def location_from_scan(self, barcode): + return self.env["stock.location"].search([("barcode", "=", barcode)]) + + def package_from_scan(self, barcode): + return self.env["stock.quant.package"].search([("name", "=", barcode)]) diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py index e13ea02c5e..06117ef483 100644 --- a/shopfloor/services/pack.py +++ b/shopfloor/services/pack.py @@ -11,15 +11,10 @@ class ShopfloorPack(Component): _description = __doc__ def get_by_name(self, pack_name): - """ - Get pack informations - """ - pack = self.env["stock.quant.package"].search( - [("name", "=", pack_name)], - # TODO, is it what we want? error if not found? - limit=1, - ) - return self._response(data=self._to_json(pack)[:1]) + """Get pack information""" + search = self.actions_for("search") + package = search.package_from_scan(pack_name) + return self._response(data=self._to_json(package)[:1]) def _validator_get_by_name(self): return {"pack_name": {"type": "string", "nullable": False, "required": True}} diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 61ee7a2476..dba2d88af0 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -12,6 +12,8 @@ class SinglePackPutaway(Component): _usage = "single_pack_putaway" _description = __doc__ + # TODO create an Action component for messages, so we use the + # same messages in every scenario def _response_for_no_picking_type(self): return self._response( state="start", @@ -131,14 +133,15 @@ def start(self, barcode): return self._response_for_several_picking_types() elif not picking_type: return self._response_for_no_picking_type() - company = self.env.company - # TODO define on what we search (pack name, pack barcode ...) - # we can create a component with the search methods (easy to override) - pack = self.env["stock.quant.package"].search([("name", "=", barcode)]) + search = self.actions_for("search") + pack = search.package_from_scan(barcode) if not pack: return self._response_for_package_not_found(barcode) + assert len(pack) == 1, "We cannot have 2 packages with the same barcode" + # TODO this seems to be a pretty common check, consider moving + # it to an Action Component allowed_locations = self.env["stock.location"].search( [("id", "child_of", picking_type.default_location_src_id.id)] ) @@ -156,10 +159,11 @@ def start(self, barcode): return self._response_for_forbidden_start(existing_operations) elif existing_operations: return self._response_for_start_to_confirm(existing_operations, pack) - product = pack.quant_ids[ - 0 - ].product_id # FIXME we consider only one product per pack + # FIXME we consider only one product per pack + product = pack.quant_ids[0].product_id default_location_dest = picking_type.default_location_dest_id + company = self.env.company + # TODO _prepare methods move_vals = { "picking_type_id": picking_type.id, "product_id": product.id, @@ -247,6 +251,7 @@ def _response_for_validate_success(self): def validate(self, package_level_id, location_barcode, confirmation=False): """Validate the transfer""" pack_transfer = self.actions_for("pack.transfer.validate") + search = self.actions_for("search") package = self.env["stock.package_level"].browse(package_level_id) if not package.exists(): @@ -256,8 +261,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False): if not pack_transfer.is_move_state_valid(move): return self._response_for_move_canceled() - # TODO move scan search in a component for this? - scanned_location = pack_transfer.location_from_scan(location_barcode) + scanned_location = search.location_from_scan(location_barcode) if not scanned_location: return self._response_for_location_not_found() if not pack_transfer.is_dest_location_valid(move, scanned_location): diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 2aea9d7ae7..f3fc1df67d 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -110,7 +110,10 @@ def _response_for_start_success(self, move, pack): ) def start(self, barcode): - location = self.env["stock.location"].search([("barcode", "=", barcode)]) + search = self.actions_for("search") + + location = search.location_from_scan(barcode) + pack = self.env["stock.quant.package"] if location: pack = self.env["stock.quant.package"].search( @@ -120,8 +123,10 @@ def start(self, barcode): return self._response_for_empty_location(location) if len(pack) > 1: return self._response_for_several_packages(self, location) + if not pack: - pack = self.env["stock.quant.package"].search([("name", "=", barcode)]) + pack = search.package_from_scan(barcode) + if not pack: return self._response_for_package_not_found(barcode) @@ -232,6 +237,7 @@ def _response_for_validate_success(self): def validate(self, package_level_id, location_barcode, confirmation=False): """Validate the transfer""" pack_transfer = self.actions_for("pack.transfer.validate") + search = self.actions_for("search") package = self.env["stock.package_level"].browse(package_level_id) if not package.exists(): @@ -241,7 +247,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False): if not pack_transfer.is_move_state_valid(move): return self._response_for_move_canceled() - scanned_location = pack_transfer.location_from_scan(location_barcode) + scanned_location = search.location_from_scan(location_barcode) if not pack_transfer.is_dest_location_valid(move, scanned_location): return self._response_for_forbidden_location() From 03aa15ca920ef99f7c89b39410a12f141f894429 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 6 Feb 2020 11:40:23 +0100 Subject: [PATCH 064/940] Add a Component for messages The methods should be used in Service Components, in order to share as much as possible the messages for similar events. --- shopfloor/actions/__init__.py | 1 + shopfloor/actions/message.py | 142 ++++++++++++++++++++ shopfloor/services/single_pack_putaway.py | 142 ++++++-------------- shopfloor/services/single_pack_transfer.py | 106 ++++----------- shopfloor/tests/test_single_pack_putaway.py | 12 +- 5 files changed, 214 insertions(+), 189 deletions(-) create mode 100644 shopfloor/actions/message.py diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index 0a39e1f9af..a6719c5d2f 100644 --- a/shopfloor/actions/__init__.py +++ b/shopfloor/actions/__init__.py @@ -17,5 +17,6 @@ """ from . import base_action +from . import message from . import pack_transfer_validate from . import search diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py new file mode 100644 index 0000000000..3d19bcf64b --- /dev/null +++ b/shopfloor/actions/message.py @@ -0,0 +1,142 @@ +from odoo import _ + +from odoo.addons.component.core import Component + + +class MessageAction(Component): + """Provide message templates + + The methods should be used in Service Components, in order to share as much + as possible the messages for similar events. + + Before adding a message, please look if no message already exists, + and consider making an existing message more generic. + """ + + _name = "shopfloor.message.action" + _inherit = "shopfloor.process.action" + _usage = "message" + + def no_picking_type(self): + return { + "message_type": "error", + "title": _("Configuration error"), + "message": _("No operation type found for this menu and profile."), + } + + def several_picking_types(self): + return { + "message_type": "error", + "title": _("Configuration error"), + "message": _("Several operation types found for this menu and profile."), + } + + def package_not_found_for_barcode(self, barcode): + return { + "message_type": "error", + "title": _("Package not found"), + "message": _("The package %s doesn't exist") % barcode, + } + + def package_not_allowed_in_src_location(self, barcode, picking_type): + return { + "message_type": "error", + "title": _("Cannot proceed"), + "message": _("You cannot work on a package (%s) outside of location: %s") + % (barcode, picking_type.default_location_src_id.name), + } + + def already_running_ask_confirmation(self): + return { + "message_type": "warning", + "title": _("Already started"), + "message": _( + "Operation's already running. Would you like to take it over?" + ), + } + + def scan_destination(self): + return { + "message_type": "info", + "title": _("Scan"), + "message": _("Scan the destination location"), + } + + def operation_not_found(self): + return { + "message_type": "error", + "title": _("Start again"), + "message": _("This operation does not exist anymore."), + } + + def operation_has_been_canceled_elsewhere(self): + return { + "message_type": "warning", + "title": _("Restart"), + "message": _("Restart the operation, someone has canceled it."), + } + + def no_location_found(self): + return { + "message_type": "error", + "title": _("Scan"), + "message": _("No location found for this barcode."), + } + + def dest_location_not_allowed(self): + return { + "message_type": "error", + "title": _("Forbidden"), + "message": _("You cannot place it here"), + } + + def need_confirmation(self): + return { + "message_type": "warning", + "title": _("Confirm"), + "message": _("Are you sure?"), + } + + def confirm_pack_moved(self): + return { + "message_type": "info", + "title": _("Start"), + "message": _("The pack has been moved, you can scan a new pack."), + } + + def already_done(self): + return { + "message_type": "info", + "title": _("Continue"), + "message": _("Operation already processed."), + } + + def confirm_canceled_scan_next_pack(self): + return { + "message_type": "info", + "title": _("Continue"), + "message": _("Canceled, you can scan a new pack."), + } + + def no_pack_in_location(self, location): + return { + "message_type": "error", + "title": _("Scan package"), + "message": _("Location %s doesn’t contain any package." % location.name), + } + + def several_packs_in_location(self, location): + return { + "message_type": "error", + "title": _("Scan package"), + "message": _( + "Several PACKs found in %s, please scan one PACK." % location.name + ), + } + + def no_pending_operation_for_pack(self, pack): + return { + "message_type": "error", + "title": _("Error"), + "message": _("No pending operation for package %s." % pack.name), + } diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index dba2d88af0..8b7e7fa64d 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -12,47 +12,24 @@ class SinglePackPutaway(Component): _usage = "single_pack_putaway" _description = __doc__ - # TODO create an Action component for messages, so we use the - # same messages in every scenario def _response_for_no_picking_type(self): - return self._response( - state="start", - message={ - "message_type": "error", - "title": _("Configuration error"), - "message": _("No picking types found for this menu and profile"), - }, - ) + message = self.actions_for("message") + return self._response(state="start", message=message.no_picking_type()) def _response_for_several_picking_types(self): - return self._response( - state="start", - message={ - "message_type": "error", - "title": _("Configuration error"), - "message": _("Several picking types found for this menu and profile"), - }, - ) + message = self.actions_for("message") + return self._response(state="start", message=message.several_picking_types()) def _response_for_package_not_found(self, barcode): + message = self.actions_for("message") return self._response( - state="start", - message={ - "message_type": "error", - "title": _("Pack not found"), - "message": _("The pack %s doesn't exist") % barcode, - }, + state="start", message=message.package_not_found_for_barcode(barcode) ) def _response_for_forbidden_package(self, barcode, picking_type): + message = self.actions_for("message") return self._response( - state="start", - message={ - "message_type": "error", - "title": _("Cannot proceed"), - "message": _("pack %s is not in %s location") - % (barcode, picking_type.default_location_src_id.name), - }, + state="start", message=message.package_not_allowed_in_src_location() ) def _response_for_forbidden_start(self, existing_operations): @@ -73,6 +50,7 @@ def _response_for_forbidden_start(self, existing_operations): ) def _response_for_start_to_confirm(self, existing_operations, pack): + message = self.actions_for("message") move = existing_operations.move_id return self._response( data={ @@ -90,25 +68,14 @@ def _response_for_start_to_confirm(self, existing_operations, pack): "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, }, state="confirm_start", - message={ - "message_type": "warning", - "title": _("Already started"), - "message": _( - "Operation already running. Would you like to take it over ?" - ), - }, + message=message.already_running_ask_confirmation(), ) def _response_for_start_success(self, move, pack): + message = self.actions_for("message") return self._response( state="scan_location", - message={ - "message_type": "info", - "title": _("Start"), - "message": _( - "The move is ready, you can scan the destination location." - ), - }, + message=message.scan_destination(), data={ "id": move.move_line_ids[0].package_level_id.id, "name": pack.name, @@ -189,64 +156,36 @@ def start(self, barcode): return self._response_for_start_success(move, pack) def _response_for_package_level_not_found(self): - return self._response( - state="start", - message={ - "message_type": "error", - "title": _("Start again"), - "message": _("This operation does not exist anymore."), - }, - ) + message = self.actions_for("message") + return self._response(state="start", message=message.operation_not_found()) - def _response_for_move_canceled(self): + def _response_for_move_canceled_elsewhere(self): + message = self.actions_for("message") return self._response( - state="start", - message={ - "message_type": "warning", - "title": _("Restart"), - "message": _("Restart the operation, someone has canceled it."), - }, + state="start", message=message.operation_has_been_canceled_elsewhere() ) def _response_for_location_not_found(self): + message = self.actions_for("message") return self._response( - state="scan_location", - message={ - "message_type": "error", - "title": _("Scan"), - "message": _("No location found for this barcode."), - }, + state="scan_location", message=message.no_location_found() ) def _response_for_forbidden_location(self): + message = self.actions_for("message") return self._response( - state="scan_location", - message={ - "message_type": "error", - "title": _("Forbidden"), - "message": _("You cannot place it here"), - }, + state="scan_location", message=message.dest_location_not_allowed() ) def _response_for_location_need_confirm(self): + message = self.actions_for("message") return self._response( - state="confirm_location", - message={ - "message_type": "warning", - "title": _("Confirm"), - "message": _("Are you sure?"), - }, + state="confirm_location", message=message.need_confirmation() ) def _response_for_validate_success(self): - return self._response( - state="start", - message={ - "message_type": "info", - "title": _("Start"), - "message": _("The pack has been moved, you can scan a new pack."), - }, - ) + message = self.actions_for("message") + return self._response(state="start", message=message.confirm_pack_moved()) def validate(self, package_level_id, location_barcode, confirmation=False): """Validate the transfer""" @@ -259,7 +198,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False): move = package.move_line_ids[0].move_id if not pack_transfer.is_move_state_valid(move): - return self._response_for_move_canceled() + return self._response_for_move_canceled_elsewhere() scanned_location = search.location_from_scan(location_barcode) if not scanned_location: @@ -278,6 +217,16 @@ def validate(self, package_level_id, location_barcode, confirmation=False): pack_transfer.set_destination_and_done(move, scanned_location) return self._response_for_validate_success() + def _response_for_move_already_processed(self): + message = self.actions_for("message") + return self._response(state="start", message=message.already_done()) + + def _response_for_confirm_move_cancellation(self): + message = self.actions_for("message") + return self._response( + state="start", message=message.confirm_canceled_scan_next_pack() + ) + def cancel(self, package_level_id): package = self.env["stock.package_level"].browse(package_level_id) if not package.exists(): @@ -285,23 +234,10 @@ def cancel(self, package_level_id): # package.move_ids may be empty, it seems move = package.move_line_ids.move_id if move.state == "done": - return self._response( - state="start", - message={ - "message_type": "info", - "title": _("Start"), - "message": _("Move already processed."), - }, - ) + return self._response_for_move_already_processed() + package.move_line_ids.move_id._action_cancel() - return self._response( - state="start", - message={ - "message_type": "info", - "title": _("Start"), - "message": _("The move has been canceled, you can scan a new pack."), - }, - ) + return self._response_for_confirm_move_cancellation() def _validator_cancel(self): return { diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index f3fc1df67d..1e26827324 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -1,5 +1,3 @@ -from odoo import _ - from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -13,50 +11,31 @@ class SinglePackTransfer(Component): _description = __doc__ def _response_for_empty_location(self, location): + message = self.actions_for("message") return self._response( - state="start", - message={ - "message_type": "error", - "title": _("Scan package"), - "message": _("Location %s doesn’t contain any PACK." % location.name), - }, + state="start", message=message.no_pack_in_location(location) ) def _response_for_several_packages(self, location): + message = self.actions_for("message") return self._response( - state="start", - message={ - "message_type": "error", - "title": _("Scan package"), - "message": _( - "Several PACKs found in %s, please scan one PACK." % location.name - ), - }, + state="start", message=message.several_packs_in_location(location) ) def _response_for_package_not_found(self, barcode): + message = self.actions_for("message") return self._response( - state="start", - message={ - "message_type": "error", - "title": _("Pack not found"), - "message": _("The pack %s doesn't exist") % barcode, - }, + state="start", message=message.package_not_found_for_barcode(barcode) ) - def _response_for_operation_not_found(self): + def _response_for_operation_not_found(self, pack): + message = self.actions_for("message") return self._response( - state="start", - message={ - "message_type": "error", - "title": _("Not found"), - "message": _( - "No pending operation exists for this PACK you cannot process it." - ), - }, + state="start", message=message.no_pending_operation_for_pack(pack) ) def _response_for_start_to_confirm(self, existing_operations, pack): + message = self.actions_for("message") move = existing_operations.move_id return self._response( data={ @@ -74,25 +53,14 @@ def _response_for_start_to_confirm(self, existing_operations, pack): "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, }, state="confirm_start", - message={ - "message_type": "warning", - "title": _("Already started"), - "message": _( - "Operation already running. Would you like to take it over ?" - ), - }, + message=message.already_running_ask_confirmation(), ) def _response_for_start_success(self, move, pack): + message = self.actions_for("message") return self._response( state="scan_location", - message={ - "message_type": "info", - "title": _("Start"), - "message": _( - "The move is ready, you can scan the destination location." - ), - }, + message=message.scan_destination(), data={ "id": move.move_line_ids[0].package_level_id.id, "name": pack.name, @@ -137,7 +105,7 @@ def start(self, barcode): ] ) if not existing_operations: - return self._response_for_operation_not_found() + return self._response_for_operation_not_found(pack) move = existing_operations.move_id if existing_operations[0].package_level_id.is_done: return self._response_for_start_to_confirm(existing_operations, pack) @@ -185,54 +153,30 @@ def _validator_return_start(self): ) def _response_for_package_level_not_found(self): - return self._response( - state="start", - message={ - "message_type": "error", - "title": _("Start again"), - "message": _("This operation does not exist anymore."), - }, - ) + message = self.actions_for("message") + return self._response(state="start", message=message.operation_not_found()) def _response_for_move_canceled(self): + message = self.actions_for("message") return self._response( - state="start", - message={ - "message_type": "warning", - "title": _("Restart"), - "message": _("Restart the operation, someone has canceled it."), - }, + state="start", message=message.operation_has_been_canceled_elsewhere() ) def _response_for_forbidden_location(self): + message = self.actions_for("message") return self._response( - state="scan_location", - message={ - "message_type": "error", - "title": _("Forbidden"), - "message": _("You cannot place it here"), - }, + state="scan_location", message=message.dest_location_not_allowed() ) def _response_for_location_need_confirm(self): + message = self.actions_for("message") return self._response( - state="confirm_location", - message={ - "message_type": "warning", - "title": _("Confirm"), - "message": _("Are you sure?"), - }, + state="confirm_location", message=message.need_confirmation() ) def _response_for_validate_success(self): - return self._response( - state="start", - message={ - "message_type": "info", - "title": _("Start"), - "message": _("The pack has been moved, you can scan a new pack."), - }, - ) + message = self.actions_for("message") + return self._response(state="start", message=message.confirm_pack_moved()) def validate(self, package_level_id, location_barcode, confirmation=False): """Validate the transfer""" @@ -271,3 +215,5 @@ def _validator_validate(self): def _validator_return_validate(self): return self._response_schema() + + # TODO cancel diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index a5997feac1..b1a183f658 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -275,9 +275,9 @@ def test_cancel(self): response, { "message": { - "message": "The move has been canceled, you can scan a new pack.", + "message": "Canceled, you can scan a new pack.", "message_type": "info", - "title": "Start", + "title": "Continue", }, "state": "start", }, @@ -320,9 +320,9 @@ def test_cancel_already_canceled(self): response, { "message": { - "message": "The move has been canceled, you can scan a new pack.", + "message": "Canceled, you can scan a new pack.", "message_type": "info", - "title": "Start", + "title": "Continue", }, "state": "start", }, @@ -362,9 +362,9 @@ def test_cancel_already_done(self): response, { "message": { - "message": "Move already processed.", + "message": "Operation already processed.", "message_type": "info", - "title": "Start", + "title": "Continue", }, "state": "start", }, From 35b56d2e2e5dbbb39e70fa61d7a0aaeeb049a26e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 6 Feb 2020 12:04:14 +0100 Subject: [PATCH 065/940] Use _prepare methods in putaway --- shopfloor/services/single_pack_putaway.py | 25 +++++++++++++++-------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 8b7e7fa64d..6db6a1037d 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -126,12 +126,22 @@ def start(self, barcode): return self._response_for_forbidden_start(existing_operations) elif existing_operations: return self._response_for_start_to_confirm(existing_operations, pack) + + move_vals = self._prepare_stock_move(picking_type, pack) + move = self.env["stock.move"].create(move_vals) + move._action_confirm(merge=False) + package_level = self._prepare_package_level(pack, move) + move._action_assign() + package_level.is_done = True + return self._response_for_start_success(move, pack) + + def _prepare_stock_move(self, picking_type, pack): # FIXME we consider only one product per pack + assert len(pack.quant_ids) == 1 product = pack.quant_ids[0].product_id default_location_dest = picking_type.default_location_dest_id company = self.env.company - # TODO _prepare methods - move_vals = { + return { "picking_type_id": picking_type.id, "product_id": product.id, "location_id": pack.location_id.id, @@ -141,19 +151,16 @@ def start(self, barcode): "product_uom_qty": pack.quant_ids[0].quantity, "company_id": company.id, } - move = self.env["stock.move"].create(move_vals) - move._action_confirm(merge=False) - package_level = self.env["stock.package_level"].create( + + def _prepare_package_level(self, pack, move): + return self.env["stock.package_level"].create( { "package_id": pack.id, "move_ids": [(4, move.id)], - "company_id": company.id, + "company_id": self.env.company.id, "picking_id": move.picking_id.id, } ) - move._action_assign() - package_level.is_done = True - return self._response_for_start_success(move, pack) def _response_for_package_level_not_found(self): message = self.actions_for("message") From 396065ed2ba0307cb661dbaf65c9e3bc53dff49e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 6 Feb 2020 15:14:40 +0100 Subject: [PATCH 066/940] Add test utilities to check webservice responses --- shopfloor/tests/common.py | 77 +++++++++++ shopfloor/tests/test_single_pack_putaway.py | 142 ++++++++++---------- 2 files changed, 149 insertions(+), 70 deletions(-) diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 1f14f5ac93..372fbd187c 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -1,4 +1,6 @@ from contextlib import contextmanager +from copy import deepcopy +from pprint import pformat from odoo.tests.common import SavepointCase @@ -7,6 +9,17 @@ from odoo.addons.component.tests.common import ComponentMixin +class AnyObject: + def __repr__(self): + return "ANY" + + def __deepcopy__(self, memodict=None): + return self + + def __copy__(self): + return self + + class CommonCase(SavepointCase, ComponentMixin): # by default disable tracking suite-wise, it's a time saver :) @@ -36,3 +49,67 @@ def setUpClass(cls): context=dict(cls.env.context, tracking_disable=cls.tracking_disable) ) cls.setUpComponent() + + def assert_response(self, response, state=None, message=None, data=None): + """Assert a response from the webservice + + The data and message dictionaries are checked using + ``self.assert_dict``, which means we can use ``self.ANY`` to accept any + value. + """ + expected = {} + if state: + expected["state"] = state + if message: + expected["message"] = message + if data: + expected["data"] = data + self.assert_dict(response, expected) + + ANY = AnyObject() + + def assert_dict(self, current, expected): + """Assert dictionary equality with support of wildcard values + + In the expected dictionary, instead of a value, ``self.ANY`` + can be provided, which will accept any value. + + For instance, an expected value for a response which accepts any + content could be:: + + { + "data": self.ANY, + "message": { + "title": self.ANY, + "message_type": self.ANY, + "message": self.ANY, + }, + "state": self.ANY, + } + + Note: if ``self.ANY`` is used, the key must exist in the dictionary + to check. + """ + next_checks = [(current, expected)] + while next_checks: + original, node_original_expected = next_checks.pop() + node_values = deepcopy(original) + node_expected = deepcopy(node_original_expected) + + for key in original: + # sub-dictionaries will be checked later + expected_value = node_expected.get(key) + if expected_value is self.ANY: + # ignore 'any' keys + node_values.pop(key) + node_expected.pop(key) + continue + if isinstance(expected_value, dict): + next_checks.append((node_values.pop(key), node_expected.pop(key))) + continue + + self.assertDictEqual( + node_values, + node_expected, + "\n\nNode's specs:\n%s" % (pformat(node_original_expected)), + ) diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index b1a183f658..9e879f3fc1 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -79,12 +79,26 @@ def test_start(self): self.assertRecordValues( move, [{"state": "assigned", "location_dest_id": self.stock_location.id}] ) - # self.assertDictEqual( - # response, - # { - # # TODO (mock any for ids?) - # }, - # ) + self.assert_response( + response, + state="scan_location", + message={ + "title": self.ANY, + "message_type": "info", + "message": "Scan the destination location", + }, + data={ + "id": self.ANY, + "location_src": { + "id": self.dispatch_location.id, + "name": self.dispatch_location.name, + }, + "location_dst": {"id": self.shelf1.id, "name": self.shelf1.name}, + "name": package_level.package_id.name, + "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, + "product": {"id": move.product_id.id, "name": move.product_id.name}, + }, + ) def _simulate_started(self): picking_form = Form(self.env["stock.picking"]) @@ -126,15 +140,14 @@ def test_validate(self): "location_barcode": self.shelf1.barcode, }, ) - self.assertDictEqual( + + self.assert_response( response, - { - "message": { - "message": "The pack has been moved, you can scan a new pack.", - "message_type": "info", - "title": "Start", - }, - "state": "start", + state="start", + message={ + "title": self.ANY, + "message_type": "info", + "message": "The pack has been moved, you can scan a new pack.", }, ) @@ -158,15 +171,14 @@ def test_validate_not_found(self): "validate", params={"package_level_id": -1, "location_barcode": self.shelf1.barcode}, ) - self.assertDictEqual( + + self.assert_response( response, - { - "message": { - "message": "This operation does not exist anymore.", - "message_type": "error", - "title": "Start again", - }, - "state": "start", + state="start", + message={ + "title": self.ANY, + "message_type": "error", + "message": "This operation does not exist anymore.", }, ) @@ -192,15 +204,14 @@ def test_validate_location_not_found(self): "location_barcode": "THIS_BARCODE_DOES_NOT_EXISTS", }, ) - self.assertDictEqual( + + self.assert_response( response, - { - "message": { - "message": "No location found for this barcode.", - "message_type": "error", - "title": "Scan", - }, - "state": "scan_location", + state="scan_location", + message={ + "title": self.ANY, + "message_type": "error", + "message": "No location found for this barcode.", }, ) @@ -230,15 +241,14 @@ def test_validate_location_forbidden(self): "location_barcode": self.dispatch_location.barcode, }, ) - self.assertDictEqual( + + self.assert_response( response, - { - "message": { - "message": "You cannot place it here", - "message_type": "error", - "title": "Forbidden", - }, - "state": "scan_location", + state="scan_location", + message={ + "title": self.ANY, + "message_type": "error", + "message": "You cannot place it here", }, ) @@ -271,15 +281,13 @@ def test_cancel(self): self.assertFalse(package_level.move_line_ids) self.assertFalse(move_lines.exists()) - self.assertDictEqual( + self.assert_response( response, - { - "message": { - "message": "Canceled, you can scan a new pack.", - "message_type": "info", - "title": "Continue", - }, - "state": "start", + state="start", + message={ + "title": self.ANY, + "message_type": "info", + "message": "Canceled, you can scan a new pack.", }, ) @@ -316,15 +324,13 @@ def test_cancel_already_canceled(self): self.assertFalse(package_level.move_line_ids) self.assertFalse(move_lines.exists()) - self.assertDictEqual( + self.assert_response( response, - { - "message": { - "message": "Canceled, you can scan a new pack.", - "message_type": "info", - "title": "Continue", - }, - "state": "start", + state="start", + message={ + "title": self.ANY, + "message_type": "info", + "message": "Canceled, you can scan a new pack.", }, ) @@ -358,15 +364,13 @@ def test_cancel_already_done(self): self.assertRecordValues(move, [{"state": "done"}]) self.assertRecordValues(picking, [{"state": "done"}]) - self.assertDictEqual( + self.assert_response( response, - { - "message": { - "message": "Operation already processed.", - "message_type": "info", - "title": "Continue", - }, - "state": "start", + state="start", + message={ + "title": self.ANY, + "message_type": "info", + "message": "Operation already processed.", }, ) @@ -378,14 +382,12 @@ def test_cancel_not_found(self): * No change in odoo, Transition with a message """ response = self.service.dispatch("cancel", params={"package_level_id": -1}) - self.assertDictEqual( + self.assert_response( response, - { - "message": { - "message": "This operation does not exist anymore.", - "message_type": "error", - "title": "Start again", - }, - "state": "start", + state="start", + message={ + "title": self.ANY, + "message_type": "error", + "message": "This operation does not exist anymore.", }, ) From ef47dda3d506069f6923c2d9aee62dbd8ec841e8 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 6 Feb 2020 17:18:33 +0100 Subject: [PATCH 067/940] Add test cases for the putaway Trying to cover all the exceptions mentioned in the diagram (https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP) --- shopfloor/actions/message.py | 2 + shopfloor/actions/pack_transfer_validate.py | 18 +- shopfloor/services/single_pack_putaway.py | 108 +++++---- shopfloor/tests/test_single_pack_putaway.py | 234 ++++++++++++++++++++ 4 files changed, 309 insertions(+), 53 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 3d19bcf64b..d008b58d8d 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -2,6 +2,8 @@ from odoo.addons.component.core import Component +# TODO remove 'title', useless + class MessageAction(Component): """Provide message templates diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py index 74af3071db..4997e402ef 100644 --- a/shopfloor/actions/pack_transfer_validate.py +++ b/shopfloor/actions/pack_transfer_validate.py @@ -1,6 +1,7 @@ from odoo.addons.component.core import Component +# TODO think about how we want to share the common methods / workflows class PackTransferValidateAction(Component): """Pack Transfer shared business logic @@ -17,26 +18,31 @@ class PackTransferValidateAction(Component): def is_move_state_valid(self, move): return move.state != "cancel" + # TODO generic method "is_location_below" def is_dest_location_valid(self, move, scanned_location): """Forbid a dest location to be used""" - allowed_locations = self.env["stock.location"].search( + allowed_location = self.env["stock.location"].search_count( [ ( "id", "child_of", move.picking_id.picking_type_id.default_location_dest_id.id, - ) + ), + ("id", "=", scanned_location.id), ] ) - return scanned_location in allowed_locations + return bool(allowed_location) def is_dest_location_to_confirm(self, move, scanned_location): """Destination that could be used but need confirmation""" move_dest_location = move.move_line_ids[0].location_dest_id - zone_locations = self.env["stock.location"].search( - [("id", "child_of", move_dest_location.id)] + in_dest_location = self.env["stock.location"].search_count( + [ + ("id", "child_of", move_dest_location.id), + ("id", "=", scanned_location.id), + ] ) - return scanned_location not in zone_locations + return not bool(in_dest_location) def set_destination_and_done(self, move, scanned_location): move.move_line_ids[0].location_dest_id = scanned_location.id diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 6db6a1037d..4325df07b5 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -12,6 +12,8 @@ class SinglePackPutaway(Component): _usage = "single_pack_putaway" _description = __doc__ + # TODO think about not sending back the state when we already + # come from the same state def _response_for_no_picking_type(self): message = self.actions_for("message") return self._response(state="start", message=message.no_picking_type()) @@ -29,7 +31,8 @@ def _response_for_package_not_found(self, barcode): def _response_for_forbidden_package(self, barcode, picking_type): message = self.actions_for("message") return self._response( - state="start", message=message.package_not_allowed_in_src_location() + state="start", + message=message.package_not_allowed_in_src_location(barcode, picking_type), ) def _response_for_forbidden_start(self, existing_operations): @@ -49,47 +52,34 @@ def _response_for_forbidden_start(self, existing_operations): }, ) - def _response_for_start_to_confirm(self, existing_operations, pack): + def _data_for_scan(self, move_line, pack): + move = move_line.move_id + return { + "id": move_line.package_level_id.id, + "name": pack.name, + "location_src": {"id": pack.location_id.id, "name": pack.location_id.name}, + "location_dst": { + "id": move_line.location_dest_id.id, + "name": move_line.location_dest_id.name, + }, + "product": {"id": move.product_id.id, "name": move.product_id.name}, + "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, + } + + def _response_for_start_to_confirm(self, move_line, pack): message = self.actions_for("message") - move = existing_operations.move_id return self._response( - data={ - "id": existing_operations[0].package_level_id.id, - "name": pack.name, - "location_src": { - "id": pack.location_id.id, - "name": pack.location_id.name, - }, - "location_dst": { - "id": existing_operations[0].location_dest_id.id, - "name": existing_operations[0].location_dest_id.name, - }, - "product": {"id": move.product_id.name, "name": move.product_id.name}, - "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, - }, + data=self._data_for_scan(move_line, pack), state="confirm_start", message=message.already_running_ask_confirmation(), ) - def _response_for_start_success(self, move, pack): + def _response_for_start_success(self, move_line, pack): message = self.actions_for("message") return self._response( state="scan_location", message=message.scan_destination(), - data={ - "id": move.move_line_ids[0].package_level_id.id, - "name": pack.name, - "location_src": { - "id": pack.location_id.id, - "name": pack.location_id.name, - }, - "location_dst": { - "id": move.move_line_ids[0].location_dest_id.id, - "name": move.move_line_ids[0].location_dest_id.name, - }, - "product": {"id": move.product_id.id, "name": move.product_id.name}, - "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, - }, + data=self._data_for_scan(move_line, pack), ) def start(self, barcode): @@ -109,23 +99,39 @@ def start(self, barcode): # TODO this seems to be a pretty common check, consider moving # it to an Action Component - allowed_locations = self.env["stock.location"].search( - [("id", "child_of", picking_type.default_location_src_id.id)] + allowed_location = self.env["stock.location"].search_count( + [ + ("id", "child_of", picking_type.default_location_src_id.id), + ("id", "=", pack.location_id.id), + ] ) - if pack.location_id not in allowed_locations: + if not allowed_location: return self._response_for_forbidden_package(barcode, picking_type) - quantity = pack.quant_ids[0].quantity - existing_operations = self.env["stock.move.line"].search( - [("qty_done", "=", quantity), ("package_id", "=", pack.id)] + existing_operation = self.env["stock.move.line"].search( + [ + ("package_id", "=", pack.id), + ( + "state", + "in", + ( + "assigned", + "draft", + "waiting", + "confirmed", + "partially_available", + ), + ), + ], + limit=1, ) if ( - existing_operations - and existing_operations[0].picking_id.picking_type_id != picking_type + existing_operation + and existing_operation[0].picking_id.picking_type_id != picking_type ): - return self._response_for_forbidden_start(existing_operations) - elif existing_operations: - return self._response_for_start_to_confirm(existing_operations, pack) + return self._response_for_forbidden_start(existing_operation) + elif existing_operation: + return self._response_for_start_to_confirm(existing_operation, pack) move_vals = self._prepare_stock_move(picking_type, pack) move = self.env["stock.move"].create(move_vals) @@ -133,7 +139,8 @@ def start(self, barcode): package_level = self._prepare_package_level(pack, move) move._action_assign() package_level.is_done = True - return self._response_for_start_success(move, pack) + # TODO what if we have > 1 move line? + return self._response_for_start_success(move.move_line_ids, pack) def _prepare_stock_move(self, picking_type, pack): # FIXME we consider only one product per pack @@ -215,9 +222,15 @@ def validate(self, package_level_id, location_barcode, confirmation=False): if pack_transfer.is_dest_location_to_confirm(move, scanned_location): if confirmation: - # keep the move in sync otherwise we would have a move line outside - # the dest location of the move - move.location_dest_id = scanned_location.id + # If the destination of the move would be incoherent + # (move line outside of it), we change the moves' destination + if not self.env["stock.location"].search_count( + [ + ("id", "child_of", move.location_dest_id.id), + ("id", "=", scanned_location.id), + ] + ): + move.location_dest_id = scanned_location.id else: return self._response_for_location_need_confirm() @@ -255,6 +268,7 @@ def _validator_validate(self): return { "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, "location_barcode": {"type": "string", "nullable": False, "required": True}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, } def _validator_return_validate(self): diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 9e879f3fc1..db2576d372 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -36,6 +36,7 @@ def setUpClass(cls, *args, **kwargs): } ) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck") + cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id @@ -100,7 +101,147 @@ def test_start(self): }, ) + def test_start_no_package_for_barcode(self): + """Test /start when no package is found for barcode + + The pre-conditions: + + * No Pack exists with the barcode + + Expected result: + + * return a message + """ + params = {"barcode": "NOTHING_SHOULD_EXIST_WITH: 👀"} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + state="start", + message={ + "title": self.ANY, + "message_type": "error", + "message": "The package NOTHING_SHOULD_EXIST_WITH: 👀 doesn't exist", + }, + ) + + def test_start_package_not_in_src_location(self): + """Test /start when the package is not in the src location + + The pre-conditions: + + * Pack exists with the barcode + * The Pack is outside the location or sublocation of the source + location of the current process' picking type + + Expected result: + + * return a message + """ + barcode = self.packA.name + self.packA.location_id = self.shelf1 + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + state="start", + message={ + "title": self.ANY, + "message_type": "error", + "message": "You cannot work on a package (%s) outside of location: %s" + % ( + self.packA.name, + self.process.picking_type_ids.default_location_src_id.name, + ), + }, + ) + + def test_start_move_in_different_picking_type(self): + """Test /start when the package is used in a move in a different picking type + + The pre-conditions: + + * Pack exists + * A move is created and assigned to move the package, using another picking type + + Expected result: + + * return a message + """ + barcode = self.packA.name + + # Create a move in a different picking type (trick the 'Delivery + # Orders' to go directly from Input to Customers) + picking_form = Form(self.env["stock.picking"]) + picking_form.picking_type_id = self.wh.out_type_id + picking_form.location_id = self.input_location + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.productA + move.product_uom_qty = 1 + picking = picking_form.save() + picking.action_confirm() + picking.action_assign() + + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + state="start", + message={ + "title": self.ANY, + "message_type": "error", + "message": "An operation exists in Delivery Orders %s. You cannot" + " process it with this shopfloor process." % (picking.name,), + }, + ) + + def test_start_move_in_same_picking_type(self): + """Test /start when the package is used in a move in the same picking type + + The pre-conditions: + + * Pack exists + * A move is created and assigned to move the package, using the same + picking type + + Expected result: + + * return a message + """ + barcode = self.packA.name + + # Create a move in a the same picking type + package_level = self._simulate_started() + move = package_level.move_line_ids.move_id + + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + state="confirm_start", + message={ + "title": self.ANY, + "message_type": "warning", + "message": "Operation's already running." + " Would you like to take it over?", + }, + data={ + "id": self.ANY, + "location_src": { + "id": self.dispatch_location.id, + "name": self.dispatch_location.name, + }, + "location_dst": {"id": self.shelf1.id, "name": self.shelf1.name}, + "name": package_level.package_id.name, + "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, + "product": {"id": move.product_id.id, "name": move.product_id.name}, + }, + ) + def _simulate_started(self): + """Replicate what the /start endpoint would do + + Used to test the next endpoints (/validate and /cancel) + """ picking_form = Form(self.env["stock.picking"]) picking_form.picking_type_id = self.menu.process_id.picking_type_ids with picking_form.move_ids_without_package.new() as move: @@ -252,6 +393,99 @@ def test_validate_location_forbidden(self): }, ) + def test_validate_location_to_confirm(self): + """Test a call on /validate on a location to confirm + + The pre-conditions: + + * /start has been called + + Expected result: + + * No change in odoo, transition with a message + + Note: a location to confirm is when a location is a child + of the destination location of the picking type used for the process + but not a child or the expected destination + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() + + # expected destination is 'shelf1', we'll scan shelf2 which must + # ask a confirmation to the user (it's still in the same picking type) + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": self.shelf2.barcode, + }, + ) + + self.assert_response( + response, + state="confirm_location", + message={ + "title": self.ANY, + "message_type": "warning", + "message": "Are you sure?", + }, + ) + + def test_validate_location_with_confirm(self): + """Test a call on /validate on a different location with confirmation + + The pre-conditions: + + * /start has been called + + Expected result: + + * Ignore the fact that the scanned location is not the expected + * Change the destination of the move line to the scanned one + * The move associated to the package level is 'done' + + Note: a location to confirm is when a location is a child + of the destination location of the picking type used for the process + but not a child or the expected destination. + In such situation, the js application has to call /validate with + a ``confirmation`` flag. + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() + + # expected destination is 'shelf1', we'll scan shelf2 which must + # ask a confirmation to the user (it's still in the same picking type) + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": self.shelf2.barcode, + # acknowledge the change of destination + "confirmation": True, + }, + ) + + self.assert_response( + response, + state="start", + message={ + "title": self.ANY, + "message_type": "info", + "message": "The pack has been moved, you can scan a new pack.", + }, + ) + + self.assertRecordValues( + package_level.move_line_ids, + [{"qty_done": 1.0, "location_dest_id": self.shelf2.id, "state": "done"}], + ) + self.assertRecordValues( + package_level.move_line_ids.move_id, + [{"location_dest_id": self.stock_location.id, "state": "done"}], + ) + def test_cancel(self): """Test the happy path for single pack putaway /cancel endpoint From 40dc31d3c73c91e0dd12f72b96aa82283289b06e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 6 Feb 2020 17:20:21 +0100 Subject: [PATCH 068/940] Rename camel-cased variables --- shopfloor/tests/test_single_pack_putaway.py | 28 ++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index db2576d372..3eb9f3d1e7 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -14,25 +14,25 @@ def setUpClass(cls, *args, **kwargs): cls.input_location = cls.env.ref("stock.stock_location_company") cls.shelf1 = cls.env.ref("stock.stock_location_components") cls.shelf2 = cls.env.ref("stock.stock_location_14") - cls.productA = cls.env["product.product"].create( + cls.product_a = cls.env["product.product"].create( {"name": "Product A", "type": "product"} ) - cls.packA = cls.env["stock.quant.package"].create( + cls.pack_a = cls.env["stock.quant.package"].create( {"location_id": stock_location.id} ) cls.env["stock.putaway.rule"].create( { - "product_id": cls.productA.id, + "product_id": cls.product_a.id, "location_in_id": cls.stock_location.id, "location_out_id": cls.shelf1.id, } ) cls.quantA = cls.env["stock.quant"].create( { - "product_id": cls.productA.id, + "product_id": cls.product_a.id, "location_id": cls.dispatch_location.id, "quantity": 1, - "package_id": cls.packA.id, + "package_id": cls.pack_a.id, } ) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck") @@ -62,7 +62,7 @@ def test_start(self): The next step in the workflow is to call /validate with the created package level that will set the move and picking to done. """ - barcode = self.packA.name + barcode = self.pack_a.name params = {"barcode": barcode} # Simulate the client scanning a package's barcode, which # in turns should start the operation in odoo @@ -137,8 +137,8 @@ def test_start_package_not_in_src_location(self): * return a message """ - barcode = self.packA.name - self.packA.location_id = self.shelf1 + barcode = self.pack_a.name + self.pack_a.location_id = self.shelf1 params = {"barcode": barcode} response = self.service.dispatch("start", params=params) self.assert_response( @@ -149,7 +149,7 @@ def test_start_package_not_in_src_location(self): "message_type": "error", "message": "You cannot work on a package (%s) outside of location: %s" % ( - self.packA.name, + self.pack_a.name, self.process.picking_type_ids.default_location_src_id.name, ), }, @@ -167,7 +167,7 @@ def test_start_move_in_different_picking_type(self): * return a message """ - barcode = self.packA.name + barcode = self.pack_a.name # Create a move in a different picking type (trick the 'Delivery # Orders' to go directly from Input to Customers) @@ -175,7 +175,7 @@ def test_start_move_in_different_picking_type(self): picking_form.picking_type_id = self.wh.out_type_id picking_form.location_id = self.input_location with picking_form.move_ids_without_package.new() as move: - move.product_id = self.productA + move.product_id = self.product_a move.product_uom_qty = 1 picking = picking_form.save() picking.action_confirm() @@ -207,7 +207,7 @@ def test_start_move_in_same_picking_type(self): * return a message """ - barcode = self.packA.name + barcode = self.pack_a.name # Create a move in a the same picking type package_level = self._simulate_started() @@ -245,13 +245,13 @@ def _simulate_started(self): picking_form = Form(self.env["stock.picking"]) picking_form.picking_type_id = self.menu.process_id.picking_type_ids with picking_form.move_ids_without_package.new() as move: - move.product_id = self.productA + move.product_id = self.product_a move.product_uom_qty = 1 picking = picking_form.save() picking.action_confirm() picking.action_assign() package_level = picking.move_line_ids.package_level_id - self.assertEqual(package_level.package_id, self.packA) + self.assertEqual(package_level.package_id, self.pack_a) # at this point, the package level is already set to "done", by the # "start" method of the pack transfer putaway package_level.is_done = True From af1c5896832d6faf71328605e449fc7e12d9486a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 6 Feb 2020 17:23:20 +0100 Subject: [PATCH 069/940] Remove title from messages The message and kind for styling are enough. Title is often a bit random and we do not have much room on the UI anyway. --- shopfloor/actions/message.py | 39 +++------------------ shopfloor/services/service.py | 3 +- shopfloor/services/single_pack_putaway.py | 1 - shopfloor/tests/common.py | 1 - shopfloor/tests/test_single_pack_putaway.py | 30 ++-------------- 5 files changed, 8 insertions(+), 66 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index d008b58d8d..3d1a528dfb 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -2,8 +2,6 @@ from odoo.addons.component.core import Component -# TODO remove 'title', useless - class MessageAction(Component): """Provide message templates @@ -22,28 +20,24 @@ class MessageAction(Component): def no_picking_type(self): return { "message_type": "error", - "title": _("Configuration error"), "message": _("No operation type found for this menu and profile."), } def several_picking_types(self): return { "message_type": "error", - "title": _("Configuration error"), "message": _("Several operation types found for this menu and profile."), } def package_not_found_for_barcode(self, barcode): return { "message_type": "error", - "title": _("Package not found"), "message": _("The package %s doesn't exist") % barcode, } def package_not_allowed_in_src_location(self, barcode, picking_type): return { "message_type": "error", - "title": _("Cannot proceed"), "message": _("You cannot work on a package (%s) outside of location: %s") % (barcode, picking_type.default_location_src_id.name), } @@ -51,86 +45,62 @@ def package_not_allowed_in_src_location(self, barcode, picking_type): def already_running_ask_confirmation(self): return { "message_type": "warning", - "title": _("Already started"), "message": _( "Operation's already running. Would you like to take it over?" ), } def scan_destination(self): - return { - "message_type": "info", - "title": _("Scan"), - "message": _("Scan the destination location"), - } + return {"message_type": "info", "message": _("Scan the destination location")} def operation_not_found(self): return { "message_type": "error", - "title": _("Start again"), "message": _("This operation does not exist anymore."), } def operation_has_been_canceled_elsewhere(self): return { "message_type": "warning", - "title": _("Restart"), "message": _("Restart the operation, someone has canceled it."), } def no_location_found(self): return { "message_type": "error", - "title": _("Scan"), "message": _("No location found for this barcode."), } def dest_location_not_allowed(self): - return { - "message_type": "error", - "title": _("Forbidden"), - "message": _("You cannot place it here"), - } + return {"message_type": "error", "message": _("You cannot place it here")} def need_confirmation(self): - return { - "message_type": "warning", - "title": _("Confirm"), - "message": _("Are you sure?"), - } + return {"message_type": "warning", "message": _("Are you sure?")} def confirm_pack_moved(self): return { "message_type": "info", - "title": _("Start"), "message": _("The pack has been moved, you can scan a new pack."), } def already_done(self): - return { - "message_type": "info", - "title": _("Continue"), - "message": _("Operation already processed."), - } + return {"message_type": "info", "message": _("Operation already processed.")} def confirm_canceled_scan_next_pack(self): return { "message_type": "info", - "title": _("Continue"), "message": _("Canceled, you can scan a new pack."), } def no_pack_in_location(self, location): return { "message_type": "error", - "title": _("Scan package"), "message": _("Location %s doesn’t contain any package." % location.name), } def several_packs_in_location(self, location): return { "message_type": "error", - "title": _("Scan package"), "message": _( "Several PACKs found in %s, please scan one PACK." % location.name ), @@ -139,6 +109,5 @@ def several_packs_in_location(self, location): def no_pending_operation_for_pack(self, pack): return { "message_type": "error", - "title": _("Error"), "message": _("No pending operation for package %s." % pack.name), } diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index bfed200be3..aa5f300e72 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -89,8 +89,7 @@ def _response_schema(self, data_schema=None): "required": True, "allowed": ["info", "warning", "error"], }, - "title": {"type": "string", "required": False}, - "body": {"type": "string", "required": True}, + "message": {"type": "string", "required": True}, }, }, } diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 4325df07b5..6fc36be2f8 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -40,7 +40,6 @@ def _response_for_forbidden_start(self, existing_operations): state="start", message={ "message_type": "error", - "title": _("Cannot proceed"), "message": _( "An operation exists in %s %s. " "You cannot process it with this shopfloor process." diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 372fbd187c..682656bab2 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -80,7 +80,6 @@ def assert_dict(self, current, expected): { "data": self.ANY, "message": { - "title": self.ANY, "message_type": self.ANY, "message": self.ANY, }, diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 3eb9f3d1e7..d2ccc6c6fb 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -84,7 +84,6 @@ def test_start(self): response, state="scan_location", message={ - "title": self.ANY, "message_type": "info", "message": "Scan the destination location", }, @@ -118,7 +117,6 @@ def test_start_no_package_for_barcode(self): response, state="start", message={ - "title": self.ANY, "message_type": "error", "message": "The package NOTHING_SHOULD_EXIST_WITH: 👀 doesn't exist", }, @@ -145,7 +143,6 @@ def test_start_package_not_in_src_location(self): response, state="start", message={ - "title": self.ANY, "message_type": "error", "message": "You cannot work on a package (%s) outside of location: %s" % ( @@ -187,7 +184,6 @@ def test_start_move_in_different_picking_type(self): response, state="start", message={ - "title": self.ANY, "message_type": "error", "message": "An operation exists in Delivery Orders %s. You cannot" " process it with this shopfloor process." % (picking.name,), @@ -219,7 +215,6 @@ def test_start_move_in_same_picking_type(self): response, state="confirm_start", message={ - "title": self.ANY, "message_type": "warning", "message": "Operation's already running." " Would you like to take it over?", @@ -286,7 +281,6 @@ def test_validate(self): response, state="start", message={ - "title": self.ANY, "message_type": "info", "message": "The pack has been moved, you can scan a new pack.", }, @@ -317,7 +311,6 @@ def test_validate_not_found(self): response, state="start", message={ - "title": self.ANY, "message_type": "error", "message": "This operation does not exist anymore.", }, @@ -350,7 +343,6 @@ def test_validate_location_not_found(self): response, state="scan_location", message={ - "title": self.ANY, "message_type": "error", "message": "No location found for this barcode.", }, @@ -386,11 +378,7 @@ def test_validate_location_forbidden(self): self.assert_response( response, state="scan_location", - message={ - "title": self.ANY, - "message_type": "error", - "message": "You cannot place it here", - }, + message={"message_type": "error", "message": "You cannot place it here"}, ) def test_validate_location_to_confirm(self): @@ -425,11 +413,7 @@ def test_validate_location_to_confirm(self): self.assert_response( response, state="confirm_location", - message={ - "title": self.ANY, - "message_type": "warning", - "message": "Are you sure?", - }, + message={"message_type": "warning", "message": "Are you sure?"}, ) def test_validate_location_with_confirm(self): @@ -471,7 +455,6 @@ def test_validate_location_with_confirm(self): response, state="start", message={ - "title": self.ANY, "message_type": "info", "message": "The pack has been moved, you can scan a new pack.", }, @@ -519,7 +502,6 @@ def test_cancel(self): response, state="start", message={ - "title": self.ANY, "message_type": "info", "message": "Canceled, you can scan a new pack.", }, @@ -562,7 +544,6 @@ def test_cancel_already_canceled(self): response, state="start", message={ - "title": self.ANY, "message_type": "info", "message": "Canceled, you can scan a new pack.", }, @@ -601,11 +582,7 @@ def test_cancel_already_done(self): self.assert_response( response, state="start", - message={ - "title": self.ANY, - "message_type": "info", - "message": "Operation already processed.", - }, + message={"message_type": "info", "message": "Operation already processed."}, ) def test_cancel_not_found(self): @@ -620,7 +597,6 @@ def test_cancel_not_found(self): response, state="start", message={ - "title": self.ANY, "message_type": "error", "message": "This operation does not exist anymore.", }, From 2da21d69e03c57b1bcc47c2ce3e457a6ea40d70b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 7 Feb 2020 08:29:10 +0100 Subject: [PATCH 070/940] Add method to check if location is a sublocation of another --- shopfloor/actions/pack_transfer_validate.py | 21 +++------------------ shopfloor/models/__init__.py | 1 + shopfloor/models/stock_location.py | 13 +++++++++++++ shopfloor/services/single_pack_putaway.py | 17 ++--------------- 4 files changed, 19 insertions(+), 33 deletions(-) create mode 100644 shopfloor/models/stock_location.py diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py index 4997e402ef..a53998e748 100644 --- a/shopfloor/actions/pack_transfer_validate.py +++ b/shopfloor/actions/pack_transfer_validate.py @@ -18,31 +18,16 @@ class PackTransferValidateAction(Component): def is_move_state_valid(self, move): return move.state != "cancel" - # TODO generic method "is_location_below" def is_dest_location_valid(self, move, scanned_location): """Forbid a dest location to be used""" - allowed_location = self.env["stock.location"].search_count( - [ - ( - "id", - "child_of", - move.picking_id.picking_type_id.default_location_dest_id.id, - ), - ("id", "=", scanned_location.id), - ] + return scanned_location.is_sublocation_of( + move.picking_id.picking_type_id.default_location_dest_id ) - return bool(allowed_location) def is_dest_location_to_confirm(self, move, scanned_location): """Destination that could be used but need confirmation""" move_dest_location = move.move_line_ids[0].location_dest_id - in_dest_location = self.env["stock.location"].search_count( - [ - ("id", "child_of", move_dest_location.id), - ("id", "=", scanned_location.id), - ] - ) - return not bool(in_dest_location) + return not scanned_location.is_sublocation_of(move_dest_location) def set_destination_and_done(self, move, scanned_location): move.move_line_ids[0].location_dest_id = scanned_location.id diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index 1aa3c9fbc8..d0299a28b9 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -3,4 +3,5 @@ from . import shopfloor_process from . import stock_picking_type from . import shopfloor_profile +from . import stock_location from . import res_users diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py new file mode 100644 index 0000000000..6ff8d3a4e8 --- /dev/null +++ b/shopfloor/models/stock_location.py @@ -0,0 +1,13 @@ +from odoo import models + + +class StockLocation(models.Model): + _inherit = "stock.location" + + def is_sublocation_of(self, other): + self.ensure_one() + return bool( + self.env["stock.location"].search_count( + [("id", "child_of", other.id), ("id", "=", self.id)] + ) + ) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 6fc36be2f8..c5f2df3a7a 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -96,15 +96,7 @@ def start(self, barcode): return self._response_for_package_not_found(barcode) assert len(pack) == 1, "We cannot have 2 packages with the same barcode" - # TODO this seems to be a pretty common check, consider moving - # it to an Action Component - allowed_location = self.env["stock.location"].search_count( - [ - ("id", "child_of", picking_type.default_location_src_id.id), - ("id", "=", pack.location_id.id), - ] - ) - if not allowed_location: + if not pack.location_id.is_sublocation_of(picking_type.default_location_src_id): return self._response_for_forbidden_package(barcode, picking_type) existing_operation = self.env["stock.move.line"].search( @@ -223,12 +215,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False): if confirmation: # If the destination of the move would be incoherent # (move line outside of it), we change the moves' destination - if not self.env["stock.location"].search_count( - [ - ("id", "child_of", move.location_dest_id.id), - ("id", "=", scanned_location.id), - ] - ): + if not scanned_location.is_sublocation_of(move.location_dest_id): move.location_dest_id = scanned_location.id else: return self._response_for_location_need_confirm() From 39836ed78efe4e340be4820f6f2be5d958849003 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 7 Feb 2020 14:40:00 +0100 Subject: [PATCH 071/940] Add single putaway methods and tests The "completion info" state still needs to be added. --- shopfloor/actions/message.py | 4 +- shopfloor/actions/pack_transfer_validate.py | 2 +- shopfloor/demo/stock_picking_type_demo.xml | 2 +- shopfloor/services/single_pack_putaway.py | 9 +- shopfloor/services/single_pack_transfer.py | 130 ++-- shopfloor/tests/common.py | 11 + shopfloor/tests/test_single_pack_putaway.py | 23 +- shopfloor/tests/test_single_pack_transfer.py | 677 +++++++++++++++++++ 8 files changed, 796 insertions(+), 62 deletions(-) create mode 100644 shopfloor/tests/test_single_pack_transfer.py diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 3d1a528dfb..98557b39cd 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -95,14 +95,14 @@ def confirm_canceled_scan_next_pack(self): def no_pack_in_location(self, location): return { "message_type": "error", - "message": _("Location %s doesn’t contain any package." % location.name), + "message": _("Location %s doesn't contain any package." % location.name), } def several_packs_in_location(self, location): return { "message_type": "error", "message": _( - "Several PACKs found in %s, please scan one PACK." % location.name + "Several packages found in %s, please scan a package." % location.name ), } diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py index a53998e748..7de53656cd 100644 --- a/shopfloor/actions/pack_transfer_validate.py +++ b/shopfloor/actions/pack_transfer_validate.py @@ -1,7 +1,7 @@ from odoo.addons.component.core import Component -# TODO think about how we want to share the common methods / workflows +# TODO think BETTER about how we want to share the common methods / workflows class PackTransferValidateAction(Component): """Pack Transfer shared business logic diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml index c6bb3bae22..6e473b5de6 100644 --- a/shopfloor/demo/stock_picking_type_demo.xml +++ b/shopfloor/demo/stock_picking_type_demo.xml @@ -21,7 +21,7 @@ SPT - + diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index c5f2df3a7a..b2f9294052 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -51,7 +51,7 @@ def _response_for_forbidden_start(self, existing_operations): }, ) - def _data_for_scan(self, move_line, pack): + def _data_after_package_scanned(self, move_line, pack): move = move_line.move_id return { "id": move_line.package_level_id.id, @@ -68,7 +68,7 @@ def _data_for_scan(self, move_line, pack): def _response_for_start_to_confirm(self, move_line, pack): message = self.actions_for("message") return self._response( - data=self._data_for_scan(move_line, pack), + data=self._data_after_package_scanned(move_line, pack), state="confirm_start", message=message.already_running_ask_confirmation(), ) @@ -78,7 +78,7 @@ def _response_for_start_success(self, move_line, pack): return self._response( state="scan_location", message=message.scan_destination(), - data=self._data_for_scan(move_line, pack), + data=self._data_after_package_scanned(move_line, pack), ) def start(self, barcode): @@ -250,6 +250,9 @@ def _validator_cancel(self): "package_level_id": {"coerce": to_int, "required": True, "type": "integer"} } + def _validator_return_cancel(self): + return self._response_schema() + def _validator_validate(self): return { "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 1e26827324..5c242a075b 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -1,6 +1,8 @@ from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component +# TODO add completion info screen + class SinglePackTransfer(Component): """Methods for the Single Pack Transfer Process""" @@ -10,6 +12,8 @@ class SinglePackTransfer(Component): _usage = "single_pack_transfer" _description = __doc__ + # TODO get rid of these methods now that we have a component + # for the messages? could help for extensibility though...? def _response_for_empty_location(self, location): message = self.actions_for("message") return self._response( @@ -28,58 +32,60 @@ def _response_for_package_not_found(self, barcode): state="start", message=message.package_not_found_for_barcode(barcode) ) + def _response_for_forbidden_package(self, barcode, picking_type): + message = self.actions_for("message") + return self._response( + state="start", + message=message.package_not_allowed_in_src_location(barcode, picking_type), + ) + + def _response_for_several_picking_types(self): + message = self.actions_for("message") + return self._response(state="start", message=message.several_picking_types()) + def _response_for_operation_not_found(self, pack): message = self.actions_for("message") return self._response( state="start", message=message.no_pending_operation_for_pack(pack) ) - def _response_for_start_to_confirm(self, existing_operations, pack): + def _data_after_package_scanned(self, move_line, pack): + move = move_line.move_id + return { + "id": move_line.package_level_id.id, + "name": pack.name, + "location_src": {"id": pack.location_id.id, "name": pack.location_id.name}, + "location_dst": { + "id": move_line.location_dest_id.id, + "name": move_line.location_dest_id.name, + }, + "product": {"id": move.product_id.id, "name": move.product_id.name}, + "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, + } + + def _response_for_start_to_confirm(self, move_line, pack): message = self.actions_for("message") - move = existing_operations.move_id return self._response( - data={ - "id": existing_operations[0].package_level_id.id, - "name": pack.name, - "location_src": { - "id": pack.location_id.id, - "name": pack.location_id.name, - }, - "location_dst": { - "id": existing_operations[0].location_dest_id.id, - "name": existing_operations[0].location_dest_id.name, - }, - "product": {"id": move.product_id.name, "name": move.product_id.name}, - "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, - }, state="confirm_start", message=message.already_running_ask_confirmation(), + data=self._data_after_package_scanned(move_line, pack), ) - def _response_for_start_success(self, move, pack): + def _response_for_start_success(self, move_line, pack): message = self.actions_for("message") return self._response( state="scan_location", message=message.scan_destination(), - data={ - "id": move.move_line_ids[0].package_level_id.id, - "name": pack.name, - "location_src": { - "id": pack.location_id.id, - "name": pack.location_id.name, - }, - "location_dst": { - "id": move.move_line_ids[0].location_dest_id.id, - "name": move.move_line_ids[0].location_dest_id.name, - }, - "product": {"id": move.product_id.id, "name": move.product_id.name}, - "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, - }, + data=self._data_after_package_scanned(move_line, pack), ) def start(self, barcode): search = self.actions_for("search") + picking_type = self.picking_types + if len(picking_type) > 1: + return self._response_for_several_picking_types() + location = search.location_from_scan(barcode) pack = self.env["stock.quant.package"] @@ -90,7 +96,7 @@ def start(self, barcode): if not pack: return self._response_for_empty_location(location) if len(pack) > 1: - return self._response_for_several_packages(self, location) + return self._response_for_several_packages(location) if not pack: pack = search.package_from_scan(barcode) @@ -98,20 +104,24 @@ def start(self, barcode): if not pack: return self._response_for_package_not_found(barcode) + if not pack.location_id.is_sublocation_of(picking_type.default_location_src_id): + return self._response_for_forbidden_package(barcode, picking_type) + existing_operations = self.env["stock.move.line"].search( [ ("package_id", "=", pack.id), + ("state", "!=", "done"), ("picking_id.picking_type_id", "in", self.picking_types.ids), ] ) if not existing_operations: return self._response_for_operation_not_found(pack) - move = existing_operations.move_id + # TODO can we have more than one move line? if existing_operations[0].package_level_id.is_done: return self._response_for_start_to_confirm(existing_operations, pack) existing_operations[0].package_level_id.is_done = True - return self._response_for_start_success(move, pack) + return self._response_for_start_success(existing_operations[0], pack) def _validator_start(self): return {"barcode": {"type": "string", "nullable": False, "required": True}} @@ -156,12 +166,18 @@ def _response_for_package_level_not_found(self): message = self.actions_for("message") return self._response(state="start", message=message.operation_not_found()) - def _response_for_move_canceled(self): + def _response_for_move_canceled_elsewhere(self): message = self.actions_for("message") return self._response( state="start", message=message.operation_has_been_canceled_elsewhere() ) + def _response_for_location_not_found(self): + message = self.actions_for("message") + return self._response( + state="scan_location", message=message.no_location_found() + ) + def _response_for_forbidden_location(self): message = self.actions_for("message") return self._response( @@ -180,6 +196,7 @@ def _response_for_validate_success(self): def validate(self, package_level_id, location_barcode, confirmation=False): """Validate the transfer""" + # TODO this method is duplicated in putaway pack_transfer = self.actions_for("pack.transfer.validate") search = self.actions_for("search") @@ -189,21 +206,24 @@ def validate(self, package_level_id, location_barcode, confirmation=False): move = package.move_line_ids[0].move_id if not pack_transfer.is_move_state_valid(move): - return self._response_for_move_canceled() + return self._response_for_move_canceled_elsewhere() scanned_location = search.location_from_scan(location_barcode) + if not scanned_location: + return self._response_for_location_not_found() if not pack_transfer.is_dest_location_valid(move, scanned_location): return self._response_for_forbidden_location() if pack_transfer.is_dest_location_to_confirm(move, scanned_location): if confirmation: - # keep the move in sync otherwise we would have a move line outside - # the dest location of the move - move.location_dest_id = scanned_location.id + # If the destination of the move would be incoherent + # (move line outside of it), we change the moves' destination + if not scanned_location.is_sublocation_of(move.location_dest_id): + move.location_dest_id = scanned_location.id else: return self._response_for_location_need_confirm() - pack_transfer.set_destination_and_done() + pack_transfer.set_destination_and_done(move, scanned_location) return self._response_for_validate_success() def _validator_validate(self): @@ -216,4 +236,32 @@ def _validator_validate(self): def _validator_return_validate(self): return self._response_schema() - # TODO cancel + def cancel(self, package_level_id): + package = self.env["stock.package_level"].browse(package_level_id) + if not package.exists(): + return self._response_for_package_level_not_found() + # package.move_ids may be empty, it seems + move = package.move_line_ids.move_id + if move.state == "done": + return self._response_for_move_already_processed() + + package.is_done = False + return self._response_for_confirm_cancel() + + def _response_for_move_already_processed(self): + message = self.actions_for("message") + return self._response(state="start", message=message.already_done()) + + def _response_for_confirm_cancel(self): + message = self.actions_for("message") + return self._response( + state="start", message=message.confirm_canceled_scan_next_pack() + ) + + def _validator_cancel(self): + return { + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"} + } + + def _validator_return_cancel(self): + return self._response_schema() diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 682656bab2..8b58279f0d 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -49,6 +49,17 @@ def setUpClass(cls): context=dict(cls.env.context, tracking_disable=cls.tracking_disable) ) cls.setUpComponent() + cls.setUpClassVars() + + @classmethod + def setUpClassVars(cls): + stock_location = cls.env.ref("stock.stock_location_stock") + cls.stock_location = stock_location + cls.dispatch_location = cls.env.ref("stock.location_dispatch_zone") + cls.dispatch_location.barcode = "DISPATCH" + cls.input_location = cls.env.ref("stock.stock_location_company") + cls.shelf1 = cls.env.ref("stock.stock_location_components") + cls.shelf2 = cls.env.ref("stock.stock_location_14") def assert_response(self, response, state=None, message=None, data=None): """Assert a response from the webservice diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index d2ccc6c6fb..2983419727 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -3,22 +3,15 @@ from .common import CommonCase -class PutawayCase(CommonCase): +class SinglePackPutawayCase(CommonCase): @classmethod def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) - stock_location = cls.env.ref("stock.stock_location_stock") - cls.stock_location = stock_location - cls.dispatch_location = cls.env.ref("stock.location_dispatch_zone") - cls.dispatch_location.barcode = "DISPATCH" - cls.input_location = cls.env.ref("stock.stock_location_company") - cls.shelf1 = cls.env.ref("stock.stock_location_components") - cls.shelf2 = cls.env.ref("stock.stock_location_14") cls.product_a = cls.env["product.product"].create( {"name": "Product A", "type": "product"} ) cls.pack_a = cls.env["stock.quant.package"].create( - {"location_id": stock_location.id} + {"location_id": cls.stock_location.id} ) cls.env["stock.putaway.rule"].create( { @@ -27,7 +20,7 @@ def setUpClass(cls, *args, **kwargs): "location_out_id": cls.shelf1.id, } ) - cls.quantA = cls.env["stock.quant"].create( + cls.quant_a = cls.env["stock.quant"].create( { "product_id": cls.product_a.id, "location_id": cls.dispatch_location.id, @@ -190,8 +183,10 @@ def test_start_move_in_different_picking_type(self): }, ) - def test_start_move_in_same_picking_type(self): - """Test /start when the package is used in a move in the same picking type + def test_start_move_already_exist(self): + """Test /start when the move for the package already exists + + Because it was already started. The pre-conditions: @@ -201,7 +196,7 @@ def test_start_move_in_same_picking_type(self): Expected result: - * return a message + * return a message to confirm """ barcode = self.pack_a.name @@ -228,7 +223,7 @@ def test_start_move_in_same_picking_type(self): "location_dst": {"id": self.shelf1.id, "name": self.shelf1.name}, "name": package_level.package_id.name, "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, - "product": {"id": move.product_id.id, "name": move.product_id.name}, + "product": {"id": self.product_a.id, "name": self.product_a.name}, }, ) diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py new file mode 100644 index 0000000000..df30c61845 --- /dev/null +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -0,0 +1,677 @@ +from odoo.tests.common import Form + +from .common import CommonCase + + +class SinglePackTransferCase(CommonCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.product_a = cls.env["product.product"].create( + {"name": "Product A", "type": "product"} + ) + cls.pack_a = cls.env["stock.quant.package"].create( + {"location_id": cls.stock_location.id} + ) + cls.quant_a = cls.env["stock.quant"].create( + { + "product_id": cls.product_a.id, + "location_id": cls.shelf1.id, + "quantity": 1, + "package_id": cls.pack_a.id, + } + ) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_single_pallet_transfer") + cls.process = cls.menu.process_id + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + cls.wh = cls.profile.warehouse_id + cls.picking_type = cls.process.picking_type_ids + cls.picking = cls._create_initial_move() + + def setUp(self): + super().setUp() + with self.work_on_services(menu=self.menu, profile=self.profile) as work: + self.service = work.component(usage="single_pack_transfer") + + @classmethod + def _create_initial_move(cls): + """Create the move to satisfy the pre-condition before /start""" + picking_form = Form(cls.env["stock.picking"]) + picking_form.picking_type_id = cls.picking_type + picking_form.location_id = cls.shelf1 + picking_form.location_dest_id = cls.shelf2 + with picking_form.move_ids_without_package.new() as move: + move.product_id = cls.product_a + move.product_uom_qty = 1 + picking = picking_form.save() + picking.action_confirm() + picking.action_assign() + return picking + + def _simulate_started(self): + """Replicate what the /start endpoint would do + + Used to test the next endpoints (/validate and /cancel) + """ + package_level = self.picking.move_line_ids.package_level_id + package_level.is_done = True + return package_level + + def test_start(self): + """Test the happy path for single pack transfer /start endpoint + + We scan the barcode of the pack (simplest use case). + + The pre-conditions: + + * A Pack exists in Stock/Shelf1. + * A stock picking exists to move the Pack from Stock/Shelf1 to + Stock/Shelf2. The move is "assigned". + + Expected result: + + * The package level of the move is set to "is_done". + + The next step in the workflow is to call /validate with the + package level that will set the move and picking to done. + """ + barcode = self.pack_a.name + params = {"barcode": barcode} + + package_level = self.picking.move_line_ids.package_level_id + self.assertFalse(package_level.is_done) + + # Simulate the client scanning a package's barcode, which + # in turns should start the operation in odoo + response = self.service.dispatch("start", params=params) + + self.assertTrue(package_level.is_done) + self.assert_response( + response, + state="scan_location", + message={ + "message_type": "info", + "message": "Scan the destination location", + }, + data={ + "id": self.ANY, + "name": package_level.package_id.name, + "location_src": {"id": self.shelf1.id, "name": self.shelf1.name}, + "location_dst": {"id": self.shelf2.id, "name": self.shelf2.name}, + "picking": {"id": self.picking.id, "name": self.picking.name}, + "product": {"id": self.product_a.id, "name": self.product_a.name}, + }, + ) + + def test_start_no_operation(self): + """Test /start when there is no operation to move the pack + + The pre-conditions: + + * A Pack exists in Stock/Shelf1. + * No stock picking exists to move the Pack from Stock/Shelf1 to + Stock/Shelf2, or the state is not assigned. + + Expected result: + + * Return a message + """ + barcode = self.pack_a.name + params = {"barcode": barcode} + self.picking.do_unreserve() + + # Simulate the client scanning a package's barcode, which + # in turns should start the operation in odoo + response = self.service.dispatch("start", params=params) + + self.assert_response( + response, + state="start", + message={ + "message_type": "error", + "message": "No pending operation for package {}.".format( + self.pack_a.name + ), + }, + ) + + def test_start_barcode_not_known(self): + """Test /start when the barcode is unknown + + The pre-conditions: + + * Nothing + + Expected result: + + * Return a message + """ + params = {"barcode": "THIS_BARCODE_DOES_NOT_EXIST"} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + state="start", + message={ + "message_type": "error", + "message": "The package THIS_BARCODE_DOES_NOT_EXIST" " doesn't exist", + }, + ) + + def test_start_pack_from_location(self): + """Test /start, finding the pack from location's barcode + + When we scan a location which contains only one pack, + we want to move this pack. + + The pre-conditions: + + * A Pack exists in Stock/Shelf1. + * A stock picking exists to move the Pack from Stock/Shelf1 to + Stock/Shelf2. The move is "assigned". + + Expected result: + + * The package level of the move is set to "is_done". + + The next step in the workflow is to call /validate with the + package level that will set the move and picking to done. + """ + barcode = self.shelf1.barcode + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + self.assert_response( + # We only care about the fact that we jump to the next + # screen, so it found the pack. The details are already + # checked in the test_start test. + response, + state="scan_location", + message=self.ANY, + data=self.ANY, + ) + + def test_start_pack_from_location_empty(self): + """Test /start, scan location's barcode without pack + + When we scan a location which contains no packs, + we ask the user to scan a pack. + + The pre-conditions: + + * No packs exists in Stock/Shelf2 + + Expected result: + + * Return a message + """ + barcode = self.shelf2.barcode + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + state="start", + message={ + "message_type": "error", + "message": "Location %s doesn't contain any package." + % (self.shelf2.name,), + }, + ) + + def test_start_pack_from_location_several_packs(self): + """Test /start, scan location's barcode with several packs + + When we scan a location which contains several packs, + we ask the user to scan a pack. + + The pre-conditions: + + * 2 packs exists in Stock/Shelf1. + + Expected result: + + * Return a message + """ + pack_b = self.env["stock.quant.package"].create( + {"location_id": self.stock_location.id} + ) + self.env["stock.quant"].create( + { + "product_id": self.product_a.id, + "location_id": self.shelf1.id, + "quantity": 1, + "package_id": pack_b.id, + } + ) + barcode = self.shelf1.barcode + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + state="start", + message={ + "message_type": "error", + "message": "Several packages found in %s, please scan a package." + % (self.shelf1.name,), + }, + ) + + def test_start_pack_outside_of_location(self): + """Test /start, scan a pack outside of the picking type location + + The pre-conditions: + + * A pack exists in a location outside of Stock (the default source + location of the picking type associated with the process) + + Expected result: + + * Return a message + """ + self.pack_a.location_id = self.dispatch_location + barcode = self.pack_a.name + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + state="start", + message={ + "message_type": "error", + "message": "You cannot work on a package (%s) outside of location: %s" + % (self.pack_a.name, self.picking_type.default_location_src_id.name), + }, + ) + + def test_start_already_started(self): + """Test /start when it was already started + + We scan the barcode of the pack (simplest use case). + + The pre-conditions: + + * A Pack exists in Stock/Shelf1. + * A stock picking exists to move the Pack from Stock/Shelf1 to + Stock/Shelf2. The move is "assigned". + * Start is already called once + + Expected result: + + * Transition for confirmation with such message + + The next step in the workflow is to call /validate with the + package level that will set the move and picking to done. + """ + barcode = self.pack_a.name + params = {"barcode": barcode} + + package_level = self._simulate_started() + self.assertTrue(package_level.is_done) + + # Simulate the client scanning a package's barcode, which + # in turns should start the operation in odoo + response = self.service.dispatch("start", params=params) + + self.assert_response( + response, + state="confirm_start", + message={ + "message_type": "warning", + "message": "Operation's already running." + " Would you like to take it over?", + }, + data={ + "id": self.ANY, + "name": package_level.package_id.name, + "location_src": {"id": self.shelf1.id, "name": self.shelf1.name}, + "location_dst": {"id": self.shelf2.id, "name": self.shelf2.name}, + "picking": {"id": self.picking.id, "name": self.picking.name}, + "product": {"id": self.product_a.id, "name": self.product_a.name}, + }, + ) + + def test_validate(self): + """Test the happy path for single pack transfer /validate endpoint + + The pre-conditions: + + * /start has been called + + Expected result: + + * The move associated to the package level is 'done' + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() + + # now, call the service to proceed with validation of the + # movement + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": self.shelf2.barcode, + }, + ) + + self.assert_response( + response, + state="start", + message={ + "message_type": "info", + "message": "The pack has been moved, you can scan a new pack.", + }, + ) + + self.assertRecordValues( + package_level.move_line_ids, + [{"qty_done": 1.0, "location_dest_id": self.shelf2.id, "state": "done"}], + ) + self.assertRecordValues( + package_level.move_line_ids.move_id, + [{"location_dest_id": self.shelf2.id, "state": "done"}], + ) + + def test_validate_not_found(self): + """Test a call on /validate on package level not found + + Expected result: + + * No change in odoo, Transition with a message + """ + response = self.service.dispatch( + "validate", + params={"package_level_id": -1, "location_barcode": self.shelf1.barcode}, + ) + + self.assert_response( + response, + state="start", + message={ + "message_type": "error", + "message": "This operation does not exist anymore.", + }, + ) + + def test_validate_location_not_found(self): + """Test a call on /validate on location not found + + The pre-conditions: + + * /start has been called + + Expected result: + + * No change in odoo, Transition with a message + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() + + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": "THIS_BARCODE_DOES_NOT_EXISTS", + }, + ) + + self.assert_response( + response, + state="scan_location", + message={ + "message_type": "error", + "message": "No location found for this barcode.", + }, + ) + + def test_validate_location_forbidden(self): + """Test a call on /validate on a forbidden location + + The pre-conditions: + + * /start has been called + + Expected result: + + * No change in odoo, Transition with a message + + Note: a forbidden location is when a location is not a child + of the destination location of the picking type used for the process + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() + + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + # this location is outside of the expected destination + "location_barcode": self.dispatch_location.barcode, + }, + ) + + self.assert_response( + response, + state="scan_location", + message={"message_type": "error", "message": "You cannot place it here"}, + ) + + def test_validate_location_to_confirm(self): + """Test a call on /validate on a location to confirm + + The pre-conditions: + + * /start has been called + + Expected result: + + * No change in odoo, transition with a message + + Note: a location to confirm is when a location is a child + of the destination location of the picking type used for the process + but not a child or the expected destination + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() + + # expected destination is 'shelf2', we'll scan shelf1 which must + # ask a confirmation to the user (it's still in the same picking type) + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": self.shelf1.barcode, + }, + ) + + self.assert_response( + response, + state="confirm_location", + message={"message_type": "warning", "message": "Are you sure?"}, + ) + + def test_validate_location_with_confirm(self): + """Test a call on /validate on a different location with confirmation + + The pre-conditions: + + * /start has been called + + Expected result: + + * Ignore the fact that the scanned location is not the expected + * Change the destination of the move line to the scanned one + * The move associated to the package level is 'done' + + Note: a location to confirm is when a location is a child + of the destination location of the picking type used for the process + but not a child or the expected destination. + In such situation, the js application has to call /validate with + a ``confirmation`` flag. + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() + + # expected destination is 'shelf1', we'll scan shelf2 which must + # ask a confirmation to the user (it's still in the same picking type) + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": self.shelf2.barcode, + # acknowledge the change of destination + "confirmation": True, + }, + ) + + self.assert_response( + response, + state="start", + message={ + "message_type": "info", + "message": "The pack has been moved, you can scan a new pack.", + }, + ) + + self.assertRecordValues( + package_level.move_line_ids, + [{"qty_done": 1.0, "location_dest_id": self.shelf2.id, "state": "done"}], + ) + self.assertRecordValues( + package_level.move_line_ids.move_id, + [{"location_dest_id": self.shelf2.id, "state": "done"}], + ) + + def test_cancel(self): + """Test the happy path for single pack transfer /cancel endpoint + + The pre-conditions: + + * /start has been called + + Expected result: + + * The package level has is_done to False + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() + self.assertTrue(package_level.is_done) + + # keep references for later checks + move = package_level.move_line_ids.move_id + picking = move.picking_id + + # now, call the service to cancel + response = self.service.dispatch( + "cancel", params={"package_level_id": package_level.id} + ) + self.assertRecordValues(move, [{"state": "assigned"}]) + self.assertRecordValues(picking, [{"state": "assigned"}]) + self.assertRecordValues(package_level, [{"is_done": False}]) + + self.assert_response( + response, + state="start", + message={ + "message_type": "info", + "message": "Canceled, you can scan a new pack.", + }, + ) + + def test_cancel_already_canceled(self): + """Test a call on /cancel for already canceled package level + + The pre-conditions: + + * /start has been called + * /cancel has been called elsewhere or 'is_done' removed in odoo + + Expected result: + + * Nothing happens, transition with a message + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() + + # keep references for later checks + move = package_level.move_line_ids.move_id + move_lines = package_level.move_line_ids + picking = move.picking_id + + # someone cancel the work started by our operator + move._action_cancel() + + # now, call the service to cancel + response = self.service.dispatch( + "cancel", params={"package_level_id": package_level.id} + ) + self.assertRecordValues(move, [{"state": "cancel"}]) + self.assertRecordValues(picking, [{"state": "cancel"}]) + self.assertFalse(package_level.move_line_ids) + self.assertFalse(move_lines.exists()) + + self.assert_response( + response, + state="start", + message={ + "message_type": "info", + "message": "Canceled, you can scan a new pack.", + }, + ) + + def test_cancel_already_done(self): + """Test a call on /cancel on move already done + + The pre-conditions: + + * /start has been called + * /validate has been called or move set to done in odoo + + Expected result: + + * No change in odoo, Transition with a message + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() + + # keep references for later checks + move = package_level.move_line_ids.move_id + picking = move.picking_id + + # someone cancel the work started by our operator + move._action_done() + + # now, call the service to cancel + response = self.service.dispatch( + "cancel", params={"package_level_id": package_level.id} + ) + self.assertRecordValues(move, [{"state": "done"}]) + self.assertRecordValues(picking, [{"state": "done"}]) + + self.assert_response( + response, + state="start", + message={"message_type": "info", "message": "Operation already processed."}, + ) + + def test_cancel_not_found(self): + """Test a call on /cancel on package level not found + + Expected result: + + * No change in odoo, Transition with a message + """ + response = self.service.dispatch("cancel", params={"package_level_id": -1}) + self.assert_response( + response, + state="start", + message={ + "message_type": "error", + "message": "This operation does not exist anymore.", + }, + ) From 97ef84fb0204676ec5ac9757b6d273d38da3b5cf Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 7 Feb 2020 15:11:28 +0100 Subject: [PATCH 072/940] Add 'completion info' for single pack transfer process --- shopfloor/__manifest__.py | 8 ++- shopfloor/demo/stock_picking_type_demo.xml | 2 + shopfloor/services/single_pack_transfer.py | 10 ++-- shopfloor/tests/test_single_pack_transfer.py | 51 ++++++++++++++++++++ 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 69f064b2c2..a532100ae3 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -11,7 +11,13 @@ "author": "Akretion, BCIM, Camptocamp, Odoo Community Association (OCA)", "license": "AGPL-3", "application": True, - "depends": ["stock", "base_rest", "auth_api_key"], + "depends": [ + "stock", + "base_rest", + "auth_api_key", + # https://github.com/OCA/stock-logistics-warehouse/pull/808 + "stock_picking_completion_info", + ], "data": [ "security/ir.model.access.csv", "views/shopfloor_operation_group.xml", diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml index 6e473b5de6..a6a96d9d69 100644 --- a/shopfloor/demo/stock_picking_type_demo.xml +++ b/shopfloor/demo/stock_picking_type_demo.xml @@ -14,6 +14,7 @@ internal + @@ -30,6 +31,7 @@ internal + diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 5c242a075b..1d44a60d17 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -190,9 +190,12 @@ def _response_for_location_need_confirm(self): state="confirm_location", message=message.need_confirmation() ) - def _response_for_validate_success(self): + def _response_for_validate_success(self, last=False): message = self.actions_for("message") - return self._response(state="start", message=message.confirm_pack_moved()) + state = "start" + if last: + state = "show_completion_info" + return self._response(state=state, message=message.confirm_pack_moved()) def validate(self, package_level_id, location_barcode, confirmation=False): """Validate the transfer""" @@ -224,7 +227,8 @@ def validate(self, package_level_id, location_barcode, confirmation=False): return self._response_for_location_need_confirm() pack_transfer.set_destination_and_done(move, scanned_location) - return self._response_for_validate_success() + last = move.picking_id.completion_info == "next_picking_ready" + return self._response_for_validate_success(last=last) def _validator_validate(self): return { diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index df30c61845..dff4999c3d 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -28,6 +28,10 @@ def setUpClass(cls, *args, **kwargs): cls.picking_type = cls.process.picking_type_ids cls.picking = cls._create_initial_move() + # disable the completion on the picking type, we'll have specific test(s) + # to check the behavior of this screen + cls.picking_type.display_completion_info = False + def setUp(self): super().setUp() with self.work_on_services(menu=self.menu, profile=self.profile) as work: @@ -333,6 +337,7 @@ def test_validate(self): The pre-conditions: * /start has been called + * "completion info" is not active on the picking type Expected result: @@ -370,6 +375,52 @@ def test_validate(self): [{"location_dest_id": self.shelf2.id, "state": "done"}], ) + def test_validate_completion_info(self): + """Test /validate when the picking is the last (show completion info) + + When the picking is the last, we display an information screen on the + js application. + + The pre-conditions: + + * /start has been called + * "completion info" is active on the picking type + * the picking must be the last (it must not have destination moves with + unprocessed origin moves) + + Expected result: + + * The move associated to the package level is 'done' + * The transition goes to the completion info screen instead of starting + over + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() + + # activate the computation of this field, so we have a chance to + # transition to the 'show completion info' screen. + self.picking_type.display_completion_info = True + + # now, call the service to proceed with validation of the + # movement + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": self.shelf2.barcode, + }, + ) + + self.assert_response( + response, + state="show_completion_info", + message={ + "message_type": "info", + "message": "The pack has been moved, you can scan a new pack.", + }, + ) + def test_validate_not_found(self): """Test a call on /validate on package level not found From 9a9881180dc982e707ae3a3da230eebd92c4a88f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 10 Feb 2020 13:34:40 +0100 Subject: [PATCH 073/940] Propose default values for HTTP vars in Swagger --- shopfloor/models/shopfloor_process.py | 1 + shopfloor/services/service.py | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/shopfloor/models/shopfloor_process.py b/shopfloor/models/shopfloor_process.py index db4759c2d0..751eecafe7 100644 --- a/shopfloor/models/shopfloor_process.py +++ b/shopfloor/models/shopfloor_process.py @@ -10,6 +10,7 @@ class ShopfloorProcess(models.Model): picking_type_ids = fields.One2many( "stock.picking.type", "process_id", string="Operation types" ) + menu_ids = fields.One2many(comodel_name="shopfloor.menu", inverse_name="process_id") def _selection_code(self): return [ diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index aa5f300e72..f31cf118a8 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -1,4 +1,4 @@ -from odoo import _ +from odoo import _, fields from odoo.exceptions import MissingError from odoo.osv import expression @@ -99,6 +99,16 @@ def _get_openapi_default_parameters(self): demo_api_key = self.env.ref( "shopfloor.api_key_demo", raise_if_not_found=False ).key + + # Try to first the first menu that implements the current service. + # Not all usages have a process, in that case, well set the first + # process found, because it should not matter for the service. + processes = self.env["shopfloor.process"].search([("code", "=", self._usage)]) + menu = fields.first(processes.menu_ids) + if not menu: + menu = self.env["shopfloor.menu"].search([], limit=1) + profile = self.env["shopfloor.profile"].search([], limit=1) + defaults.extend( [ { @@ -117,7 +127,7 @@ def _get_openapi_default_parameters(self): "required": True, "schema": {"type": "integer"}, "style": "simple", - "value": "1", + "value": menu.id, }, { "name": "SERVICE_CTX_PROFILE_ID", @@ -126,7 +136,7 @@ def _get_openapi_default_parameters(self): "required": True, "schema": {"type": "integer"}, "style": "simple", - "value": "1", + "value": profile.id, }, ] ) From d1cff5cb30259dd471d1ca520248e1034e88ed65 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 11 Feb 2020 08:07:40 +0100 Subject: [PATCH 074/940] putaway: check source location is set --- shopfloor/services/single_pack_putaway.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index b2f9294052..cec72763d9 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -96,7 +96,10 @@ def start(self, barcode): return self._response_for_package_not_found(barcode) assert len(pack) == 1, "We cannot have 2 packages with the same barcode" - if not pack.location_id.is_sublocation_of(picking_type.default_location_src_id): + location_src = picking_type.default_location_src_id + assert location_src, "Picking type has no default source location" + + if not pack.location_id.is_sublocation_of(location_src): return self._response_for_forbidden_package(barcode, picking_type) existing_operation = self.env["stock.move.line"].search( From b34b60ef884dc4eb4c473e1b6481d0b5be5c044c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 11 Feb 2020 08:08:11 +0100 Subject: [PATCH 075/940] putaway: fix call to old method name --- shopfloor/services/single_pack_putaway.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index cec72763d9..8b2e579950 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -123,7 +123,7 @@ def start(self, barcode): existing_operation and existing_operation[0].picking_id.picking_type_id != picking_type ): - return self._response_for_forbidden_start(existing_operation) + return self._response_for_forbidden_scan_pack(existing_operation) elif existing_operation: return self._response_for_start_to_confirm(existing_operation, pack) From 29eec2b4620fa0981807b09d18d40005e65b3ed2 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 11 Feb 2020 08:09:47 +0100 Subject: [PATCH 076/940] backend: improve menu data --- shopfloor/services/menu.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index 96847ce7c4..77670ff2e7 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -67,8 +67,9 @@ def _record_return_schema(self): return { "id": {"coerce": to_int, "required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, - "process": {"type": "string", "nullable": False, "required": True}, + "process_code": {"type": "string", "nullable": False, "required": True}, + "process_id": {"coerce": to_int, "required": True, "type": "integer"}, } def _convert_one_record(self, record): - return {"id": record.id, "name": record.name, "process": record.process_code} + return {"id": record.id, "name": record.name, "process_code": record.process_code, "process_id": record.process_id.id} From 0960d80ef9dc661342ab4e9d72d4605c1c960450 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 11 Feb 2020 11:48:28 +0100 Subject: [PATCH 077/940] backend: special headers are needed for process only --- shopfloor/controllers/main.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py index 297b0e4033..e2a7dd79b2 100644 --- a/shopfloor/controllers/main.py +++ b/shopfloor/controllers/main.py @@ -9,6 +9,7 @@ class ShopfloorController(main.RestController): _root_path = "/shopfloor/" _collection_name = "shopfloor.service" _default_auth = "api_key" + _non_process_services = ('app', 'menu', 'profile') def _get_component_context(self): """ @@ -19,16 +20,29 @@ def _get_component_context(self): """ res = super(ShopfloorController, self)._get_component_context() headers = request.httprequest.environ + + res['menu'] = None + res['profile'] = None + if self._is_process_enpoint(request.httprequest.path): + res.update(self._get_process_context(headers, request.env)) + return res + + def _is_process_enpoint(self, request_path): + # '/shopfloor/app/user_config' -> app/config + service_path = request_path.split(self._root_path)[-1] + return not service_path.startswith(self._non_process_services) + + def _get_process_context(self, headers, env): + ctx = {} try: menu_id = int(headers.get("HTTP_SERVICE_CTX_MENU_ID")) except (TypeError, ValueError): raise BadRequest("HTTP_SERVICE_CTX_MENU_ID must be set with an integer") - res["menu"] = request.env["shopfloor.menu"].browse(menu_id) + ctx["menu"] = env["shopfloor.menu"].browse(menu_id) try: profile_id = int(headers.get("HTTP_SERVICE_CTX_PROFILE_ID")) except (TypeError, ValueError): raise BadRequest("HTTP_SERVICE_CTX_PROFILE_ID must be set with an integer") - res["profile"] = request.env["shopfloor.profile"].browse(profile_id) - - return res + ctx["profile"] = env["shopfloor.profile"].browse(profile_id) + return ctx From f448b9d9029955c668ab855d302c7cfedfdda507 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 11 Feb 2020 11:57:32 +0100 Subject: [PATCH 078/940] backend: putaway rollback to "start" endpoint --- shopfloor/services/single_pack_putaway.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 8b2e579950..cec72763d9 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -123,7 +123,7 @@ def start(self, barcode): existing_operation and existing_operation[0].picking_id.picking_type_id != picking_type ): - return self._response_for_forbidden_scan_pack(existing_operation) + return self._response_for_forbidden_start(existing_operation) elif existing_operation: return self._response_for_start_to_confirm(existing_operation, pack) From 6a4e10748eb68d3cb43eaad000638dadaedd78c7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 11 Feb 2020 15:52:15 +0100 Subject: [PATCH 079/940] Add skeleton for cluster picking services --- shopfloor/demo/shopfloor_menu_demo.xml | 6 ++++++ shopfloor/demo/shopfloor_process_demo.xml | 5 +++++ shopfloor/demo/stock_picking_type_demo.xml | 17 +++++++++++++++++ shopfloor/models/shopfloor_process.py | 3 ++- shopfloor/services/__init__.py | 1 + shopfloor/services/cluster_picking.py | 10 ++++++++++ shopfloor/tests/__init__.py | 2 ++ shopfloor/tests/test_cluster_picking.py | 17 +++++++++++++++++ 8 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 shopfloor/services/cluster_picking.py create mode 100644 shopfloor/tests/test_cluster_picking.py diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml index 6d0d511f79..f19f603dfb 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -12,4 +12,10 @@ + + Cluster Picking + 30 + + + diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml index 3f869cb457..f2aab9b212 100644 --- a/shopfloor/demo/shopfloor_process_demo.xml +++ b/shopfloor/demo/shopfloor_process_demo.xml @@ -10,4 +10,9 @@ single_pack_transfer + + Cluster Picking + cluster_picking + + diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml index a6a96d9d69..80792ca479 100644 --- a/shopfloor/demo/stock_picking_type_demo.xml +++ b/shopfloor/demo/stock_picking_type_demo.xml @@ -34,4 +34,21 @@ + + Cluster Picking + CPI + + + + + + + + + internal + + + + + diff --git a/shopfloor/models/shopfloor_process.py b/shopfloor/models/shopfloor_process.py index 751eecafe7..24890d6a59 100644 --- a/shopfloor/models/shopfloor_process.py +++ b/shopfloor/models/shopfloor_process.py @@ -14,7 +14,8 @@ class ShopfloorProcess(models.Model): def _selection_code(self): return [ - # these must match a REST service + # these must match a REST service's '_usage' ("single_pack_putaway", "Single Pack Put-away"), ("single_pack_transfer", "Single Pack Transfer"), + ("cluster_picking", "Cluster Picking"), ] diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 5eb564a6a6..73b5657920 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -4,5 +4,6 @@ from . import menu from . import pack from . import location +from . import cluster_picking from . import single_pack_putaway from . import single_pack_transfer diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py new file mode 100644 index 0000000000..8493e6299d --- /dev/null +++ b/shopfloor/services/cluster_picking.py @@ -0,0 +1,10 @@ +from odoo.addons.component.core import Component + + +class ClusterPicking(Component): + """Methods for the Cluster Picking Process""" + + _inherit = "base.shopfloor.service" + _name = "shopfloor.cluster.picking" + _usage = "cluster_picking" + _description = __doc__ diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 188a3788ab..b9fe5004f9 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -1 +1,3 @@ from . import test_single_pack_putaway +from . import test_single_pack_transfer +from . import test_cluster_picking diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py new file mode 100644 index 0000000000..dd53151e59 --- /dev/null +++ b/shopfloor/tests/test_cluster_picking.py @@ -0,0 +1,17 @@ +from .common import CommonCase + + +class ClusterPickingCase(CommonCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") + cls.process = cls.menu.process_id + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + cls.wh = cls.profile.warehouse_id + cls.picking_type = cls.process.picking_type_ids + + def setUp(self): + super().setUp() + with self.work_on_services(menu=self.menu, profile=self.profile) as work: + self.service = work.component(usage="cluster_picking") From 7518be5c17377e11ddd1e994b8991620e3a4fe64 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 11 Feb 2020 16:18:32 +0100 Subject: [PATCH 080/940] backend: use sub-dict for returning menu's process --- shopfloor/services/menu.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index 77670ff2e7..e62fa87ae2 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -67,9 +67,19 @@ def _record_return_schema(self): return { "id": {"coerce": to_int, "required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, - "process_code": {"type": "string", "nullable": False, "required": True}, - "process_id": {"coerce": to_int, "required": True, "type": "integer"}, + "process": { + "type": "dict", + "required": True, + "schema": { + "code": {"type": "string", "nullable": False, "required": True}, + "id": {"coerce": to_int, "required": True, "type": "integer"}, + }, + }, } def _convert_one_record(self, record): - return {"id": record.id, "name": record.name, "process_code": record.process_code, "process_id": record.process_id.id} + return { + "id": record.id, + "name": record.name, + "process": {"id": record.process_id.id, "code": record.process_code}, + } From 508e300cc5238ffc7beb1f761ce00f98ca912dfd Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 12 Feb 2020 12:29:36 +0100 Subject: [PATCH 081/940] Add picking batch service --- shopfloor/__manifest__.py | 1 + shopfloor/controllers/main.py | 6 +- shopfloor/services/__init__.py | 1 + shopfloor/services/picking_batch.py | 94 +++++++++++++++++++++++ shopfloor/tests/__init__.py | 1 + shopfloor/tests/common.py | 13 ++++ shopfloor/tests/test_picking_batch.py | 105 ++++++++++++++++++++++++++ 7 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 shopfloor/services/picking_batch.py create mode 100644 shopfloor/tests/test_picking_batch.py diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index a532100ae3..d2dbcaa11f 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -13,6 +13,7 @@ "application": True, "depends": [ "stock", + "stock_picking_batch", "base_rest", "auth_api_key", # https://github.com/OCA/stock-logistics-warehouse/pull/808 diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py index e2a7dd79b2..87ce6ef967 100644 --- a/shopfloor/controllers/main.py +++ b/shopfloor/controllers/main.py @@ -9,7 +9,7 @@ class ShopfloorController(main.RestController): _root_path = "/shopfloor/" _collection_name = "shopfloor.service" _default_auth = "api_key" - _non_process_services = ('app', 'menu', 'profile') + _non_process_services = ("app", "menu", "profile") def _get_component_context(self): """ @@ -21,8 +21,8 @@ def _get_component_context(self): res = super(ShopfloorController, self)._get_component_context() headers = request.httprequest.environ - res['menu'] = None - res['profile'] = None + res["menu"] = None + res["profile"] = None if self._is_process_enpoint(request.httprequest.path): res.update(self._get_process_context(headers, request.env)) return res diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 73b5657920..f3b34ed433 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -7,3 +7,4 @@ from . import cluster_picking from . import single_pack_putaway from . import single_pack_transfer +from . import picking_batch diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py new file mode 100644 index 0000000000..df580ba85f --- /dev/null +++ b/shopfloor/services/picking_batch.py @@ -0,0 +1,94 @@ +from odoo.osv import expression + +from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import Component + + +class PickingBatch(Component): + """Picking Batch services for the client application.""" + + _inherit = "base.shopfloor.service" + _name = "shopfloor.picking.batch" + _usage = "picking_batch" + _expose_model = "stock.picking.batch" + _description = __doc__ + + def _get_base_search_domain(self): + base_domain = super()._get_base_search_domain() + user = self.env.user + return expression.AND( + [ + base_domain, + [ + "|", + "&", + ("user_id", "=", False), + ("state", "=", "draft"), + "&", + ("user_id", "=", user.id), + ("state", "in", ("draft", "in_progress")), + ], + ] + ) + + def _search(self, name_fragment=None): + domain = self._get_base_search_domain() + if name_fragment: + domain.append(("name", "ilike", name_fragment)) + records = self.env[self._expose_model].search(domain, order="id asc") + records = records.filtered( + # Include done/cancel because we want to be able to work on the + # batch even if some pickings are done/canceled. They'll should be + # ignored later. + lambda batch: all( + picking.state in ("assigned", "done", "cancel") + for picking in batch.picking_ids + ) + ) + # TODO why already convert to json in the internal method? + return self._to_json(records) + + def search(self, name_fragment=None): + """List available stock picking batches for current user + + Show only picking batches where all the pickings are available. + """ + json_records = self._search(name_fragment=name_fragment) + return self._response(data={"size": len(json_records), "records": json_records}) + + def _validator_search(self): + return { + "name_fragment": {"type": "string", "nullable": True, "required": False} + } + + def _validator_return_search(self): + return self._response_schema( + { + "size": {"coerce": to_int, "required": True, "type": "integer"}, + "records": { + "type": "list", + "required": True, + "schema": {"type": "dict", "schema": self._record_return_schema}, + }, + } + ) + + @property + def _record_return_schema(self): + return { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "picking_count": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_count": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def _convert_one_record(self, record): + assigned_pickings = record.picking_ids.filtered( + lambda picking: picking.state == "assigned" + ) + return { + "id": record.id, + "name": record.name, + "picking_count": len(assigned_pickings), + "move_line_count": len(assigned_pickings.mapped("move_line_ids")), + } diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index b9fe5004f9..b9d6b8a20e 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -1,3 +1,4 @@ +from . import test_picking_batch from . import test_single_pack_putaway from . import test_single_pack_transfer from . import test_cluster_picking diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 8b58279f0d..52b4d05aa5 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -57,6 +57,7 @@ def setUpClassVars(cls): cls.stock_location = stock_location cls.dispatch_location = cls.env.ref("stock.location_dispatch_zone") cls.dispatch_location.barcode = "DISPATCH" + cls.packing_location = cls.env.ref("stock.location_pack_zone") cls.input_location = cls.env.ref("stock.stock_location_company") cls.shelf1 = cls.env.ref("stock.stock_location_components") cls.shelf2 = cls.env.ref("stock.stock_location_14") @@ -123,3 +124,15 @@ def assert_dict(self, current, expected): node_expected, "\n\nNode's specs:\n%s" % (pformat(node_original_expected)), ) + + def _update_qty_in_location(self, location, product, quantity): + self.env["stock.quant"]._update_available_quantity(product, location, quantity) + + def _fill_stock_for_pickings(self, pickings): + product_locations = {} + for move in self.all_batches.mapped("picking_ids.move_lines"): + key = (move.product_id, move.location_id) + product_locations.setdefault(key, 0) + product_locations[key] += move.product_qty + for (product, location), qty in product_locations.items(): + self._update_qty_in_location(location, product, qty) diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py new file mode 100644 index 0000000000..62e4f70a38 --- /dev/null +++ b/shopfloor/tests/test_picking_batch.py @@ -0,0 +1,105 @@ +from odoo.tests.common import Form + +from .common import CommonCase + + +class BatchPickingCase(CommonCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.product_a = cls.env["product.product"].create( + {"name": "Product A", "type": "product"} + ) + cls.product_b = cls.env["product.product"].create( + {"name": "Product B", "type": "product"} + ) + # which menu we pick should not matter for the batch picking api + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") + cls.process = cls.menu.process_id + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + cls.wh = cls.profile.warehouse_id + cls.picking_type = cls.process.picking_type_ids + cls.batch1 = cls._create_picking_batch(cls.product_a) + cls.batch2 = cls._create_picking_batch(cls.product_a) + cls.batch3 = cls._create_picking_batch(cls.product_a) + cls.batch4 = cls._create_picking_batch(cls.product_b) + cls.batch5 = cls._create_picking_batch(cls.product_b) + cls.batch6 = cls._create_picking_batch(cls.product_b) + cls.all_batches = ( + cls.batch1 + cls.batch2 + cls.batch3 + cls.batch4 + cls.batch5 + cls.batch6 + ) + + def setUp(self): + super().setUp() + with self.work_on_services(menu=self.menu, profile=self.profile) as work: + self.service = work.component(usage="picking_batch") + + @classmethod + def _create_picking_batch(cls, product): + picking_form = Form(cls.env["stock.picking"]) + picking_form.picking_type_id = cls.picking_type + picking_form.location_id = cls.stock_location + picking_form.location_dest_id = cls.packing_location + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = 1 + picking = picking_form.save() + picking.action_confirm() + picking.action_assign() + + batch_form = Form(cls.env["stock.picking.batch"]) + batch_form.picking_ids.add(picking) + return batch_form.save() + + def test_search_empty(self): + """No batch is available""" + # Simulate the client asking the list of picking batch + response = self.service.dispatch("search") + # none of the pickings are assigned, so we can't work on them + self.assert_response(response, data={"size": 0, "records": []}) + + def test_search(self): + """Return only draft batches with assigned pickings """ + pickings = self.all_batches.mapped("picking_ids") + self._fill_stock_for_pickings(pickings) + pickings.action_assign() + self.assertTrue(all(p.state == "assigned" for p in pickings)) + # we should not have done batches in list + self.batch5.state = "done" + # nor canceled batches + self.batch6.state = "cancel" + # we should not have batches in progress + self.batch4.user_id = self.env.ref("base.user_demo") + self.batch4.confirm_picking() + # unless it's assigned to our user + self.batch3.user_id = self.env.user + self.batch3.confirm_picking() + + # Simulate the client asking the list of picking batch + response = self.service.dispatch("search") + self.assert_response( + response, + data={ + "size": 3, + "records": [ + { + "id": self.batch1.id, + "name": self.batch1.name, + "picking_count": 1, + "move_line_count": 1, + }, + { + "id": self.batch2.id, + "name": self.batch2.name, + "picking_count": 1, + "move_line_count": 1, + }, + { + "id": self.batch3.id, + "name": self.batch3.name, + "picking_count": 1, + "move_line_count": 1, + }, + ], + }, + ) From 17021ddd3ff360a8b11e4cc1f59ea94c957c3d8d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 13 Feb 2020 11:42:18 +0100 Subject: [PATCH 082/940] Use components for validators The validator methods and schema in the service components make it quite had to read. Dedicated components for validators separate methods by concern and allow to remove the prefixes on methods. Needs: https://github.com/OCA/rest-framework/pull/58 --- shopfloor/services/__init__.py | 1 + shopfloor/services/app.py | 20 +++- shopfloor/services/location.py | 36 +++++-- shopfloor/services/menu.py | 54 ++++++---- shopfloor/services/pack.py | 30 ++++-- shopfloor/services/picking_batch.py | 46 +++++--- shopfloor/services/profile.py | 44 +++++--- shopfloor/services/service.py | 40 +++---- shopfloor/services/single_pack_putaway.py | 34 ++++-- shopfloor/services/single_pack_transfer.py | 118 ++++++++++++--------- shopfloor/services/validator.py | 45 ++++++++ 11 files changed, 315 insertions(+), 153 deletions(-) create mode 100644 shopfloor/services/validator.py diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index f3b34ed433..7a137fed0d 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -1,4 +1,5 @@ from . import service +from . import validator from . import app from . import profile from . import menu diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py index ed1bbf4880..aa70424129 100644 --- a/shopfloor/services/app.py +++ b/shopfloor/services/app.py @@ -14,10 +14,26 @@ def user_config(self): profiles = self.component("profile")._search() return self._response(data={"menus": menus, "profiles": profiles}) - def _validator_user_config(self): + +class ShopfloorAppValidator(Component): + """Validators for the Application endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.app.validator" + _usage = "app.validator" + + def user_config(self): return {} - def _validator_return_user_config(self): + +class ShopfloorAppValidatorResponse(Component): + """Validators for the Application endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.app.validator.response" + _usage = "app.validator.response" + + def user_config(self): menu_service = self.component("menu") profile_service = self.component("profile") return self._response_schema( diff --git a/shopfloor/services/location.py b/shopfloor/services/location.py index a789869120..7bd237f4bd 100644 --- a/shopfloor/services/location.py +++ b/shopfloor/services/location.py @@ -36,12 +36,36 @@ def _get_base_search_domain(self): # TODO add filter on warehouse of the current profile return super()._get_base_search_domain() - def _validator_search(self): + def _convert_one_record(self, record): + return { + "id": record.id, + "name": record.name, + "complete_name": record.complete_name, + "barcode": record.barcode or "", + } + + +class ShopfloorLocationValidator(Component): + """Validators for the Location endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.location.validator" + _usage = "location.validator" + + def search(self): return { "name_fragment": {"type": "string", "nullable": True, "required": False} } - def _validator_return_search(self): + +class ShopfloorLocationValidatorResponse(Component): + """Validators for the Location endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.location.validator.response" + _usage = "location.validator.response" + + def search(self): return self._response_schema( { "size": {"coerce": to_int, "required": True, "type": "integer"}, @@ -75,11 +99,3 @@ def _validator_return_search(self): }, } ) - - def _convert_one_record(self, record): - return { - "id": record.id, - "name": record.name, - "complete_name": record.complete_name, - "barcode": record.barcode or "", - } diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index e62fa87ae2..fd2a015b86 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -45,25 +45,36 @@ def search(self, name_fragment=None): json_records = self._search(name_fragment=name_fragment) return self._response(data={"size": len(json_records), "records": json_records}) - def _validator_search(self): + def _convert_one_record(self, record): + return { + "id": record.id, + "name": record.name, + "process": {"id": record.process_id.id, "code": record.process_code}, + } + + +class ShopfloorMenuValidator(Component): + """Validators for the Menu endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.menu.validator" + _usage = "menu.validator" + + def search(self): return { "name_fragment": {"type": "string", "nullable": True, "required": False} } - def _validator_return_search(self): - return self._response_schema( - { - "size": {"coerce": to_int, "required": True, "type": "integer"}, - "records": { - "type": "list", - "required": True, - "schema": {"type": "dict", "schema": self._record_return_schema}, - }, - } - ) + +class ShopfloorMenuValidatorResponse(Component): + """Validators for the Menu endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.menu.validator.response" + _usage = "menu.validator.response" @property - def _record_return_schema(self): + def _record_schema(self): return { "id": {"coerce": to_int, "required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, @@ -77,9 +88,14 @@ def _record_return_schema(self): }, } - def _convert_one_record(self, record): - return { - "id": record.id, - "name": record.name, - "process": {"id": record.process_id.id, "code": record.process_code}, - } + def return_search(self): + return self._response_schema( + { + "size": {"coerce": to_int, "required": True, "type": "integer"}, + "records": { + "type": "list", + "required": True, + "schema": {"type": "dict", "schema": self._record_schema}, + }, + } + ) diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py index 06117ef483..bf4d3ea2f3 100644 --- a/shopfloor/services/pack.py +++ b/shopfloor/services/pack.py @@ -16,12 +16,6 @@ def get_by_name(self, pack_name): package = search.package_from_scan(pack_name) return self._response(data=self._to_json(package)[:1]) - def _validator_get_by_name(self): - return {"pack_name": {"type": "string", "nullable": False, "required": True}} - - def _validator_return_get_by_name(self): - return self._response_schema(self._record_return_schema) - def _convert_one_record(self, record): return { "id": record.id, @@ -29,8 +23,27 @@ def _convert_one_record(self, record): "location": {"id": record.location_id.id, "name": record.location_id.name}, } + +class ShopfloorPackValidator(Component): + """Validators for the Pack endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.pack.validator" + _usage = "pack.validator" + + def get_by_name(self): + return {"pack_name": {"type": "string", "nullable": False, "required": True}} + + +class ShopfloorPackValidatorResponse(Component): + """Validators for the Pack endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.pack.validator.response" + _usage = "pack.validator.response" + @property - def _record_return_schema(self): + def _record_schema(self): return { "id": {"coerce": to_int, "required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, @@ -42,3 +55,6 @@ def _record_return_schema(self): }, }, } + + def get_by_name(self): + return self._response_schema(self._record_schema) diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py index df580ba85f..a4d2cfe2d8 100644 --- a/shopfloor/services/picking_batch.py +++ b/shopfloor/services/picking_batch.py @@ -56,39 +56,55 @@ def search(self, name_fragment=None): json_records = self._search(name_fragment=name_fragment) return self._response(data={"size": len(json_records), "records": json_records}) - def _validator_search(self): + def _convert_one_record(self, record): + assigned_pickings = record.picking_ids.filtered( + lambda picking: picking.state == "assigned" + ) + return { + "id": record.id, + "name": record.name, + "picking_count": len(assigned_pickings), + "move_line_count": len(assigned_pickings.mapped("move_line_ids")), + } + + +class ShopfloorPickingBatchValidator(Component): + """Validators for the Picking_Batch endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.picking.batch.validator" + _usage = "picking_batch.validator" + + def search(self): return { "name_fragment": {"type": "string", "nullable": True, "required": False} } - def _validator_return_search(self): + +class ShopfloorPickingBatchValidatorResponse(Component): + """Validators for the Picking_Batch endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.picking.batch.validator.response" + _usage = "picking_batch.validator.response" + + def search(self): return self._response_schema( { "size": {"coerce": to_int, "required": True, "type": "integer"}, "records": { "type": "list", "required": True, - "schema": {"type": "dict", "schema": self._record_return_schema}, + "schema": {"type": "dict", "schema": self._record_schema}, }, } ) @property - def _record_return_schema(self): + def _record_schema(self): return { "id": {"coerce": to_int, "required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, "picking_count": {"coerce": to_int, "required": True, "type": "integer"}, "move_line_count": {"coerce": to_int, "required": True, "type": "integer"}, } - - def _convert_one_record(self, record): - assigned_pickings = record.picking_ids.filtered( - lambda picking: picking.state == "assigned" - ) - return { - "id": record.id, - "name": record.name, - "picking_count": len(assigned_pickings), - "move_line_count": len(assigned_pickings.mapped("move_line_ids")), - } diff --git a/shopfloor/services/profile.py b/shopfloor/services/profile.py index 9f59ef0706..395971a73a 100644 --- a/shopfloor/services/profile.py +++ b/shopfloor/services/profile.py @@ -58,24 +58,50 @@ def _get_base_search_domain(self): ] ) - def _validator_search(self): + def _convert_one_record(self, record): + return { + "id": record.id, + "name": record.name, + "warehouse": { + "id": record.warehouse_id.id, + "name": record.warehouse_id.name, + }, + } + + +class ShopfloorProfileValidator(Component): + """Validators for the Profile endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.profile.validator" + _usage = "profile.validator" + + def search(self): return { "name_fragment": {"type": "string", "nullable": True, "required": False} } - def _validator_return_search(self): + +class ShopfloorProfileValidatorResponse(Component): + """Validators for the Profile endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.profile.validator.response" + _usage = "profile.validator.response" + + def search(self): return self._response_schema( { "size": {"coerce": to_int, "required": True, "type": "integer"}, "records": { "type": "list", - "schema": {"type": "dict", "schema": self._record_return_schema}, + "schema": {"type": "dict", "schema": self._record_schema}, }, } ) @property - def _record_return_schema(self): + def _record_schema(self): return { "id": {"coerce": to_int, "required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, @@ -87,13 +113,3 @@ def _record_return_schema(self): }, }, } - - def _convert_one_record(self, record): - return { - "id": record.id, - "name": record.name, - "warehouse": { - "id": record.warehouse_id.id, - "name": record.warehouse_id.name, - }, - } diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index f31cf118a8..44a3f336fa 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -48,6 +48,20 @@ def _to_json(self, records): res.append(self._convert_one_record(record)) return res + def _get_input_validator(self, method_name): + # override the method to get the validator in a component + # instead of a method, to keep things apart + validator_component = self.component(usage="%s.validator" % self._usage) + return validator_component._get_validator(method_name) + + def _get_output_validator(self, method_name): + # override the method to get the validator in a component + # instead of a method, to keep things apart + validator_component = self.component( + usage="%s.validator.response" % self._usage + ) + return validator_component._get_validator(method_name) + def _response(self, data=None, state=None, message=None): """Base "envelope" for the responses @@ -68,32 +82,6 @@ def _response(self, data=None, state=None, message=None): response["message"] = message return response - def _response_schema(self, data_schema=None): - """Schema for the return validator - - Must be used for the schema of all responses. - The "data" part can be customized and is optional, - it must be a dictionary. - """ - if not data_schema: - data_schema = {} - return { - "data": {"type": "dict", "required": False, "schema": data_schema}, - "state": {"type": "string", "required": False}, - "message": { - "type": "dict", - "required": False, - "schema": { - "message_type": { - "type": "string", - "required": True, - "allowed": ["info", "warning", "error"], - }, - "message": {"type": "string", "required": True}, - }, - }, - } - def _get_openapi_default_parameters(self): defaults = super()._get_openapi_default_parameters() demo_api_key = self.env.ref( diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index cec72763d9..ce74e746a6 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -248,28 +248,44 @@ def cancel(self, package_level_id): package.move_line_ids.move_id._action_cancel() return self._response_for_confirm_move_cancellation() - def _validator_cancel(self): + +class SinglePackPutawayValidator(Component): + """Validators for Single Pack Putaway methods""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.single.pack.putaway.validator" + _usage = "single_pack_putaway.validator" + + def start(self): + return {"barcode": {"type": "string", "nullable": False, "required": True}} + + def cancel(self): return { "package_level_id": {"coerce": to_int, "required": True, "type": "integer"} } - def _validator_return_cancel(self): - return self._response_schema() - - def _validator_validate(self): + def validate(self): return { "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, "location_barcode": {"type": "string", "nullable": False, "required": True}, "confirmation": {"type": "boolean", "nullable": True, "required": False}, } - def _validator_return_validate(self): + +class SinglePackPutawayValidatorResponse(Component): + """Validators for Single Pack Putaway methods responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.single.pack.putaway.validator.response" + _usage = "single_pack_putaway.validator.response" + + def cancel(self): return self._response_schema() - def _validator_start(self): - return {"barcode": {"type": "string", "nullable": False, "required": True}} + def validate(self): + return self._response_schema() - def _validator_return_start(self): + def start(self): return self._response_schema( { "id": {"coerce": to_int, "required": True, "type": "integer"}, diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 1d44a60d17..832321b17c 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -123,45 +123,6 @@ def start(self, barcode): existing_operations[0].package_level_id.is_done = True return self._response_for_start_success(existing_operations[0], pack) - def _validator_start(self): - return {"barcode": {"type": "string", "nullable": False, "required": True}} - - def _validator_return_start(self): - return self._response_schema( - { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "location_src": { - "type": "dict", - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - "location_dst": { - "type": "dict", - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - "product": { - "type": "dict", - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - "picking": { - "type": "dict", - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - } - ) - def _response_for_package_level_not_found(self): message = self.actions_for("message") return self._response(state="start", message=message.operation_not_found()) @@ -230,16 +191,6 @@ def validate(self, package_level_id, location_barcode, confirmation=False): last = move.picking_id.completion_info == "next_picking_ready" return self._response_for_validate_success(last=last) - def _validator_validate(self): - return { - "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, - "location_barcode": {"type": "string", "nullable": False, "required": True}, - "confirmation": {"type": "boolean", "required": False}, - } - - def _validator_return_validate(self): - return self._response_schema() - def cancel(self, package_level_id): package = self.env["stock.package_level"].browse(package_level_id) if not package.exists(): @@ -262,10 +213,75 @@ def _response_for_confirm_cancel(self): state="start", message=message.confirm_canceled_scan_next_pack() ) - def _validator_cancel(self): + +class SinglePackTransferValidator(Component): + """Validators for Single Pack Transfer methods""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.single.pack.transfer.validator" + _usage = "single_pack_transfer.validator" + + def start(self): + return {"barcode": {"type": "string", "nullable": False, "required": True}} + + def cancel(self): return { "package_level_id": {"coerce": to_int, "required": True, "type": "integer"} } - def _validator_return_cancel(self): + def validate(self): + return { + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + "location_barcode": {"type": "string", "nullable": False, "required": True}, + "confirmation": {"type": "boolean", "required": False}, + } + + +class SinglePackTransferValidatorResponse(Component): + """Validators for Single Pack Transfer methods responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.single.pack.transfer.validator.response" + _usage = "single_pack_transfer.validator.response" + + def start(self): + return self._response_schema( + { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "location_src": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + "location_dst": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + "product": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + "picking": { + "type": "dict", + "schema": { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + } + ) + + def cancel(self): + return self._response_schema() + + def validate(self): return self._response_schema() diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py new file mode 100644 index 0000000000..0ecee638b7 --- /dev/null +++ b/shopfloor/services/validator.py @@ -0,0 +1,45 @@ +from odoo.addons.component.core import AbstractComponent + + +class BaseShopfloorValidator(AbstractComponent): + """Base class for Validators""" + + _inherit = "base.rest.service" + _name = "base.shopfloor.validator" + _collection = "shopfloor.service" + _is_rest_service_component = False + + +class BaseShopfloorValidatorResponse(AbstractComponent): + """Base class for Validator for Responses""" + + _inherit = "base.rest.service" + _name = "base.shopfloor.validator.response" + _collection = "shopfloor.service" + _is_rest_service_component = False + + def _response_schema(self, data_schema=None): + """Schema for the return validator + + Must be used for the schema of all responses. + The "data" part can be customized and is optional, + it must be a dictionary. + """ + if not data_schema: + data_schema = {} + return { + "data": {"type": "dict", "required": False, "schema": data_schema}, + "state": {"type": "string", "required": False}, + "message": { + "type": "dict", + "required": False, + "schema": { + "message_type": { + "type": "string", + "required": True, + "allowed": ["info", "warning", "error"], + }, + "message": {"type": "string", "required": True}, + }, + }, + } From 0b9c2c1b84c0ac49e5dfe1cf4aacfe9c48061cd4 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 13 Feb 2020 11:48:05 +0100 Subject: [PATCH 083/940] Add schemas / validators for cluster picking methods --- shopfloor/services/cluster_picking.py | 556 ++++++++++++++++++++++++ shopfloor/tests/test_cluster_picking.py | 33 ++ 2 files changed, 589 insertions(+) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 8493e6299d..42211235fa 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -1,3 +1,4 @@ +from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component @@ -8,3 +9,558 @@ class ClusterPicking(Component): _name = "shopfloor.cluster.picking" _usage = "cluster_picking" _description = __doc__ + + def find_batch(self): + """Find a picking batch to work on and start it + + Usually the starting point of the process. + """ + return self._response() + + def select(self, batch_id): + """Manually select a picking batch + + The client application can use the service /picking_batch/search + to get the list of candidate batches. + """ + return self._response() + + def unassign(self, batch_id): + """Unassign and reset to draft a started picking batch""" + return self._response(state="start") + + def scan_line(self, move_line_id, barcode): + """Scan a location, a pack, a product or a lots + + To validate the operator takes the expected pack or product. + """ + return self._response() + + def scan_destination_pack(self, move_line_id, barcode): + """Scan the destination pack (bin) for a move line + + It will change the destination of the move line + """ + return self._response() + + def prepare_unload(self, picking_batch_id): + """Scan the destination pack (bin) for a move line + + It will change the destination of the move line + """ + return self._response() + + def is_zero(self, move_line_id, zero): + """Confirm or not if the source location of a move has zero qty""" + return self._response() + + def skip_line(self, move_line_id): + """Skip a line. The line will be processed at the end.""" + return self._response() + + def stock_issue(self, move_line_id): + """Declare a stock issue for a line""" + return self._response() + + def change_pack_lot(self, move_line_id, barcode): + """Change the expected pack or the lot for a line""" + return self._response() + + def set_destination_all(self, picking_batch_id, barcode, confirmation=False): + """Set the destination for all the lines of the batch""" + return self._response() + + def unload_split(self, picking_batch_id, barcode, confirmation=False): + """Set the destination for all the lines of the batch""" + return self._response() + + def unload_scan_pack(self, move_line_id, barcode): + """Check that the operator scans the correct pack (bin)""" + return self._response() + + def unload_scan_destination(self, move_line_id, barcode, confirmation=False): + """Check that the operator scans the correct pack (bin)""" + return self._response() + + +class ShopfloorClusterPickingValidator(Component): + """Validators for the Cluster Picking endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.cluster_picking.validator" + _usage = "cluster_picking.validator" + + def find_batch(self): + return {} + + def select(self): + return {"batch_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def unassign(self): + return {"batch_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def scan_line(self): + return { + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def scan_destination_pack(self): + return { + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def prepare_unload(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"} + } + + def is_zero(self): + return { + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "zero": {"coerce": to_bool, "required": True, "type": "boolean"}, + } + + def skip_line(self): + return {"move_line_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def stock_issue(self): + return {"move_line_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def change_pack_lot(self): + return { + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def set_destination_all(self): + return { + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, + } + + def unload_split(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"} + } + + def unload_scan_pack(self): + return { + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def unload_scan_destination(self): + return { + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, + } + + +class ShopfloorClusterPickingValidatorResponse(Component): + """Validators for the Cluster Picking endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.cluster_picking.validator.response" + _usage = "cluster_picking.validator.response" + + def find_batch(self): + return self._response_schema( + { + "confirm_start": { + "type": "dict", + "required": True, + "schema": self._schema_for_batch_details, + }, + "start_line": { + "type": "dict", + "required": True, + "schema": self._schema_for_single_line_details, + }, + "start": {"type": "dict", "required": False}, + } + ) + + def select(self): + return self._response_schema( + { + "manual_selection": {"type": "dict", "required": False}, + "confirm_start": { + "type": "dict", + "required": True, + "schema": self._schema_for_single_line_details, + }, + "start_line": { + "type": "dict", + "required": True, + "schema": self._schema_for_single_line_details, + }, + } + ) + + def unassign(self): + return self._response_schema({"start": {"type": "dict", "required": False}}) + + def scan_line(self): + return self._response_schema( + { + "start_line": { + "type": "dict", + "required": True, + "schema": self._schema_for_single_line_details, + }, + "scan_destination": { + "type": "dict", + "required": True, + "schema": self._schema_for_single_line_details, + }, + } + ) + + def scan_destination_pack(self): + return self._response_schema( + # when we still have lines to process + { + "start_line": { + "type": "dict", + "required": False, + "schema": self._schema_for_single_line_details, + }, + "zero_check": { + "type": "dict", + "required": False, + "schema": self._schema_for_zero_check, + }, + # when all lines have been processed and have same + # destination + "unload_all": { + "type": "dict", + "required": False, + "schema": self._schema_for_unload_all, + }, + # when all lines have been processed and have different + # destinations + "unload_confirm_pack": { + "type": "dict", + "required": False, + "schema": self._schema_for_unload_single, + }, + } + ) + + def prepare_unload(self): + return self._response_schema( + { + # when all lines have been processed and have same + # destination + "unload_all": { + "type": "dict", + "required": False, + "schema": self._schema_for_unload_all, + }, + # when all lines have been processed and have different + # destinations + "unload_confirm_pack": { + "type": "dict", + "required": False, + "schema": self._schema_for_unload_single, + }, + } + ) + + def is_zero(self): + return self._response_schema( + { + # when we still have lines to process + "start_line": { + "type": "dict", + "required": False, + "schema": self._schema_for_single_line_details, + }, + # when all lines have been processed and have same + # destination + "unload_all": { + "type": "dict", + "required": False, + "schema": self._schema_for_unload_all, + }, + # when all lines have been processed and have different + # destinations + "unload_confirm_pack": { + "type": "dict", + "required": False, + "schema": self._schema_for_unload_single, + }, + } + ) + + def skip_line(self): + return self._response_schema( + { + "start_line": { + "type": "dict", + "required": True, + "schema": self._schema_for_single_line_details, + } + } + ) + + def stock_issue(self): + return self._response_schema( + { + # when we still have lines to process + "start_line": { + "type": "dict", + "required": False, + "schema": self._schema_for_single_line_details, + }, + # when all lines have been processed and have same + # destination + "unload_all": { + "type": "dict", + "required": False, + "schema": self._schema_for_unload_all, + }, + # when all lines have been processed and have different + # destinations + "unload_confirm_pack": { + "type": "dict", + "required": False, + "schema": self._schema_for_unload_single, + }, + } + ) + + def change_pack_lot(self): + return self._response_schema( + { + "scan_destination": { + "type": "dict", + "required": True, + "schema": self._schema_for_single_line_details, + } + } + ) + + def set_destination_all(self): + return self._response_schema( + { + # if the batch still contain lines + "start_line": { + "type": "dict", + "required": False, + "schema": self._schema_for_single_line_details, + }, + "confirm_unload_all": { + "type": "dict", + "required": False, + "schema": self._schema_for_unload_all, + }, + "start": {"type": "dict", "required": False}, + } + ) + + def unload_split(self): + return self._response_schema( + { + "unload_confirm_pack": { + "type": "dict", + "required": False, + "schema": self._schema_for_unload_single, + } + } + ) + + def unload_scan_pack(self): + return self._response_schema( + { + "unload_confirm_pack": { + "type": "dict", + "required": False, + "schema": self._schema_for_unload_single, + }, + "unload_set_destination": { + "type": "dict", + "required": False, + "schema": self._schema_for_unload_single, + }, + } + ) + + def unload_scan_destination(self): + return self._response_schema( + { + "unload_confirm_pack": { + "type": "dict", + "required": False, + "schema": self._schema_for_unload_single, + }, + "confirm_unload_set_destination": { + "type": "dict", + "required": False, + "schema": self._schema_for_unload_single, + }, + "show_completion_info": { + "type": "dict", + "required": False, + "schema": self._schema_for_unload_single, + }, + "start": {"type": "dict", "required": False}, + "start_line": { + "type": "dict", + "required": True, + "schema": self._schema_for_single_line_details, + }, + } + ) + + @property + def _schema_for_batch_details(self): + return { + # id is a stock.picking.batch + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "weight": {"type": "float", "nullable": False, "required": True}, + "pickings": { + "type": "list", + "required": True, + "schema": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "move_line_count": {"required": True, "type": "integer"}, + "origin": { + "type": "string", + "nullable": False, + "required": True, + }, + "partner": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, + }, + }, + }, + }, + }, + } + + @property + def _schema_for_single_line_details(self): + return { + # id is a stock.move.line + "id": {"required": True, "type": "integer"}, + "picking": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "origin": {"type": "string", "nullable": False, "required": True}, + "note": {"type": "string", "nullable": False, "required": True}, + }, + }, + "batch": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + "quantity": {"type": "float", "required": True}, + "product": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "ref": {"type": "string", "nullable": False, "required": True}, + "qty_available": { + "type": "float", + "nullable": False, + "required": True, + }, + }, + }, + "lot": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "ref": {"type": "string", "nullable": False, "required": True}, + }, + }, + # TODO share parts of the schema? + "location_src": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + "location_dst": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + "pack": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + } + + @property + def _schema_for_unload_all(self): + return { + # stock.batch.picking + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "location_dst": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + } + + @property + def _schema_for_unload_single(self): + return { + # stock.move.line + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "location_dst": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + } + + @property + def _schema_for_zero_check(self): + return { + # stock.move.line + "id": {"required": True, "type": "integer"}, + "location_src": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + } diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index dd53151e59..6157f8e552 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -1,3 +1,5 @@ +from odoo.tests.common import Form + from .common import CommonCase @@ -5,13 +7,44 @@ class ClusterPickingCase(CommonCase): @classmethod def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) + cls.product_a = cls.env["product.product"].create( + {"name": "Product A", "type": "product"} + ) + cls.product_b = cls.env["product.product"].create( + {"name": "Product B", "type": "product"} + ) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id cls.picking_type = cls.process.picking_type_ids + cls.main_batch = cls._create_batch_picking(cls.product_a) def setUp(self): super().setUp() with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="cluster_picking") + + @classmethod + def _create_batch_picking(cls, product): + picking_form = Form(cls.env["stock.picking"]) + picking_form.picking_type_id = cls.picking_type + picking_form.location_id = cls.stock_location + picking_form.location_dest_id = cls.packing_location + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = 1 + picking = picking_form.save() + picking.action_confirm() + picking.action_assign() + + batch_form = Form(cls.env["stock.picking.batch"]) + batch_form.picking_ids.add(picking) + return batch_form.save() + + # def test_list_manual_batch(self): + # """Test getting list of picking batches the user can work on""" + # # Simulate the client asking the list of picking batch it can + # # select after the user clicked on the "Manual Selection" button + # response = self.service.dispatch("/list_manual_selection") + # import pdb; pdb.set_trace() From ec03f9b336b2ff1edb5cad84298a3810930eafb4 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 13 Feb 2020 13:34:07 +0100 Subject: [PATCH 084/940] Rename attribute 'state' to 'next_state' in REST API --- shopfloor/services/cluster_picking.py | 2 +- shopfloor/services/service.py | 9 +++-- shopfloor/services/single_pack_putaway.py | 32 ++++++++-------- shopfloor/services/single_pack_transfer.py | 40 +++++++++++--------- shopfloor/services/validator.py | 2 +- shopfloor/tests/common.py | 8 ++-- shopfloor/tests/test_single_pack_putaway.py | 30 +++++++-------- shopfloor/tests/test_single_pack_transfer.py | 38 +++++++++---------- 8 files changed, 84 insertions(+), 77 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 42211235fa..56aa024206 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -27,7 +27,7 @@ def select(self, batch_id): def unassign(self, batch_id): """Unassign and reset to draft a started picking batch""" - return self._response(state="start") + return self._response(next_state="start") def scan_line(self, move_line_id, barcode): """Scan a location, a pack, a product or a lots diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 44a3f336fa..e7776129f3 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -62,13 +62,13 @@ def _get_output_validator(self, method_name): ) return validator_component._get_validator(method_name) - def _response(self, data=None, state=None, message=None): + def _response(self, data=None, next_state=None, message=None): """Base "envelope" for the responses All the keys are optional. :param data: dictionary of values - :param state: string describing the next state that the client + :param next_state: string describing the next state that the client application must reach :param message: dictionary for the message to show in the client application (see ``_response_schema`` for the keys) @@ -76,8 +76,9 @@ def _response(self, data=None, state=None, message=None): response = {} if data: response["data"] = data - if state: - response["state"] = state + if next_state: + response["next_state"] = next_state + # TODO ensure we have at least an empty dict for the next state if message: response["message"] = message return response diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index ce74e746a6..96658a65bc 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -16,28 +16,30 @@ class SinglePackPutaway(Component): # come from the same state def _response_for_no_picking_type(self): message = self.actions_for("message") - return self._response(state="start", message=message.no_picking_type()) + return self._response(next_state="start", message=message.no_picking_type()) def _response_for_several_picking_types(self): message = self.actions_for("message") - return self._response(state="start", message=message.several_picking_types()) + return self._response( + next_state="start", message=message.several_picking_types() + ) def _response_for_package_not_found(self, barcode): message = self.actions_for("message") return self._response( - state="start", message=message.package_not_found_for_barcode(barcode) + next_state="start", message=message.package_not_found_for_barcode(barcode) ) def _response_for_forbidden_package(self, barcode, picking_type): message = self.actions_for("message") return self._response( - state="start", + next_state="start", message=message.package_not_allowed_in_src_location(barcode, picking_type), ) def _response_for_forbidden_start(self, existing_operations): return self._response( - state="start", + next_state="start", message={ "message_type": "error", "message": _( @@ -69,14 +71,14 @@ def _response_for_start_to_confirm(self, move_line, pack): message = self.actions_for("message") return self._response( data=self._data_after_package_scanned(move_line, pack), - state="confirm_start", + next_state="confirm_start", message=message.already_running_ask_confirmation(), ) def _response_for_start_success(self, move_line, pack): message = self.actions_for("message") return self._response( - state="scan_location", + next_state="scan_location", message=message.scan_destination(), data=self._data_after_package_scanned(move_line, pack), ) @@ -165,35 +167,35 @@ def _prepare_package_level(self, pack, move): def _response_for_package_level_not_found(self): message = self.actions_for("message") - return self._response(state="start", message=message.operation_not_found()) + return self._response(next_state="start", message=message.operation_not_found()) def _response_for_move_canceled_elsewhere(self): message = self.actions_for("message") return self._response( - state="start", message=message.operation_has_been_canceled_elsewhere() + next_state="start", message=message.operation_has_been_canceled_elsewhere() ) def _response_for_location_not_found(self): message = self.actions_for("message") return self._response( - state="scan_location", message=message.no_location_found() + next_state="scan_location", message=message.no_location_found() ) def _response_for_forbidden_location(self): message = self.actions_for("message") return self._response( - state="scan_location", message=message.dest_location_not_allowed() + next_state="scan_location", message=message.dest_location_not_allowed() ) def _response_for_location_need_confirm(self): message = self.actions_for("message") return self._response( - state="confirm_location", message=message.need_confirmation() + next_state="confirm_location", message=message.need_confirmation() ) def _response_for_validate_success(self): message = self.actions_for("message") - return self._response(state="start", message=message.confirm_pack_moved()) + return self._response(next_state="start", message=message.confirm_pack_moved()) def validate(self, package_level_id, location_barcode, confirmation=False): """Validate the transfer""" @@ -228,12 +230,12 @@ def validate(self, package_level_id, location_barcode, confirmation=False): def _response_for_move_already_processed(self): message = self.actions_for("message") - return self._response(state="start", message=message.already_done()) + return self._response(next_state="start", message=message.already_done()) def _response_for_confirm_move_cancellation(self): message = self.actions_for("message") return self._response( - state="start", message=message.confirm_canceled_scan_next_pack() + next_state="start", message=message.confirm_canceled_scan_next_pack() ) def cancel(self, package_level_id): diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 832321b17c..65e465decd 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -17,36 +17,38 @@ class SinglePackTransfer(Component): def _response_for_empty_location(self, location): message = self.actions_for("message") return self._response( - state="start", message=message.no_pack_in_location(location) + next_state="start", message=message.no_pack_in_location(location) ) def _response_for_several_packages(self, location): message = self.actions_for("message") return self._response( - state="start", message=message.several_packs_in_location(location) + next_state="start", message=message.several_packs_in_location(location) ) def _response_for_package_not_found(self, barcode): message = self.actions_for("message") return self._response( - state="start", message=message.package_not_found_for_barcode(barcode) + next_state="start", message=message.package_not_found_for_barcode(barcode) ) def _response_for_forbidden_package(self, barcode, picking_type): message = self.actions_for("message") return self._response( - state="start", + next_state="start", message=message.package_not_allowed_in_src_location(barcode, picking_type), ) def _response_for_several_picking_types(self): message = self.actions_for("message") - return self._response(state="start", message=message.several_picking_types()) + return self._response( + next_state="start", message=message.several_picking_types() + ) def _response_for_operation_not_found(self, pack): message = self.actions_for("message") return self._response( - state="start", message=message.no_pending_operation_for_pack(pack) + next_state="start", message=message.no_pending_operation_for_pack(pack) ) def _data_after_package_scanned(self, move_line, pack): @@ -66,7 +68,7 @@ def _data_after_package_scanned(self, move_line, pack): def _response_for_start_to_confirm(self, move_line, pack): message = self.actions_for("message") return self._response( - state="confirm_start", + next_state="confirm_start", message=message.already_running_ask_confirmation(), data=self._data_after_package_scanned(move_line, pack), ) @@ -74,7 +76,7 @@ def _response_for_start_to_confirm(self, move_line, pack): def _response_for_start_success(self, move_line, pack): message = self.actions_for("message") return self._response( - state="scan_location", + next_state="scan_location", message=message.scan_destination(), data=self._data_after_package_scanned(move_line, pack), ) @@ -125,38 +127,40 @@ def start(self, barcode): def _response_for_package_level_not_found(self): message = self.actions_for("message") - return self._response(state="start", message=message.operation_not_found()) + return self._response(next_state="start", message=message.operation_not_found()) def _response_for_move_canceled_elsewhere(self): message = self.actions_for("message") return self._response( - state="start", message=message.operation_has_been_canceled_elsewhere() + next_state="start", message=message.operation_has_been_canceled_elsewhere() ) def _response_for_location_not_found(self): message = self.actions_for("message") return self._response( - state="scan_location", message=message.no_location_found() + next_state="scan_location", message=message.no_location_found() ) def _response_for_forbidden_location(self): message = self.actions_for("message") return self._response( - state="scan_location", message=message.dest_location_not_allowed() + next_state="scan_location", message=message.dest_location_not_allowed() ) def _response_for_location_need_confirm(self): message = self.actions_for("message") return self._response( - state="confirm_location", message=message.need_confirmation() + next_state="confirm_location", message=message.need_confirmation() ) def _response_for_validate_success(self, last=False): message = self.actions_for("message") - state = "start" + next_state = "start" if last: - state = "show_completion_info" - return self._response(state=state, message=message.confirm_pack_moved()) + next_state = "show_completion_info" + return self._response( + next_state=next_state, message=message.confirm_pack_moved() + ) def validate(self, package_level_id, location_barcode, confirmation=False): """Validate the transfer""" @@ -205,12 +209,12 @@ def cancel(self, package_level_id): def _response_for_move_already_processed(self): message = self.actions_for("message") - return self._response(state="start", message=message.already_done()) + return self._response(next_state="start", message=message.already_done()) def _response_for_confirm_cancel(self): message = self.actions_for("message") return self._response( - state="start", message=message.confirm_canceled_scan_next_pack() + next_state="start", message=message.confirm_canceled_scan_next_pack() ) diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py index 0ecee638b7..48d8d44e96 100644 --- a/shopfloor/services/validator.py +++ b/shopfloor/services/validator.py @@ -29,7 +29,7 @@ def _response_schema(self, data_schema=None): data_schema = {} return { "data": {"type": "dict", "required": False, "schema": data_schema}, - "state": {"type": "string", "required": False}, + "next_state": {"type": "string", "required": False}, "message": { "type": "dict", "required": False, diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 52b4d05aa5..45bd0c4cf3 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -62,7 +62,7 @@ def setUpClassVars(cls): cls.shelf1 = cls.env.ref("stock.stock_location_components") cls.shelf2 = cls.env.ref("stock.stock_location_14") - def assert_response(self, response, state=None, message=None, data=None): + def assert_response(self, response, next_state=None, message=None, data=None): """Assert a response from the webservice The data and message dictionaries are checked using @@ -70,8 +70,8 @@ def assert_response(self, response, state=None, message=None, data=None): value. """ expected = {} - if state: - expected["state"] = state + if next_state: + expected["next_state"] = next_state if message: expected["message"] = message if data: @@ -95,7 +95,7 @@ def assert_dict(self, current, expected): "message_type": self.ANY, "message": self.ANY, }, - "state": self.ANY, + "next_state": self.ANY, } Note: if ``self.ANY`` is used, the key must exist in the dictionary diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 2983419727..8a0cada93a 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -75,7 +75,7 @@ def test_start(self): ) self.assert_response( response, - state="scan_location", + next_state="scan_location", message={ "message_type": "info", "message": "Scan the destination location", @@ -108,7 +108,7 @@ def test_start_no_package_for_barcode(self): response = self.service.dispatch("start", params=params) self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "error", "message": "The package NOTHING_SHOULD_EXIST_WITH: 👀 doesn't exist", @@ -134,7 +134,7 @@ def test_start_package_not_in_src_location(self): response = self.service.dispatch("start", params=params) self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "error", "message": "You cannot work on a package (%s) outside of location: %s" @@ -175,7 +175,7 @@ def test_start_move_in_different_picking_type(self): response = self.service.dispatch("start", params=params) self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "error", "message": "An operation exists in Delivery Orders %s. You cannot" @@ -208,7 +208,7 @@ def test_start_move_already_exist(self): response = self.service.dispatch("start", params=params) self.assert_response( response, - state="confirm_start", + next_state="confirm_start", message={ "message_type": "warning", "message": "Operation's already running." @@ -274,7 +274,7 @@ def test_validate(self): self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "info", "message": "The pack has been moved, you can scan a new pack.", @@ -304,7 +304,7 @@ def test_validate_not_found(self): self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "error", "message": "This operation does not exist anymore.", @@ -336,7 +336,7 @@ def test_validate_location_not_found(self): self.assert_response( response, - state="scan_location", + next_state="scan_location", message={ "message_type": "error", "message": "No location found for this barcode.", @@ -372,7 +372,7 @@ def test_validate_location_forbidden(self): self.assert_response( response, - state="scan_location", + next_state="scan_location", message={"message_type": "error", "message": "You cannot place it here"}, ) @@ -407,7 +407,7 @@ def test_validate_location_to_confirm(self): self.assert_response( response, - state="confirm_location", + next_state="confirm_location", message={"message_type": "warning", "message": "Are you sure?"}, ) @@ -448,7 +448,7 @@ def test_validate_location_with_confirm(self): self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "info", "message": "The pack has been moved, you can scan a new pack.", @@ -495,7 +495,7 @@ def test_cancel(self): self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "info", "message": "Canceled, you can scan a new pack.", @@ -537,7 +537,7 @@ def test_cancel_already_canceled(self): self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "info", "message": "Canceled, you can scan a new pack.", @@ -576,7 +576,7 @@ def test_cancel_already_done(self): self.assert_response( response, - state="start", + next_state="start", message={"message_type": "info", "message": "Operation already processed."}, ) @@ -590,7 +590,7 @@ def test_cancel_not_found(self): response = self.service.dispatch("cancel", params={"package_level_id": -1}) self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "error", "message": "This operation does not exist anymore.", diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index dff4999c3d..fd02731953 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -92,7 +92,7 @@ def test_start(self): self.assertTrue(package_level.is_done) self.assert_response( response, - state="scan_location", + next_state="scan_location", message={ "message_type": "info", "message": "Scan the destination location", @@ -130,7 +130,7 @@ def test_start_no_operation(self): self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "error", "message": "No pending operation for package {}.".format( @@ -154,7 +154,7 @@ def test_start_barcode_not_known(self): response = self.service.dispatch("start", params=params) self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "error", "message": "The package THIS_BARCODE_DOES_NOT_EXIST" " doesn't exist", @@ -188,7 +188,7 @@ def test_start_pack_from_location(self): # screen, so it found the pack. The details are already # checked in the test_start test. response, - state="scan_location", + next_state="scan_location", message=self.ANY, data=self.ANY, ) @@ -212,7 +212,7 @@ def test_start_pack_from_location_empty(self): response = self.service.dispatch("start", params=params) self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "error", "message": "Location %s doesn't contain any package." @@ -250,7 +250,7 @@ def test_start_pack_from_location_several_packs(self): response = self.service.dispatch("start", params=params) self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "error", "message": "Several packages found in %s, please scan a package." @@ -276,7 +276,7 @@ def test_start_pack_outside_of_location(self): response = self.service.dispatch("start", params=params) self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "error", "message": "You cannot work on a package (%s) outside of location: %s" @@ -315,7 +315,7 @@ def test_start_already_started(self): self.assert_response( response, - state="confirm_start", + next_state="confirm_start", message={ "message_type": "warning", "message": "Operation's already running." @@ -359,7 +359,7 @@ def test_validate(self): self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "info", "message": "The pack has been moved, you can scan a new pack.", @@ -414,7 +414,7 @@ def test_validate_completion_info(self): self.assert_response( response, - state="show_completion_info", + next_state="show_completion_info", message={ "message_type": "info", "message": "The pack has been moved, you can scan a new pack.", @@ -435,7 +435,7 @@ def test_validate_not_found(self): self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "error", "message": "This operation does not exist anymore.", @@ -467,7 +467,7 @@ def test_validate_location_not_found(self): self.assert_response( response, - state="scan_location", + next_state="scan_location", message={ "message_type": "error", "message": "No location found for this barcode.", @@ -503,7 +503,7 @@ def test_validate_location_forbidden(self): self.assert_response( response, - state="scan_location", + next_state="scan_location", message={"message_type": "error", "message": "You cannot place it here"}, ) @@ -538,7 +538,7 @@ def test_validate_location_to_confirm(self): self.assert_response( response, - state="confirm_location", + next_state="confirm_location", message={"message_type": "warning", "message": "Are you sure?"}, ) @@ -579,7 +579,7 @@ def test_validate_location_with_confirm(self): self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "info", "message": "The pack has been moved, you can scan a new pack.", @@ -625,7 +625,7 @@ def test_cancel(self): self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "info", "message": "Canceled, you can scan a new pack.", @@ -667,7 +667,7 @@ def test_cancel_already_canceled(self): self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "info", "message": "Canceled, you can scan a new pack.", @@ -706,7 +706,7 @@ def test_cancel_already_done(self): self.assert_response( response, - state="start", + next_state="start", message={"message_type": "info", "message": "Operation already processed."}, ) @@ -720,7 +720,7 @@ def test_cancel_not_found(self): response = self.service.dispatch("cancel", params={"package_level_id": -1}) self.assert_response( response, - state="start", + next_state="start", message={ "message_type": "error", "message": "This operation does not exist anymore.", From cbd1aba01747845fca6f41fb9614c6f887267f35 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 13 Feb 2020 16:20:38 +0100 Subject: [PATCH 085/940] Rework how responses include data needed for the next state The 'validator.response' components have to define a `_states()` method that gives the match between a state and the schema a state requires. Validators for methods which can make the client transition must declare the list of possible next states using `next_states=["state_a", "state_b"]` (which must exists in the `_states()` method). When an endpoint returns data for a state, the data will now be enclosed in a key with the same name as the state, this is in order to support polymorphism in schemas (an endpoint being able to return different data depending on the next state). General idea of a schema for a method that changes state (data may vary, in this example, next_state will be one of "confirm_start", "start", "scan_location"): { message { message_type* string message* string } next_state string data { confirm_start {...} start {...} scan_location {...} } } General idea of a schema for a generic method (data may vary): { message { message_type* string message* string } data { size* integer records* integer } } --- shopfloor/services/cluster_picking.py | 252 +++++-------------- shopfloor/services/service.py | 26 +- shopfloor/services/single_pack_putaway.py | 100 +++++--- shopfloor/services/single_pack_transfer.py | 112 ++++++--- shopfloor/services/validator.py | 87 ++++++- shopfloor/tests/common.py | 11 +- shopfloor/tests/test_cluster_picking.py | 4 + shopfloor/tests/test_picking_batch.py | 4 + shopfloor/tests/test_single_pack_putaway.py | 11 +- shopfloor/tests/test_single_pack_transfer.py | 6 + 10 files changed, 329 insertions(+), 284 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 56aa024206..db625c25cc 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -167,254 +167,132 @@ class ShopfloorClusterPickingValidatorResponse(Component): _name = "shopfloor.cluster_picking.validator.response" _usage = "cluster_picking.validator.response" + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + return { + "confirm_start": self._schema_for_batch_details, + "start_line": self._schema_for_single_line_details, + "start": {}, + "manual_selection": {}, + "scan_destination": self._schema_for_single_line_details, + "zero_check": self._schema_for_zero_check, + "unload_all": self._schema_for_unload_all, + "confirm_unload_all": self._schema_for_unload_all, + "unload_confirm_pack": self._schema_for_unload_single, + "unload_set_destination": self._schema_for_unload_single, + "confirm_unload_set_destination": self._schema_for_unload_single, + "show_completion_info": self._schema_for_unload_single, + } + def find_batch(self): return self._response_schema( - { - "confirm_start": { - "type": "dict", - "required": True, - "schema": self._schema_for_batch_details, - }, - "start_line": { - "type": "dict", - "required": True, - "schema": self._schema_for_single_line_details, - }, - "start": {"type": "dict", "required": False}, - } + next_states=["confirm_start", "start_line", "start"] ) def select(self): - return self._response_schema( - { - "manual_selection": {"type": "dict", "required": False}, - "confirm_start": { - "type": "dict", - "required": True, - "schema": self._schema_for_single_line_details, - }, - "start_line": { - "type": "dict", - "required": True, - "schema": self._schema_for_single_line_details, - }, - } - ) + return self._response_schema(next_states=["manual_selection", "confirm_start"]) def unassign(self): - return self._response_schema({"start": {"type": "dict", "required": False}}) + return self._response_schema(next_states=["start"]) def scan_line(self): - return self._response_schema( - { - "start_line": { - "type": "dict", - "required": True, - "schema": self._schema_for_single_line_details, - }, - "scan_destination": { - "type": "dict", - "required": True, - "schema": self._schema_for_single_line_details, - }, - } - ) + return self._response_schema(next_states=["start_line", "scan_destination"]) def scan_destination_pack(self): return self._response_schema( - # when we still have lines to process - { - "start_line": { - "type": "dict", - "required": False, - "schema": self._schema_for_single_line_details, - }, - "zero_check": { - "type": "dict", - "required": False, - "schema": self._schema_for_zero_check, - }, + next_states=[ + # when we still have lines to process + "start_line", + # when the source location is empty + "zero_check", # when all lines have been processed and have same # destination - "unload_all": { - "type": "dict", - "required": False, - "schema": self._schema_for_unload_all, - }, + "unload_all", # when all lines have been processed and have different # destinations - "unload_confirm_pack": { - "type": "dict", - "required": False, - "schema": self._schema_for_unload_single, - }, - } + "unload_confirm_pack", + ] ) def prepare_unload(self): return self._response_schema( - { + next_states=[ # when all lines have been processed and have same # destination - "unload_all": { - "type": "dict", - "required": False, - "schema": self._schema_for_unload_all, - }, + "unload_all", # when all lines have been processed and have different # destinations - "unload_confirm_pack": { - "type": "dict", - "required": False, - "schema": self._schema_for_unload_single, - }, - } + "unload_confirm_pack", + ] ) def is_zero(self): return self._response_schema( - { + next_states=[ # when we still have lines to process - "start_line": { - "type": "dict", - "required": False, - "schema": self._schema_for_single_line_details, - }, + "start_line", # when all lines have been processed and have same # destination - "unload_all": { - "type": "dict", - "required": False, - "schema": self._schema_for_unload_all, - }, + "unload_all", # when all lines have been processed and have different # destinations - "unload_confirm_pack": { - "type": "dict", - "required": False, - "schema": self._schema_for_unload_single, - }, - } + "unload_confirm_pack", + ] ) def skip_line(self): - return self._response_schema( - { - "start_line": { - "type": "dict", - "required": True, - "schema": self._schema_for_single_line_details, - } - } - ) + return self._response_schema(next_states=["start_line"]) def stock_issue(self): return self._response_schema( - { + next_states=[ # when we still have lines to process - "start_line": { - "type": "dict", - "required": False, - "schema": self._schema_for_single_line_details, - }, + "start_line", # when all lines have been processed and have same # destination - "unload_all": { - "type": "dict", - "required": False, - "schema": self._schema_for_unload_all, - }, + "unload_all", # when all lines have been processed and have different # destinations - "unload_confirm_pack": { - "type": "dict", - "required": False, - "schema": self._schema_for_unload_single, - }, - } + "unload_confirm_pack", + ] ) def change_pack_lot(self): - return self._response_schema( - { - "scan_destination": { - "type": "dict", - "required": True, - "schema": self._schema_for_single_line_details, - } - } - ) + return self._response_schema(next_states=["scan_destination"]) def set_destination_all(self): return self._response_schema( - { + next_states=[ # if the batch still contain lines - "start_line": { - "type": "dict", - "required": False, - "schema": self._schema_for_single_line_details, - }, - "confirm_unload_all": { - "type": "dict", - "required": False, - "schema": self._schema_for_unload_all, - }, - "start": {"type": "dict", "required": False}, - } + "start_line", + # different destination to confirm + "confirm_unload_all", + # batch finished + "start", + ] ) def unload_split(self): - return self._response_schema( - { - "unload_confirm_pack": { - "type": "dict", - "required": False, - "schema": self._schema_for_unload_single, - } - } - ) + return self._response_schema(next_states=["unload_confirm_pack"]) def unload_scan_pack(self): return self._response_schema( - { - "unload_confirm_pack": { - "type": "dict", - "required": False, - "schema": self._schema_for_unload_single, - }, - "unload_set_destination": { - "type": "dict", - "required": False, - "schema": self._schema_for_unload_single, - }, - } + next_states=["unload_confirm_pack", "unload_set_destination"] ) def unload_scan_destination(self): return self._response_schema( - { - "unload_confirm_pack": { - "type": "dict", - "required": False, - "schema": self._schema_for_unload_single, - }, - "confirm_unload_set_destination": { - "type": "dict", - "required": False, - "schema": self._schema_for_unload_single, - }, - "show_completion_info": { - "type": "dict", - "required": False, - "schema": self._schema_for_unload_single, - }, - "start": {"type": "dict", "required": False}, - "start_line": { - "type": "dict", - "required": True, - "schema": self._schema_for_single_line_details, - }, - } + next_states=[ + "unload_confirm_pack", + "confirm_unload_set_destination", + "show_completion_info", + "start", + "start_line", + ] ) @property diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index e7776129f3..9290fecdb7 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -67,20 +67,36 @@ def _response(self, data=None, next_state=None, message=None): All the keys are optional. - :param data: dictionary of values + :param data: dictionary of values, when a next_state is provided, + the data is enclosed in a key of the same name (to support polymorphism + in the schema) :param next_state: string describing the next state that the client application must reach :param message: dictionary for the message to show in the client application (see ``_response_schema`` for the keys) """ response = {} - if data: - response["data"] = data if next_state: - response["next_state"] = next_state - # TODO ensure we have at least an empty dict for the next state + # data for a state is always enclosed in a key with the name + # of the state, so an endpoint can return to different states + # that need different data: the schema can be different for + # every state this way + response.update( + { + # ensure we have an empty dict when the state + # does not need any data, so the client does not need + # to check this + "data": {next_state: data or {}}, + "next_state": next_state, + } + ) + + elif data: + response["data"] = data + if message: response["message"] = message + return response def _get_openapi_default_parameters(self): diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 96658a65bc..b365f644b4 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -175,16 +175,20 @@ def _response_for_move_canceled_elsewhere(self): next_state="start", message=message.operation_has_been_canceled_elsewhere() ) - def _response_for_location_not_found(self): + def _response_for_location_not_found(self, move_line, pack): message = self.actions_for("message") return self._response( - next_state="scan_location", message=message.no_location_found() + next_state="scan_location", + message=message.no_location_found(), + data=self._data_after_package_scanned(move_line, pack), ) - def _response_for_forbidden_location(self): + def _response_for_forbidden_location(self, move_line, pack): message = self.actions_for("message") return self._response( - next_state="scan_location", message=message.dest_location_not_allowed() + next_state="scan_location", + message=message.dest_location_not_allowed(), + data=self._data_after_package_scanned(move_line, pack), ) def _response_for_location_need_confirm(self): @@ -206,15 +210,16 @@ def validate(self, package_level_id, location_barcode, confirmation=False): if not package.exists(): return self._response_for_package_level_not_found() - move = package.move_line_ids[0].move_id + move_line = package.move_line_ids[0] + move = move_line.move_id if not pack_transfer.is_move_state_valid(move): return self._response_for_move_canceled_elsewhere() scanned_location = search.location_from_scan(location_barcode) if not scanned_location: - return self._response_for_location_not_found() + return self._response_for_location_not_found(move_line, package.package_id) if not pack_transfer.is_dest_location_valid(move, scanned_location): - return self._response_for_forbidden_location() + return self._response_for_forbidden_location(move_line, package.package_id) if pack_transfer.is_dest_location_to_confirm(move, scanned_location): if confirmation: @@ -281,44 +286,63 @@ class SinglePackPutawayValidatorResponse(Component): _name = "shopfloor.single.pack.putaway.validator.response" _usage = "single_pack_putaway.validator.response" + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + return { + "start": {}, + "confirm_start": self._schema_for_location, + "scan_location": self._schema_for_location, + "confirm_location": {}, + } + def cancel(self): - return self._response_schema() + return self._response_schema(next_states=["start"]) def validate(self): - return self._response_schema() + return self._response_schema( + next_states=["scan_location", "start", "confirm_location"] + ) def start(self): return self._response_schema( - { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "location_src": { - "type": "dict", - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, + next_states=["confirm_start", "start", "scan_location"] + ) + + @property + def _schema_for_location(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "location_src": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, }, - "location_dst": { - "type": "dict", - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, + }, + "location_dst": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, }, - "product": { - "type": "dict", - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, + }, + "product": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, }, - "picking": { - "type": "dict", - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, + }, + "picking": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, }, - } - ) + }, + } diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 65e465decd..4cb233bc69 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -135,16 +135,20 @@ def _response_for_move_canceled_elsewhere(self): next_state="start", message=message.operation_has_been_canceled_elsewhere() ) - def _response_for_location_not_found(self): + def _response_for_location_not_found(self, move_line, pack): message = self.actions_for("message") return self._response( - next_state="scan_location", message=message.no_location_found() + next_state="scan_location", + message=message.no_location_found(), + data=self._data_after_package_scanned(move_line, pack), ) - def _response_for_forbidden_location(self): + def _response_for_forbidden_location(self, move_line, pack): message = self.actions_for("message") return self._response( - next_state="scan_location", message=message.dest_location_not_allowed() + next_state="scan_location", + message=message.dest_location_not_allowed(), + data=self._data_after_package_scanned(move_line, pack), ) def _response_for_location_need_confirm(self): @@ -172,15 +176,16 @@ def validate(self, package_level_id, location_barcode, confirmation=False): if not package.exists(): return self._response_for_package_level_not_found() - move = package.move_line_ids[0].move_id + move_line = package.move_line_ids[0] + move = move_line.move_id if not pack_transfer.is_move_state_valid(move): return self._response_for_move_canceled_elsewhere() scanned_location = search.location_from_scan(location_barcode) if not scanned_location: - return self._response_for_location_not_found() + return self._response_for_location_not_found(move_line, package.package_id) if not pack_transfer.is_dest_location_valid(move, scanned_location): - return self._response_for_forbidden_location() + return self._response_for_forbidden_location(move_line, package.package_id) if pack_transfer.is_dest_location_to_confirm(move, scanned_location): if confirmation: @@ -248,44 +253,69 @@ class SinglePackTransferValidatorResponse(Component): _name = "shopfloor.single.pack.transfer.validator.response" _usage = "single_pack_transfer.validator.response" + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + return { + "start": {}, + "confirm_start": self._schema_for_location, + "scan_location": self._schema_for_location, + "confirm_location": {}, + "show_completion_info": {}, + } + def start(self): return self._response_schema( - { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "location_src": { - "type": "dict", - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - "location_dst": { - "type": "dict", - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - "product": { - "type": "dict", - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - "picking": { - "type": "dict", - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - } + next_states=["confirm_start", "start", "scan_location"] ) def cancel(self): - return self._response_schema() + return self._response_schema(next_states=["start"]) def validate(self): - return self._response_schema() + return self._response_schema( + next_states=[ + "scan_location", + "start", + "confirm_location", + "show_completion_info", + ] + ) + + @property + def _schema_for_location(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "location_src": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + "location_dst": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + "product": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + "picking": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + } diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py index 48d8d44e96..67b2b16b38 100644 --- a/shopfloor/services/validator.py +++ b/shopfloor/services/validator.py @@ -11,25 +11,70 @@ class BaseShopfloorValidator(AbstractComponent): class BaseShopfloorValidatorResponse(AbstractComponent): - """Base class for Validator for Responses""" + """Base class for Validator for Responses + + When an endpoint returns data for a state, the data is enclosed + in a key with the same name as the state, this is in order to support + polymorphism in schemas (an endpoint being able to return different data + depending on the next state). + + General idea of a schema for a method that changes state (data may vary, + in this example, next_state will be one of "confirm_start", "start", + "scan_location"): + + { + message { + message_type* string + message* string + } + next_state string + data { + confirm_start {...} + start {...} + scan_location {...} + } + } + + General idea of a schema for a generic method (data may vary): + + { + message { + message_type* string + message* string + } + data { + size* integer + records* integer + } + } + + """ _inherit = "base.rest.service" _name = "base.shopfloor.validator.response" _collection = "shopfloor.service" _is_rest_service_component = False - def _response_schema(self, data_schema=None): + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + return {} + + def _response_schema(self, data_schema=None, next_states=None): """Schema for the return validator Must be used for the schema of all responses. The "data" part can be customized and is optional, it must be a dictionary. + + next_states is a list of allowed states to which the client + can transition. The schema of the data needed for every state + of the list must be defined in the ``_states`` method. """ - if not data_schema: - data_schema = {} - return { - "data": {"type": "dict", "required": False, "schema": data_schema}, - "next_state": {"type": "string", "required": False}, + response_schema = { "message": { "type": "dict", "required": False, @@ -41,5 +86,31 @@ def _response_schema(self, data_schema=None): }, "message": {"type": "string", "required": True}, }, - }, + } + } + if not data_schema: + data_schema = {} + + if next_states: + states_schemas = self._states() + unknown_states = set(next_states) - states_schemas.keys() + if unknown_states: + raise ValueError( + "states {!r} are not defined in _states".format(unknown_states) + ) + + data_schema = data_schema.copy() + data_schema.update( + { + state: {"type": "dict", "schema": states_schemas[state]} + for state in next_states + } + ) + response_schema["next_state"] = {"type": "string", "required": False} + + response_schema["data"] = { + "type": "dict", + "required": False, + "schema": data_schema, } + return response_schema diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 45bd0c4cf3..2c7aed84fa 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -70,11 +70,13 @@ def assert_response(self, response, next_state=None, message=None, data=None): value. """ expected = {} - if next_state: - expected["next_state"] = next_state if message: expected["message"] = message - if data: + if next_state: + expected.update( + {"next_state": next_state, "data": {next_state: data or {}}} + ) + elif data: expected["data"] = data self.assert_dict(response, expected) @@ -122,7 +124,8 @@ def assert_dict(self, current, expected): self.assertDictEqual( node_values, node_expected, - "\n\nNode's specs:\n%s" % (pformat(node_original_expected)), + "\n\nActual:\n%s" + "\n\nExpected:\n%s" % (pformat(current), pformat(expected)), ) def _update_qty_in_location(self, location, product, quantity): diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index 6157f8e552..7fd1d38430 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -42,6 +42,10 @@ def _create_batch_picking(cls, product): batch_form.picking_ids.add(picking) return batch_form.save() + def test_to_openapi(self): + # will raise if it fails to generate the openapi specs + self.service.to_openapi() + # def test_list_manual_batch(self): # """Test getting list of picking batches the user can work on""" # # Simulate the client asking the list of picking batch it can diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py index 62e4f70a38..374261de59 100644 --- a/shopfloor/tests/test_picking_batch.py +++ b/shopfloor/tests/test_picking_batch.py @@ -51,6 +51,10 @@ def _create_picking_batch(cls, product): batch_form.picking_ids.add(picking) return batch_form.save() + def test_to_openapi(self): + # will raise if it fails to generate the openapi specs + self.service.to_openapi() + def test_search_empty(self): """No batch is available""" # Simulate the client asking the list of picking batch diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 8a0cada93a..640fc1aa7a 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -38,6 +38,10 @@ def setUp(self): with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="single_pack_putaway") + def test_to_openapi(self): + # will raise if it fails to generate the openapi specs + self.service.to_openapi() + def test_start(self): """Test the happy path for single pack putaway /start endpoint @@ -60,9 +64,10 @@ def test_start(self): # Simulate the client scanning a package's barcode, which # in turns should start the operation in odoo response = self.service.dispatch("start", params=params) + state_data = response["data"]["scan_location"] # Checks: - package_level = self.env["stock.package_level"].browse(response["data"]["id"]) + package_level = self.env["stock.package_level"].browse(state_data["id"]) move_line = package_level.move_line_ids move = move_line.move_id @@ -206,6 +211,7 @@ def test_start_move_already_exist(self): params = {"barcode": barcode} response = self.service.dispatch("start", params=params) + self.assert_response( response, next_state="confirm_start", @@ -341,6 +347,7 @@ def test_validate_location_not_found(self): "message_type": "error", "message": "No location found for this barcode.", }, + data=self.ANY, ) def test_validate_location_forbidden(self): @@ -374,6 +381,7 @@ def test_validate_location_forbidden(self): response, next_state="scan_location", message={"message_type": "error", "message": "You cannot place it here"}, + data=self.ANY, ) def test_validate_location_to_confirm(self): @@ -530,6 +538,7 @@ def test_cancel_already_canceled(self): response = self.service.dispatch( "cancel", params={"package_level_id": package_level.id} ) + self.assertRecordValues(move, [{"state": "cancel"}]) self.assertRecordValues(picking, [{"state": "cancel"}]) self.assertFalse(package_level.move_line_ids) diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index fd02731953..acbd4aef88 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -61,6 +61,10 @@ def _simulate_started(self): package_level.is_done = True return package_level + def test_to_openapi(self): + # will raise if it fails to generate the openapi specs + self.service.to_openapi() + def test_start(self): """Test the happy path for single pack transfer /start endpoint @@ -468,6 +472,7 @@ def test_validate_location_not_found(self): self.assert_response( response, next_state="scan_location", + data=self.ANY, message={ "message_type": "error", "message": "No location found for this barcode.", @@ -504,6 +509,7 @@ def test_validate_location_forbidden(self): self.assert_response( response, next_state="scan_location", + data=self.ANY, message={"message_type": "error", "message": "You cannot place it here"}, ) From 16101ecb321380436c4c490c96161c9dd427b308 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 14 Feb 2020 15:49:56 +0100 Subject: [PATCH 086/940] Document and add details to cluster picking endpoints --- shopfloor/models/__init__.py | 1 + shopfloor/models/stock_picking_batch.py | 12 + shopfloor/services/cluster_picking.py | 390 +++++++++++++++++++++--- shopfloor/services/service.py | 8 + 4 files changed, 376 insertions(+), 35 deletions(-) create mode 100644 shopfloor/models/stock_picking_batch.py diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index d0299a28b9..b39ee610aa 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -4,4 +4,5 @@ from . import stock_picking_type from . import shopfloor_profile from . import stock_location +from . import stock_picking_batch from . import res_users diff --git a/shopfloor/models/stock_picking_batch.py b/shopfloor/models/stock_picking_batch.py new file mode 100644 index 0000000000..4101de4da5 --- /dev/null +++ b/shopfloor/models/stock_picking_batch.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class StockPickingBatch(models.Model): + _inherit = "stock.picking.batch" + + cluster_picking_unload_all = fields.Boolean( + default=False, + copy=False, + help="Technical field. Indicates if a batch is destination is" + " asked once for all lines or for every line.", + ) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index db625c25cc..9165382afb 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -1,9 +1,64 @@ from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component +from .service import to_float + class ClusterPicking(Component): - """Methods for the Cluster Picking Process""" + """Methods for the Cluster Picking Process + + The goal of this process is to do the pickings for a Picking Batch, for + several customers at once. + The process assumes that picking batch records already exist. + + At first, a user gets automatically a batch to work on (assigned to them), + or can select one from a list. + + The process has 2 main phases, which can be done one after the other or a + bit of both. The first one is picking goods and put them in a roller-cage. + + First phase, picking: + + * Pick a good (move line) from a source location, scan it to confirm it's + the expected one + * Scan the label of a Bin (package) in a roller-cage, put the good inside + (physically). Once the first move line of a picking has been scanned, the + screen will show the same destination package for all the other lines of + the picking to help the user grouping goods together, and will prevent + lines from other pickings to be put in the same destination package. + * If odoo thinks a source location is empty after picking the goods, a + "zero check" is done: it asks the user to confirm if it is empty or not + * Repeat until the end of the batch or the roller-cage is full (there is + button to declare this) + + Second phase, unload to destination: + + * If all the goods (move lines) in the roller-cage go to the same destination, + a screen asking a single barcode for the destination is shown + * Otherwise, the user has to scan one destination per good. + * If all the goods are supposed to go to the same destination but user doesn't + want or can't, a "split" allows to reach the screen to scan one destination + per good. + * When everything has a destination set and the batch is not finished yet, + the user goes to the first phase of pickings again for the rest. + + Inside the main workflow, some actions are accessible from the client: + + * Change a lot or pack: if the expected lot is at the very bottom of the + location or a stock error forces a user to change lot or pack, user can + do it during the picking. + * Skip a line: during picking, for instance because a line is not accessible + easily, it can be postponed, note that skipped lines have to be done, they + are only moved to the end of the queue. + * Declare stock out: if a good is in fact not in stock or only partially. Note + the move lines will become unavailable or partially unavailable and will + generate a back-order. + * Full bin: declaring a full bin allows to move directly to the first phase + (picking) to the second one (unload). The process will go + back to the first phase if some lines remain in the queue of lines to pick. + + Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP + """ _inherit = "base.shopfloor.service" _name = "shopfloor.cluster.picking" @@ -14,72 +69,292 @@ def find_batch(self): """Find a picking batch to work on and start it Usually the starting point of the process. + + Business rules to find a batch, try in order: + + a. Find an batch in progress assigned to the current user + b. Find a draft batch assigned to the current user: + 1. set it to 'in progress' + c. Find an unassigned draft batch: + 1. assign batch to the current user + 2. set it to 'in progress' + + Transitions: + * confirm_start: when it could find a batch + * start: when no batch is available """ return self._response() - def select(self, batch_id): + def select(self, picking_batch_id): """Manually select a picking batch The client application can use the service /picking_batch/search - to get the list of candidate batches. + to get the list of candidate batches. Then, it starts to work on + the selected batch by calling this. + + Note: it should be able to work only on batches which are in draft or + (in progress and assigned to the current user), the search method that + lists batches filter them, but it has to be checked again here in case + of race condition. + + Transitions: + * manual_selection: a selected batch cannot be used (assigned to someone else + concurrently for instance) + * confirm_start: after the batch has been assigned to the user """ return self._response() - def unassign(self, batch_id): - """Unassign and reset to draft a started picking batch""" + def confirm_start(self, picking_batch_id): + """User confirms they start a batch + + Should have no effect in odoo besides logging and routing the user to + the next action. The next action is "start_line" with data about the + line to pick. + + Transitions: + * start_line: when the batch has at least one line without destination + package + * start: if the condition above is wrong (rare case of race condition...) + """ + return self._response() + + def unassign(self, picking_batch_id): + """Unassign and reset to draft a started picking batch + + Transitions: + * "start" to work on a new batch + """ return self._response(next_state="start") def scan_line(self, move_line_id, barcode): """Scan a location, a pack, a product or a lots - To validate the operator takes the expected pack or product. + There is no side-effect, it is only to check that the operator takes + the expected pack or product. + + User can scan a location if there is only pack inside. Otherwise, they + have to precise what they want by scanning one of: + + * pack + * product + * lot + + The result must be unambigous. For instance if we scan a product but the + product is tracked by lot, scanning the lot has to be required. + + Transitions: + * start_line: with an appropriate message when user has + to scan for the same line again + * start_line: with the next line if the line was added to a + pack meanwhile (race condition). + * scan_destination: if the barcode matches. """ return self._response() - def scan_destination_pack(self, move_line_id, barcode): - """Scan the destination pack (bin) for a move line - - It will change the destination of the move line + def scan_destination_pack(self, move_line_id, barcode, quantity): + """Scan the destination package (bin) for a move line + + If the quantity picked (passed to the endpoint) is < expected quantity, + it splits the move line. + It changes the destination package of the move line and set the "qty done". + It prevents to put a move line of a picking in a destination package + used for another picking. + + Transitions: + * zero_check: if the quantity of product moved is 0 in the + source location after the move (beware: at this point the product we put in + a bin is still considered to be in the source location, so we have to compute + the source location's quantity - qty_done). + * unload_all: when all lines have a destination package and they all + have the same destination. + * unload_single: when all lines have a destination package and they all + have the same destination. + * start_line: to pick the next line if any. """ return self._response() def prepare_unload(self, picking_batch_id): - """Scan the destination pack (bin) for a move line + """Initiate the unloading phase of the process - It will change the destination of the move line + If the destination of all the move lines still to unload is the same, + it sets the flag ``cluster_picking_unload_all`` to True on + ``stock.batch.picking``. + Everytime this method is called, it resets the flag according to the + condition above. + + Transitions: + * unload_all: when ``cluster_picking_unload_all`` is True + * unload_single: when ``cluster_picking_unload_all`` is False """ return self._response() def is_zero(self, move_line_id, zero): - """Confirm or not if the source location of a move has zero qty""" + """Confirm or not if the source location of a move has zero qty + + If the user confirms there is zero quantity, it means the stock was + correct and there is nothing to do. If the user says "no", a draft + empty inventory is created for the product (with lot if tracked). + + Transitions: + * start_line: if the batch has lines without destination package (bin) + * unload_all: if all lines have a destination package and same + destination + * unload_single: if all lines have a destination package and different + destination + """ return self._response() def skip_line(self, move_line_id): - """Skip a line. The line will be processed at the end.""" + """Skip a line. The line will be processed at the end. + + It adds a flag on the move line, when the next line to pick + is searched, lines with such flag at moved to the end. + + A skipped line *must* be picked. + + Transitions: + * start_line: with data for the next line (or itself if it's the last one, + in such case, a helpful message is returned) + """ return self._response() def stock_issue(self, move_line_id): - """Declare a stock issue for a line""" + """Declare a stock issue for a line + + After errors in the stock, the user cannot take all the products + because there is physically not enough goods. The move line is + unassigned, and an inventory is created to reduce the quantity in the + source location to prevent future errors until a correction. Beware: + the quantity already reserved by other lines should remain reserved so + the inventory's quantity must be set to the quantity of lines reserved + by other move lines (but not the current one). + + A second inventory is created in draft to have someone do an inventory. + + Transitions: + * start_line: when the batch still contains lines without destination + package + * unload_all: if all lines have a destination package and same + destination + * unload_single: if all lines have a destination package and different + destination + * start: all lines are done/confirmed (because all lines were unloaded + and the last line has a stock issue). In this case, this method *has* + to handle the closing of the batch to create backorders. TODO find a + generic way to share actions happening on transitions such as "close + the batch" + """ return self._response() def change_pack_lot(self, move_line_id, barcode): - """Change the expected pack or the lot for a line""" + """Change the expected pack or the lot for a line + + If the expected lot is at the very bottom of the location or a stock + error forces a user to change lot or pack, user can change the pack or + lot of the current line. + + The change occurs when the pack/product/lot is normally scanned and + goes directly to the scan of the destination package (bin) since we do + not need to check it. + + If the pack or lot was not supposed to be in the source location, + a draft inventory is created to have this checked. + + Transitions: + * scan_destination: the pack or the lot could be changed + * start_line: any error occurred during the change + """ return self._response() def set_destination_all(self, picking_batch_id, barcode, confirmation=False): - """Set the destination for all the lines of the batch""" + """Set the destination for all the lines with a dest. package of the batch + + This method must be used only if all the move lines which have a destination + package and qty done have the same destination location. + + A scanned location outside of the source location of the operation type is + invalid. + + TODO: remove the destination package or not? + + Transitions: + * start_line: the batch still have move lines without destination package + * unload_all: invalid destination, have to scan a good one + * confirm_unload_all: the scanned location is not the expected one (but + still a valid one) + * start: batch is totally done. In this case, this method *has* + to handle the closing of the batch to create backorders. TODO find a + generic way to share actions happening on transitions such as "close + the batch" + """ return self._response() def unload_split(self, picking_batch_id, barcode, confirmation=False): - """Set the destination for all the lines of the batch""" + """Indicates that now the batch must be treated line per line + + Even if the move lines to unload all have the same destination. + + It sets the flag ``stock_picking_batch.cluster_picking_unload_all`` to + False. + + Note: if we go back to the first phase of picking and start a new + phase of unloading, the flag is reevaluated to the initial condition. + + Transitions: + * unload_single: always goes here since we now want to unload line per line + """ + return self._response() + + def unload_router(self, picking_batch_id): + """Called after the info screen, route to the next state + + No side effect in Odoo. + + Transitions: + * unload_single: if the batch still has lines to unload + * start_line: if the batch still has lines to pick + * start: if the batch is done. In this case, this method *has* + to handle the closing of the batch to create backorders. TODO find a + generic way to share actions happening on transitions such as "close + the batch" + """ return self._response() def unload_scan_pack(self, move_line_id, barcode): - """Check that the operator scans the correct pack (bin)""" + """Check that the operator scans the correct package (bin) on unload + + If the package is different as the destination package of the move line, + ask to scan it again. + + Transitions: + * unload_single: if the barcode does not match + * unload_set_destination: barcode is correct + """ return self._response() def unload_scan_destination(self, move_line_id, barcode, confirmation=False): - """Check that the operator scans the correct pack (bin)""" + """Scan the final destination of the move line + + TODO not sure. We have to call action_done on the picking *only when we + have scanned all the move lines* of the picking, so maybe we have to + keep track of this with a new flag on move lines? + + Transitions: + * unload_single: invalid scanned location or error + * unload_single: line is processed and the next line can be unloaded + * confirm_unload_set_destination: the destination is valid but not the + expected, ask a confirmation. This state has to call again the + endpoint with confirmation=True + * show_completion_info: the completion info of the picking is + "next_picking_ready", it will show an info box to the user, the js + client should then call /unload_router to know the next state + * start_line: if the batch still has lines to pick + * start: if the batch is done. In this case, this method *has* + to handle the closing of the batch to create backorders. TODO find a + generic way to share actions happening on transitions such as "close + the batch" + + """ return self._response() @@ -94,10 +369,19 @@ def find_batch(self): return {} def select(self): - return {"batch_id": {"coerce": to_int, "required": True, "type": "integer"}} + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"} + } + + def confirm_start(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"} + } def unassign(self): - return {"batch_id": {"coerce": to_int, "required": True, "type": "integer"}} + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"} + } def scan_line(self): return { @@ -109,6 +393,7 @@ def scan_destination_pack(self): return { "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, "barcode": {"required": True, "type": "string"}, + "quantity": {"coerce": to_float, "required": True, "type": "float"}, } def prepare_unload(self): @@ -146,6 +431,11 @@ def unload_split(self): "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"} } + def unload_router(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"} + } + def unload_scan_pack(self): return { "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, @@ -182,20 +472,31 @@ def _states(self): "zero_check": self._schema_for_zero_check, "unload_all": self._schema_for_unload_all, "confirm_unload_all": self._schema_for_unload_all, - "unload_confirm_pack": self._schema_for_unload_single, + "unload_single": self._schema_for_unload_single, "unload_set_destination": self._schema_for_unload_single, "confirm_unload_set_destination": self._schema_for_unload_single, - "show_completion_info": self._schema_for_unload_single, + "show_completion_info": self._schema_for_completion_info, } def find_batch(self): - return self._response_schema( - next_states=["confirm_start", "start_line", "start"] - ) + return self._response_schema(next_states=["confirm_start", "start"]) def select(self): return self._response_schema(next_states=["manual_selection", "confirm_start"]) + def confirm_start(self): + return self._response_schema( + next_states=[ + "start_line", + # "start" should be pretty rare, only if the batch has been + # canceled, deleted meanwhile... + # TODO every state could bring back to 'start' in case of + # unrecoverable error, maybe we should add an attribute + # `_start_state = "start"` and implicitly add it in states + "start", + ] + ) + def unassign(self): return self._response_schema(next_states=["start"]) @@ -214,7 +515,7 @@ def scan_destination_pack(self): "unload_all", # when all lines have been processed and have different # destinations - "unload_confirm_pack", + "unload_single", ] ) @@ -226,7 +527,7 @@ def prepare_unload(self): "unload_all", # when all lines have been processed and have different # destinations - "unload_confirm_pack", + "unload_single", ] ) @@ -240,7 +541,7 @@ def is_zero(self): "unload_all", # when all lines have been processed and have different # destinations - "unload_confirm_pack", + "unload_single", ] ) @@ -257,18 +558,21 @@ def stock_issue(self): "unload_all", # when all lines have been processed and have different # destinations - "unload_confirm_pack", + "unload_single", + "start", ] ) def change_pack_lot(self): - return self._response_schema(next_states=["scan_destination"]) + return self._response_schema(next_states=["scan_destination", "start_line"]) def set_destination_all(self): return self._response_schema( next_states=[ # if the batch still contain lines "start_line", + # invalid destination, have to scan a valid one + "unload_all", # different destination to confirm "confirm_unload_all", # batch finished @@ -277,17 +581,22 @@ def set_destination_all(self): ) def unload_split(self): - return self._response_schema(next_states=["unload_confirm_pack"]) + return self._response_schema(next_states=["unload_single"]) + + def unload_router(self): + return self._response_schema( + next_states=["unload_single", "start_line", "start"] + ) def unload_scan_pack(self): return self._response_schema( - next_states=["unload_confirm_pack", "unload_set_destination"] + next_states=["unload_single", "unload_set_destination"] ) def unload_scan_destination(self): return self._response_schema( next_states=[ - "unload_confirm_pack", + "unload_single", "confirm_unload_set_destination", "show_completion_info", "start", @@ -295,9 +604,11 @@ def unload_scan_destination(self): ] ) + # TODO single class for sharing schemas between services @property def _schema_for_batch_details(self): return { + # TODO full name instead of id? or always wrap in batch/move_line? # id is a stock.picking.batch "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, @@ -442,3 +753,12 @@ def _schema_for_zero_check(self): }, }, } + + @property + def _schema_for_completion_info(self): + return { + # stock.picking.batch + "id": {"required": True, "type": "integer"}, + "picking_done": {"type": "string", "nullable": False, "required": True}, + "picking_next": {"type": "string", "nullable": False, "required": True}, + } diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 9290fecdb7..e34e6c45d5 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -6,6 +6,14 @@ from odoo.addons.component.core import AbstractComponent, WorkContext +def to_float(val): + if isinstance(val, float): + return val + if val: + return float(val) + return None + + class BaseShopfloorService(AbstractComponent): """Base class for REST services""" From 614e6ac5d95f32e0cf63a59ccd60a7c3c881e60d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 17 Feb 2020 08:42:57 +0100 Subject: [PATCH 087/940] Change _search methods return records instead of json So they can be used internally while public search return json for REST. --- shopfloor/services/menu.py | 8 +++++--- shopfloor/services/picking_batch.py | 9 +++++---- shopfloor/services/profile.py | 6 +++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index fd2a015b86..140c3bfde3 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -38,12 +38,14 @@ def _search(self, name_fragment=None): if name_fragment: domain.append(("name", "ilike", name_fragment)) records = self.env[self._expose_model].search(domain) - return self._to_json(records) + return records def search(self, name_fragment=None): """List available menu entries for current user""" - json_records = self._search(name_fragment=name_fragment) - return self._response(data={"size": len(json_records), "records": json_records}) + records = self._search(name_fragment=name_fragment) + return self._response( + data={"size": len(records), "records": self._to_json(records)} + ) def _convert_one_record(self, record): return { diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py index a4d2cfe2d8..3646ce850c 100644 --- a/shopfloor/services/picking_batch.py +++ b/shopfloor/services/picking_batch.py @@ -45,16 +45,17 @@ def _search(self, name_fragment=None): for picking in batch.picking_ids ) ) - # TODO why already convert to json in the internal method? - return self._to_json(records) + return records def search(self, name_fragment=None): """List available stock picking batches for current user Show only picking batches where all the pickings are available. """ - json_records = self._search(name_fragment=name_fragment) - return self._response(data={"size": len(json_records), "records": json_records}) + records = self._search(name_fragment=name_fragment) + return self._response( + data={"size": len(records), "records": self._to_json(records)} + ) def _convert_one_record(self, record): assigned_pickings = record.picking_ids.filtered( diff --git a/shopfloor/services/profile.py b/shopfloor/services/profile.py index 395971a73a..94ee11d5ed 100644 --- a/shopfloor/services/profile.py +++ b/shopfloor/services/profile.py @@ -30,13 +30,13 @@ def _search(self, name_fragment=None): if name_fragment: domain.append(("name", "ilike", name_fragment)) records = self.env[self._expose_model].search(domain) - return self._to_json(records) + return records def search(self, name_fragment=None): """List available profiles for current user""" - json_records = self._search(name_fragment=name_fragment) + records = self._search(name_fragment=name_fragment) return self._response( - data={"size": len(json_records), "records": self._to_json(json_records)} + data={"size": len(records), "records": self._to_json(records)} ) def _get_base_search_domain(self): From 820db541d834cff5d3e9cd360a3c783fdeda15e4 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 17 Feb 2020 09:46:21 +0100 Subject: [PATCH 088/940] cluster_picking: implement /find_batch --- shopfloor/services/cluster_picking.py | 62 +++++++++- shopfloor/services/picking_batch.py | 14 ++- shopfloor/tests/common.py | 3 +- shopfloor/tests/test_cluster_picking.py | 143 ++++++++++++++++++++++-- 4 files changed, 209 insertions(+), 13 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 9165382afb..d6bb3915e9 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -1,3 +1,5 @@ +from odoo import _ + from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component @@ -72,7 +74,7 @@ def find_batch(self): Business rules to find a batch, try in order: - a. Find an batch in progress assigned to the current user + a. Find a batch in progress assigned to the current user b. Find a draft batch assigned to the current user: 1. set it to 'in progress' c. Find an unassigned draft batch: @@ -83,7 +85,62 @@ def find_batch(self): * confirm_start: when it could find a batch * start: when no batch is available """ - return self._response() + batch_service = self.component(usage="picking_batch") + batches = batch_service._search() + # look for in progress + assigned to self first + candidates = batches.filtered( + lambda batch: batch.state == "in_progress" + and batch.user_id == self.env.user + ) + if candidates: + return self._response_for_confirm_start(candidates[0]) + # then look for draft assigned to self + candidates = batches.filtered(lambda batch: batch.user_id == self.env.user) + if candidates: + batch = candidates[0] + batch.write({"state": "in_progress"}) + return self._response_for_confirm_start(batch) + # finally take any batch that search could return + if batches: + batch = batches[0] + batch.write({"user_id": self.env.uid, "state": "in_progress"}) + return self._response_for_confirm_start(batch) + return self._response_for_no_batch_found() + + def _response_for_no_batch_found(self): + return self._response( + next_state="start", + message={ + "message_type": "info", + "message": _("No more work to do, please create a new batch transfer"), + }, + ) + + def _response_for_confirm_start(self, batch): + pickings = [] + for picking in batch.picking_ids: + p_values = { + "id": picking.id, + "name": picking.name, + "move_line_count": len(picking.move_line_ids), + "origin": picking.origin or "", + } + if picking.partner_id: + p_values["partner"] = { + "id": picking.partner_id.id, + "name": picking.partner_id.name, + } + pickings.append(p_values) + return self._response( + next_state="confirm_start", + data={ + "id": batch.id, + "name": batch.name, + # TODO + "weight": 0, + "pickings": pickings, + }, + ) def select(self, picking_batch_id): """Manually select a picking batch @@ -629,6 +686,7 @@ def _schema_for_batch_details(self): }, "partner": { "type": "dict", + "required": False, "schema": { "id": {"required": True, "type": "integer"}, "name": { diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py index 3646ce850c..e6d22c82c7 100644 --- a/shopfloor/services/picking_batch.py +++ b/shopfloor/services/picking_batch.py @@ -41,7 +41,16 @@ def _search(self, name_fragment=None): # batch even if some pickings are done/canceled. They'll should be # ignored later. lambda batch: all( - picking.state in ("assigned", "done", "cancel") + ( + # When the batch is already in progress, we do not care + # about state of the pickings, because we want to be able + # to recover it in any case, even if, for instance, a stock + # error changed a picking to unavailable after the user + # started to work on the batch. + batch.state == "in_progress" + or picking.state in ("assigned", "done", "cancel") + ) + and picking.picking_type_id in self.picking_types for picking in batch.picking_ids ) ) @@ -50,7 +59,8 @@ def _search(self, name_fragment=None): def search(self, name_fragment=None): """List available stock picking batches for current user - Show only picking batches where all the pickings are available. + Show only picking batches where all the pickings are available and + where all pickings are in the picking type of the current process. """ records = self._search(name_fragment=name_fragment) return self._response( diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 2c7aed84fa..386d0c4956 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -61,6 +61,7 @@ def setUpClassVars(cls): cls.input_location = cls.env.ref("stock.stock_location_company") cls.shelf1 = cls.env.ref("stock.stock_location_components") cls.shelf2 = cls.env.ref("stock.stock_location_14") + cls.customer = cls.env["res.partner"].create({"name": "Customer"}) def assert_response(self, response, next_state=None, message=None, data=None): """Assert a response from the webservice @@ -133,7 +134,7 @@ def _update_qty_in_location(self, location, product, quantity): def _fill_stock_for_pickings(self, pickings): product_locations = {} - for move in self.all_batches.mapped("picking_ids.move_lines"): + for move in pickings.mapped("move_lines"): key = (move.product_id, move.location_id) product_locations.setdefault(key, 0) product_locations[key] += move.product_qty diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index 7fd1d38430..f022cf7faf 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -18,7 +18,9 @@ def setUpClass(cls, *args, **kwargs): cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id cls.picking_type = cls.process.picking_type_ids - cls.main_batch = cls._create_batch_picking(cls.product_a) + cls.batch1 = cls._create_picking_batch(cls.product_a) + cls.batch2 = cls._create_picking_batch(cls.product_a) + cls.batch3 = cls._create_picking_batch(cls.product_a) def setUp(self): super().setUp() @@ -26,11 +28,13 @@ def setUp(self): self.service = work.component(usage="cluster_picking") @classmethod - def _create_batch_picking(cls, product): + def _create_picking_batch(cls, product): picking_form = Form(cls.env["stock.picking"]) picking_form.picking_type_id = cls.picking_type picking_form.location_id = cls.stock_location picking_form.location_dest_id = cls.packing_location + picking_form.origin = "test {}".format(product.name) + picking_form.partner_id = cls.customer with picking_form.move_ids_without_package.new() as move: move.product_id = product move.product_uom_qty = 1 @@ -46,9 +50,132 @@ def test_to_openapi(self): # will raise if it fails to generate the openapi specs self.service.to_openapi() - # def test_list_manual_batch(self): - # """Test getting list of picking batches the user can work on""" - # # Simulate the client asking the list of picking batch it can - # # select after the user clicked on the "Manual Selection" button - # response = self.service.dispatch("/list_manual_selection") - # import pdb; pdb.set_trace() + def _add_stock_and_assign_pickings_for_batches(self, batches): + pickings = batches.mapped("picking_ids") + self._fill_stock_for_pickings(pickings) + pickings.action_assign() + + def test_find_batch_in_progress_current_user(self): + """Find an in-progress batch assigned to the current user""" + # Simulate the client asking a batch by clicking on "get work" + self._add_stock_and_assign_pickings_for_batches( + self.batch1 | self.batch2 | self.batch3 + ) + self.batch3.user_id = self.env.uid + self.batch3.confirm_picking() # set to in progress + response = self.service.dispatch("find_batch") + + # we expect to find batch 3 as it's assigned to the current + # user and in progress (first priority) + self.assert_response( + response, + next_state="confirm_start", + data={ + "id": self.batch3.id, + "name": self.batch3.name, + # TODO + "weight": 0, + "pickings": [ + { + "id": self.batch3.picking_ids.id, + "name": self.batch3.picking_ids.name, + "move_line_count": len(self.batch3.picking_ids.move_line_ids), + "origin": self.batch3.picking_ids.origin, + "partner": { + "id": self.batch3.picking_ids.partner_id.id, + "name": self.batch3.picking_ids.partner_id.name, + }, + } + ], + }, + ) + + def test_find_batch_assigned(self): + """Find a draft batch assigned to the current user""" + # batches must have all their pickings available to be selected + self._add_stock_and_assign_pickings_for_batches( + self.batch1 | self.batch2 | self.batch3 + ) + # batch2 in draft but assigned to the current user should be + # selected before the others + self.batch2.user_id = self.env.uid + response = self.service.dispatch("find_batch") + + # The endpoint starts the batch + self.assertEqual(self.batch2.state, "in_progress") + + # we expect to find batch 2 as it's assigned to the current user + self.assert_response( + response, + next_state="confirm_start", + data={ + "id": self.batch2.id, + "name": self.batch2.name, + # TODO + "weight": 0, + "pickings": [ + { + "id": self.batch2.picking_ids.id, + "name": self.batch2.picking_ids.name, + "move_line_count": len(self.batch2.picking_ids.move_line_ids), + "origin": self.batch2.picking_ids.origin, + "partner": { + "id": self.batch2.picking_ids.partner_id.id, + "name": self.batch2.picking_ids.partner_id.name, + }, + } + ], + }, + ) + + def test_find_batch_unassigned_draft(self): + """Find a draft batch""" + # batches must have all their pickings available to be selected + self._add_stock_and_assign_pickings_for_batches(self.batch2 | self.batch3) + # batch1 has not all pickings available, so the first draft + # is batch2, should be selected + response = self.service.dispatch("find_batch") + + # The endpoint starts the batch and assign it to self + self.assertEqual(self.batch2.user_id, self.env.user) + self.assertEqual(self.batch2.state, "in_progress") + + # we expect to find batch 2 as it's the first one with all pickings + # available + self.assert_response( + response, + next_state="confirm_start", + data={ + "id": self.batch2.id, + "name": self.batch2.name, + # TODO + "weight": 0, + "pickings": [ + { + "id": self.batch2.picking_ids.id, + "name": self.batch2.picking_ids.name, + "move_line_count": len(self.batch2.picking_ids.move_line_ids), + "origin": self.batch2.picking_ids.origin, + "partner": { + "id": self.batch2.picking_ids.partner_id.id, + "name": self.batch2.picking_ids.partner_id.name, + }, + } + ], + }, + ) + + def test_find_batch_not_found(self): + """No batch to work on""" + # No batch match the rules to work on them, because + # their pickings are not available + response = self.service.dispatch("find_batch") + + self.assert_response( + response, + next_state="start", + message={ + "message_type": "info", + "message": "No more work to do, please create a new batch transfer", + }, + ) From edfffedee4619bd3ce0746108987b424c8025503 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 17 Feb 2020 11:55:05 +0100 Subject: [PATCH 089/940] cluster_picking: implement /select --- shopfloor/services/cluster_picking.py | 33 ++++++-- shopfloor/services/picking_batch.py | 6 +- shopfloor/tests/test_cluster_picking.py | 104 ++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 7 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index d6bb3915e9..4854799dce 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -87,25 +87,33 @@ def find_batch(self): """ batch_service = self.component(usage="picking_batch") batches = batch_service._search() + selected = self._select_a_picking_batch(batches) + if selected: + return self._response_for_confirm_start(selected) + else: + return self._response_for_no_batch_found() + + # TODO this may be used in other scenarios? if so, extract + def _select_a_picking_batch(self, batches): # look for in progress + assigned to self first candidates = batches.filtered( lambda batch: batch.state == "in_progress" and batch.user_id == self.env.user ) if candidates: - return self._response_for_confirm_start(candidates[0]) + return candidates[0] # then look for draft assigned to self candidates = batches.filtered(lambda batch: batch.user_id == self.env.user) if candidates: batch = candidates[0] batch.write({"state": "in_progress"}) - return self._response_for_confirm_start(batch) + return batch # finally take any batch that search could return if batches: batch = batches[0] batch.write({"user_id": self.env.uid, "state": "in_progress"}) - return self._response_for_confirm_start(batch) - return self._response_for_no_batch_found() + return batch + return self.env["stock.picking.batch"] def _response_for_no_batch_found(self): return self._response( @@ -142,6 +150,15 @@ def _response_for_confirm_start(self, batch): }, ) + def _response_for_batch_cannot_be_selected(self): + return self._response( + next_state="manual_selection", + message={ + "message_type": "warning", + "message": _("This batch cannot be selected."), + }, + ) + def select(self, picking_batch_id): """Manually select a picking batch @@ -159,7 +176,13 @@ def select(self, picking_batch_id): concurrently for instance) * confirm_start: after the batch has been assigned to the user """ - return self._response() + batch_service = self.component(usage="picking_batch") + batch = batch_service._search(batch_ids=[picking_batch_id]) + selected = self._select_a_picking_batch(batch) + if selected: + return self._response_for_confirm_start(selected) + else: + return self._response_for_batch_cannot_be_selected() def confirm_start(self, picking_batch_id): """User confirms they start a batch diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py index e6d22c82c7..90716d83c0 100644 --- a/shopfloor/services/picking_batch.py +++ b/shopfloor/services/picking_batch.py @@ -31,10 +31,12 @@ def _get_base_search_domain(self): ] ) - def _search(self, name_fragment=None): + def _search(self, name_fragment=None, batch_ids=None): domain = self._get_base_search_domain() if name_fragment: - domain.append(("name", "ilike", name_fragment)) + domain = expression.AND([domain, [("name", "ilike", name_fragment)]]) + if batch_ids: + domain = expression.AND([domain, [("id", "in", batch_ids)]]) records = self.env[self._expose_model].search(domain, order="id asc") records = records.filtered( # Include done/cancel because we want to be able to work on the diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index f022cf7faf..64e4c26417 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -179,3 +179,107 @@ def test_find_batch_not_found(self): "message": "No more work to do, please create a new batch transfer", }, ) + + def test_select_in_progress_assigned(self): + """Select an in-progress batch assigned to the current user""" + self._add_stock_and_assign_pickings_for_batches(self.batch1) + self.batch1.write({"state": "in_progress", "user_id": self.env.uid}) + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "select", params={"picking_batch_id": self.batch1.id} + ) + self.assert_response( + response, + next_state="confirm_start", + data={ + "id": self.batch1.id, + "name": self.batch1.name, + # we don't care in these tests, the 'find_batch' tests already + # check this + "weight": self.ANY, + "pickings": self.ANY, + }, + ) + + def test_select_draft_assigned(self): + """Select a draft batch assigned to the current user""" + self._add_stock_and_assign_pickings_for_batches(self.batch1) + self.batch1.write({"user_id": self.env.uid}) + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "select", params={"picking_batch_id": self.batch1.id} + ) + # The endpoint starts the batch and assign it to self + self.assertEqual(self.batch1.user_id, self.env.user) + self.assertEqual(self.batch1.state, "in_progress") + self.assert_response( + response, + next_state="confirm_start", + data={ + "id": self.batch1.id, + "name": self.batch1.name, + # we don't care in these tests, the 'find_batch' tests already + # check this + "weight": self.ANY, + "pickings": self.ANY, + }, + ) + + def test_select_draft_unassigned(self): + """Select a draft batch not assigned to a user""" + self._add_stock_and_assign_pickings_for_batches(self.batch1) + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "select", params={"picking_batch_id": self.batch1.id} + ) + # The endpoint starts the batch and assign it to self + self.assertEqual(self.batch1.user_id, self.env.user) + self.assertEqual(self.batch1.state, "in_progress") + self.assert_response( + response, + next_state="confirm_start", + data={ + "id": self.batch1.id, + "name": self.batch1.name, + # we don't care in these tests, the 'find_batch' tests already + # check this + "weight": self.ANY, + "pickings": self.ANY, + }, + ) + + def test_select_not_exists(self): + """Select a draft that does not exist""" + batch_id = self.batch1.id + self.batch1.unlink() + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "select", params={"picking_batch_id": batch_id} + ) + self.assert_response( + response, + next_state="manual_selection", + message={ + "message_type": "warning", + "message": "This batch cannot be selected.", + }, + ) + + def test_select_already_assigned(self): + """Select a draft that does not exist""" + self._add_stock_and_assign_pickings_for_batches(self.batch1) + self.batch1.write( + {"state": "in_progress", "user_id": self.env.ref("base.user_demo")} + ) + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "select", params={"picking_batch_id": self.batch1.id} + ) + self.assert_response( + response, + next_state="manual_selection", + message={ + "message_type": "warning", + "message": "This batch cannot be selected.", + }, + ) From 04aae0fddbc8abb08f4db0f137ad5df2cbb5e727 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 17 Feb 2020 12:18:02 +0100 Subject: [PATCH 090/940] cluster_picking: implement /unassign --- shopfloor/services/cluster_picking.py | 3 +++ shopfloor/tests/test_cluster_picking.py | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 4854799dce..7b6b5e84bf 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -204,6 +204,9 @@ def unassign(self, picking_batch_id): Transitions: * "start" to work on a new batch """ + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if batch.exists(): + batch.write({"state": "draft", "user_id": False}) return self._response(next_state="start") def scan_line(self, move_line_id, barcode): diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index 64e4c26417..0be61f2fcd 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -283,3 +283,25 @@ def test_select_already_assigned(self): "message": "This batch cannot be selected.", }, ) + + def test_unassign_batch(self): + """User cancels after selecting a batch, unassign it""" + self._add_stock_and_assign_pickings_for_batches(self.batch1) + self.batch1.write({"state": "in_progress", "user_id": self.env.uid}) + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "unassign", params={"picking_batch_id": self.batch1.id} + ) + self.assertEqual(self.batch1.state, "draft") + self.assertFalse(self.batch1.user_id) + self.assert_response(response, next_state="start") + + def test_unassign_batch_not_exists(self): + """User cancels after selecting a batch deleted meanwhile""" + batch_id = self.batch1.id + self.batch1.unlink() + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "unassign", params={"picking_batch_id": batch_id} + ) + self.assert_response(response, next_state="start") From d56b6469aa590844e6a30a891da2a871334e2c04 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 17 Feb 2020 15:46:50 +0100 Subject: [PATCH 091/940] cluster_picking: update docstrings --- shopfloor/services/cluster_picking.py | 35 +++++++++++++++------------ 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 7b6b5e84bf..7ec38bcfef 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -7,7 +7,8 @@ class ClusterPicking(Component): - """Methods for the Cluster Picking Process + """ + Methods for the Cluster Picking Process The goal of this process is to do the pickings for a Picking Batch, for several customers at once. @@ -37,10 +38,11 @@ class ClusterPicking(Component): * If all the goods (move lines) in the roller-cage go to the same destination, a screen asking a single barcode for the destination is shown - * Otherwise, the user has to scan one destination per good. + * Otherwise, the user has to scan one destination per Bin (destination + package of the moves). * If all the goods are supposed to go to the same destination but user doesn't want or can't, a "split" allows to reach the screen to scan one destination - per good. + per Bin. * When everything has a destination set and the batch is not finished yet, the user goes to the first phase of pickings again for the rest. @@ -350,7 +352,7 @@ def change_pack_lot(self, move_line_id, barcode): return self._response() def set_destination_all(self, picking_batch_id, barcode, confirmation=False): - """Set the destination for all the lines with a dest. package of the batch + """Set the destination for all the lines of the batch with a dest. package This method must be used only if all the move lines which have a destination package and qty done have the same destination location. @@ -358,8 +360,6 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): A scanned location outside of the source location of the operation type is invalid. - TODO: remove the destination package or not? - Transitions: * start_line: the batch still have move lines without destination package * unload_all: invalid destination, have to scan a good one @@ -394,7 +394,7 @@ def unload_router(self, picking_batch_id): No side effect in Odoo. Transitions: - * unload_single: if the batch still has lines to unload + * unload_single: if the batch still has packs to unload * start_line: if the batch still has lines to pick * start: if the batch is done. In this case, this method *has* to handle the closing of the batch to create backorders. TODO find a @@ -403,11 +403,11 @@ def unload_router(self, picking_batch_id): """ return self._response() - def unload_scan_pack(self, move_line_id, barcode): + def unload_scan_pack(self, package_id, barcode): """Check that the operator scans the correct package (bin) on unload - If the package is different as the destination package of the move line, - ask to scan it again. + If the scanned barcode is not the one of the Bin (package), ask to scan + again. Transitions: * unload_single: if the barcode does not match @@ -415,16 +415,19 @@ def unload_scan_pack(self, move_line_id, barcode): """ return self._response() - def unload_scan_destination(self, move_line_id, barcode, confirmation=False): - """Scan the final destination of the move line + def unload_scan_destination(self, package_id, barcode, confirmation=False): + """Scan the final destination for all the move lines moved with the Bin - TODO not sure. We have to call action_done on the picking *only when we - have scanned all the move lines* of the picking, so maybe we have to + It updates all the assigned move lines with the package to the + destination. + + TODO not sure: We have to call action_done on the picking *only when we + have scanned all the packages* of the picking, so maybe we have to keep track of this with a new flag on move lines? Transitions: * unload_single: invalid scanned location or error - * unload_single: line is processed and the next line can be unloaded + * unload_single: line is processed and the next bin can be unloaded * confirm_unload_set_destination: the destination is valid but not the expected, ask a confirmation. This state has to call again the endpoint with confirmation=True @@ -521,7 +524,7 @@ def unload_router(self): def unload_scan_pack(self): return { - "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, "barcode": {"required": True, "type": "string"}, } From 82a70e24d96bec0893cf5c4f359ee085fda44a28 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 17 Feb 2020 15:50:23 +0100 Subject: [PATCH 092/940] backend: fix app endpoints --- shopfloor/services/app.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py index aa70424129..80bfbfd444 100644 --- a/shopfloor/services/app.py +++ b/shopfloor/services/app.py @@ -34,8 +34,8 @@ class ShopfloorAppValidatorResponse(Component): _usage = "app.validator.response" def user_config(self): - menu_service = self.component("menu") - profile_service = self.component("profile") + menu_return_validator = self.component("menu.validator.response") + profile_return_validator = self.component("profile.validator.response") return self._response_schema( { "menus": { @@ -43,7 +43,7 @@ def user_config(self): "required": True, "schema": { "type": "dict", - "schema": menu_service._record_return_schema, + "schema": menu_return_validator._record_schema, }, }, "profiles": { @@ -51,7 +51,7 @@ def user_config(self): "required": True, "schema": { "type": "dict", - "schema": profile_service._record_return_schema, + "schema": profile_return_validator._record_schema, }, }, } From fb7050b5a051f94864b58d4d1fabc0a32ad74437 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 17 Feb 2020 16:12:45 +0100 Subject: [PATCH 093/940] backend: Inverse cardinality between process and picking type In the workflows, we need to know which picking type we are working on, so we can't have more than one picking type. Several processes and menus can be created when a scenario can be needed on several picking types. --- shopfloor/__manifest__.py | 2 +- shopfloor/demo/shopfloor_process_demo.xml | 3 +++ shopfloor/demo/stock_picking_type_demo.xml | 3 --- shopfloor/models/shopfloor_process.py | 4 ++-- shopfloor/models/stock_picking_type.py | 7 ++++++- shopfloor/security/ir.model.access.csv | 1 + shopfloor/services/picking_batch.py | 2 +- shopfloor/services/service.py | 16 +++++++++++----- shopfloor/services/single_pack_putaway.py | 2 +- shopfloor/services/single_pack_transfer.py | 4 ++-- shopfloor/tests/test_cluster_picking.py | 2 +- shopfloor/tests/test_picking_batch.py | 2 +- shopfloor/tests/test_single_pack_putaway.py | 4 ++-- shopfloor/tests/test_single_pack_transfer.py | 2 +- shopfloor/views/shopfloor_process.xml | 5 +++-- shopfloor/views/stock_picking_type.xml | 2 +- 16 files changed, 37 insertions(+), 24 deletions(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index d2dbcaa11f..b48d72b819 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -30,8 +30,8 @@ ], "demo": [ "demo/auth_api_key_demo.xml", - "demo/shopfloor_process_demo.xml", "demo/stock_picking_type_demo.xml", + "demo/shopfloor_process_demo.xml", "demo/shopfloor_menu_demo.xml", "demo/shopfloor_operation_group_demo.xml", "demo/shopfloor_profile_demo.xml", diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml index f2aab9b212..660079c145 100644 --- a/shopfloor/demo/shopfloor_process_demo.xml +++ b/shopfloor/demo/shopfloor_process_demo.xml @@ -3,16 +3,19 @@ Put-Away Reach Truck single_pack_putaway + Single Pallet Transfer single_pack_transfer + Cluster Picking cluster_picking + diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml index 80792ca479..653cac7759 100644 --- a/shopfloor/demo/stock_picking_type_demo.xml +++ b/shopfloor/demo/stock_picking_type_demo.xml @@ -13,7 +13,6 @@ internal - @@ -30,7 +29,6 @@ internal - @@ -47,7 +45,6 @@ internal - diff --git a/shopfloor/models/shopfloor_process.py b/shopfloor/models/shopfloor_process.py index 24890d6a59..bff78f93e3 100644 --- a/shopfloor/models/shopfloor_process.py +++ b/shopfloor/models/shopfloor_process.py @@ -7,8 +7,8 @@ class ShopfloorProcess(models.Model): name = fields.Char(required=True) code = fields.Selection(selection="_selection_code", required=True) - picking_type_ids = fields.One2many( - "stock.picking.type", "process_id", string="Operation types" + picking_type_id = fields.Many2one( + comodel_name="stock.picking.type", string="Operation Type" ) menu_ids = fields.One2many(comodel_name="shopfloor.menu", inverse_name="process_id") diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py index 808bf50552..80374d89b9 100644 --- a/shopfloor/models/stock_picking_type.py +++ b/shopfloor/models/stock_picking_type.py @@ -4,4 +4,9 @@ class StockPickingType(models.Model): _inherit = "stock.picking.type" - process_id = fields.Many2one("shopfloor.process", string="Process") + process_ids = fields.One2many( + comodel_name="shopfloor.process", + inverse_name="picking_type_id", + string="Shopfloor Processes", + readonly=True, + ) diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv index a616c579ee..41e5dd07c5 100644 --- a/shopfloor/security/ir.model.access.csv +++ b/shopfloor/security/ir.model.access.csv @@ -6,3 +6,4 @@ "access_shopfloor_profile_users","shopfloor profile","model_shopfloor_profile",,1,0,0,0 "access_shopfloor_profile_stock_manager","shopfloor profile inventory manager","model_shopfloor_profile","stock.group_stock_manager",1,1,1,1 "access_shopfloor_process_users","shopfloor process","model_shopfloor_process",,1,0,0,0 +"access_shopfloor_process_stock_manager","shopfloor process inventory manager","model_shopfloor_process","stock.group_stock_manager",1,1,1,1 diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py index 90716d83c0..83cb0b2d71 100644 --- a/shopfloor/services/picking_batch.py +++ b/shopfloor/services/picking_batch.py @@ -52,7 +52,7 @@ def _search(self, name_fragment=None, batch_ids=None): batch.state == "in_progress" or picking.state in ("assigned", "done", "cancel") ) - and picking.picking_type_id in self.picking_types + and picking.picking_type_id == self.picking_type for picking in batch.picking_ids ) ) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index e34e6c45d5..cd2638fc2e 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -1,4 +1,4 @@ -from odoo import _, fields +from odoo import _, exceptions, fields from odoo.exceptions import MissingError from odoo.osv import expression @@ -24,13 +24,19 @@ class BaseShopfloorService(AbstractComponent): _expose_model = None @property - def picking_types(self): + def picking_type(self): """ Get the current picking type based on the menu and the warehouse of the profile. """ - return self.work.menu.process_id.picking_type_ids.filtered( - lambda p: p.warehouse_id == self.work.profile.warehouse_id - ) + picking_type = self.work.menu.process_id.picking_type_id + if picking_type.warehouse_id != self.work.profile.warehouse_id: + raise exceptions.UserError( + _("Process {} cannot be used on warehouse {}").format( + picking_type.display_name, + self.work.profile.warehouse_id.display_name, + ) + ) + return picking_type def _get(self, _id): domain = expression.normalize_domain(self._get_base_search_domain()) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index b365f644b4..a4239c0880 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -86,7 +86,7 @@ def _response_for_start_success(self, move_line, pack): def start(self, barcode): """Scan a pack barcode""" - picking_type = self.picking_types + picking_type = self.picking_type if len(picking_type) > 1: return self._response_for_several_picking_types() elif not picking_type: diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 4cb233bc69..550ee27ec8 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -84,7 +84,7 @@ def _response_for_start_success(self, move_line, pack): def start(self, barcode): search = self.actions_for("search") - picking_type = self.picking_types + picking_type = self.picking_type if len(picking_type) > 1: return self._response_for_several_picking_types() @@ -113,7 +113,7 @@ def start(self, barcode): [ ("package_id", "=", pack.id), ("state", "!=", "done"), - ("picking_id.picking_type_id", "in", self.picking_types.ids), + ("picking_id.picking_type_id", "=", self.picking_type.id), ] ) if not existing_operations: diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index 0be61f2fcd..7b12480303 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -17,7 +17,7 @@ def setUpClass(cls, *args, **kwargs): cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id - cls.picking_type = cls.process.picking_type_ids + cls.picking_type = cls.process.picking_type_id cls.batch1 = cls._create_picking_batch(cls.product_a) cls.batch2 = cls._create_picking_batch(cls.product_a) cls.batch3 = cls._create_picking_batch(cls.product_a) diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py index 374261de59..c11bee3a06 100644 --- a/shopfloor/tests/test_picking_batch.py +++ b/shopfloor/tests/test_picking_batch.py @@ -18,7 +18,7 @@ def setUpClass(cls, *args, **kwargs): cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id - cls.picking_type = cls.process.picking_type_ids + cls.picking_type = cls.process.picking_type_id cls.batch1 = cls._create_picking_batch(cls.product_a) cls.batch2 = cls._create_picking_batch(cls.product_a) cls.batch3 = cls._create_picking_batch(cls.product_a) diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 640fc1aa7a..1ff1fefe84 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -145,7 +145,7 @@ def test_start_package_not_in_src_location(self): "message": "You cannot work on a package (%s) outside of location: %s" % ( self.pack_a.name, - self.process.picking_type_ids.default_location_src_id.name, + self.process.picking_type_id.default_location_src_id.name, ), }, ) @@ -239,7 +239,7 @@ def _simulate_started(self): Used to test the next endpoints (/validate and /cancel) """ picking_form = Form(self.env["stock.picking"]) - picking_form.picking_type_id = self.menu.process_id.picking_type_ids + picking_form.picking_type_id = self.menu.process_id.picking_type_id with picking_form.move_ids_without_package.new() as move: move.product_id = self.product_a move.product_uom_qty = 1 diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index acbd4aef88..7679bef1b7 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -25,7 +25,7 @@ def setUpClass(cls, *args, **kwargs): cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id - cls.picking_type = cls.process.picking_type_ids + cls.picking_type = cls.process.picking_type_id cls.picking = cls._create_initial_move() # disable the completion on the picking type, we'll have specific test(s) diff --git a/shopfloor/views/shopfloor_process.xml b/shopfloor/views/shopfloor_process.xml index db58ed0b34..25821c60fc 100644 --- a/shopfloor/views/shopfloor_process.xml +++ b/shopfloor/views/shopfloor_process.xml @@ -7,7 +7,7 @@ - + @@ -24,7 +24,7 @@ - + @@ -39,6 +39,7 @@ + diff --git a/shopfloor/views/stock_picking_type.xml b/shopfloor/views/stock_picking_type.xml index 1ccb8c591b..c5ba2996ff 100644 --- a/shopfloor/views/stock_picking_type.xml +++ b/shopfloor/views/stock_picking_type.xml @@ -6,7 +6,7 @@ - + From 9a79da04a775a1e60ce9127b076f0e1e97c8702e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 18 Feb 2020 14:45:41 +0100 Subject: [PATCH 094/940] app api: fix response by converting to json Previously, _search was converting to json, which is no longer the case since 186bb7b so we can use _search internally. --- shopfloor/services/app.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py index 80bfbfd444..997c33eda4 100644 --- a/shopfloor/services/app.py +++ b/shopfloor/services/app.py @@ -10,8 +10,10 @@ class ShopfloorApp(Component): _description = __doc__ def user_config(self): - menus = self.component("menu")._search() - profiles = self.component("profile")._search() + menu_comp = self.component("menu") + profiles_comp = self.component("profile") + menus = menu_comp._to_json(menu_comp._search()) + profiles = profiles_comp._to_json(profiles_comp._search()) return self._response(data={"menus": menus, "profiles": profiles}) From d6686ab34e499a1d0cf09d1a49807f210154ed6f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 18 Feb 2020 15:20:34 +0100 Subject: [PATCH 095/940] backend tests: simplify asserts using self.ANY --- shopfloor/tests/common.py | 64 ++++++++------------------------------- 1 file changed, 12 insertions(+), 52 deletions(-) diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 386d0c4956..bae79cc4ce 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -1,5 +1,4 @@ from contextlib import contextmanager -from copy import deepcopy from pprint import pformat from odoo.tests.common import SavepointCase @@ -19,12 +18,17 @@ def __deepcopy__(self, memodict=None): def __copy__(self): return self + def __eq__(self, other): + return True + class CommonCase(SavepointCase, ComponentMixin): # by default disable tracking suite-wise, it's a time saver :) tracking_disable = True + ANY = AnyObject() + @contextmanager def work_on_services(self, **params): params = params or {} @@ -66,8 +70,7 @@ def setUpClassVars(cls): def assert_response(self, response, next_state=None, message=None, data=None): """Assert a response from the webservice - The data and message dictionaries are checked using - ``self.assert_dict``, which means we can use ``self.ANY`` to accept any + The data and message dictionaries can use ``self.ANY`` to accept any value. """ expected = {} @@ -79,55 +82,12 @@ def assert_response(self, response, next_state=None, message=None, data=None): ) elif data: expected["data"] = data - self.assert_dict(response, expected) - - ANY = AnyObject() - - def assert_dict(self, current, expected): - """Assert dictionary equality with support of wildcard values - - In the expected dictionary, instead of a value, ``self.ANY`` - can be provided, which will accept any value. - - For instance, an expected value for a response which accepts any - content could be:: - - { - "data": self.ANY, - "message": { - "message_type": self.ANY, - "message": self.ANY, - }, - "next_state": self.ANY, - } - - Note: if ``self.ANY`` is used, the key must exist in the dictionary - to check. - """ - next_checks = [(current, expected)] - while next_checks: - original, node_original_expected = next_checks.pop() - node_values = deepcopy(original) - node_expected = deepcopy(node_original_expected) - - for key in original: - # sub-dictionaries will be checked later - expected_value = node_expected.get(key) - if expected_value is self.ANY: - # ignore 'any' keys - node_values.pop(key) - node_expected.pop(key) - continue - if isinstance(expected_value, dict): - next_checks.append((node_values.pop(key), node_expected.pop(key))) - continue - - self.assertDictEqual( - node_values, - node_expected, - "\n\nActual:\n%s" - "\n\nExpected:\n%s" % (pformat(current), pformat(expected)), - ) + self.assertDictEqual( + response, + expected, + "\n\nActual:\n%s" + "\n\nExpected:\n%s" % (pformat(response), pformat(expected)), + ) def _update_qty_in_location(self, location, product, quantity): self.env["stock.quant"]._update_available_quantity(product, location, quantity) From e9fa321943707294d98e1d5b0bbfc45f3d9edeb9 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 18 Feb 2020 15:28:14 +0100 Subject: [PATCH 096/940] backend: add tests for app/menu/profiles search --- shopfloor/tests/__init__.py | 3 ++ shopfloor/tests/test_app.py | 51 +++++++++++++++++++++++++++++++++ shopfloor/tests/test_menu.py | 40 ++++++++++++++++++++++++++ shopfloor/tests/test_profile.py | 35 ++++++++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 shopfloor/tests/test_app.py create mode 100644 shopfloor/tests/test_menu.py create mode 100644 shopfloor/tests/test_profile.py diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index b9d6b8a20e..91bcbf4ea5 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -1,3 +1,6 @@ +from . import test_app +from . import test_menu +from . import test_profile from . import test_picking_batch from . import test_single_pack_putaway from . import test_single_pack_transfer diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py new file mode 100644 index 0000000000..e431ba588b --- /dev/null +++ b/shopfloor/tests/test_app.py @@ -0,0 +1,51 @@ +from .common import CommonCase + + +class AppCase(CommonCase): + def setUp(self): + super().setUp() + with self.work_on_services() as work: + self.service = work.component(usage="app") + + def test_to_openapi(self): + # will raise if it fails to generate the openapi specs + self.service.to_openapi() + + def test_user_config(self): + """Request /app/user_config""" + # Simulate the client asking the configuration + response = self.service.dispatch("user_config") + self.assert_response( + response, + data={ + "menus": [ + { + "id": self.ANY, + "name": "Put-Away Reach Truck", + "process": {"id": self.ANY, "code": "single_pack_putaway"}, + }, + { + "id": self.ANY, + "name": "Single Pallet Transfer", + "process": {"id": self.ANY, "code": "single_pack_transfer"}, + }, + { + "id": self.ANY, + "name": "Cluster Picking", + "process": {"id": self.ANY, "code": "cluster_picking"}, + }, + ], + "profiles": [ + { + "id": self.ANY, + "name": "Highbay Truck", + "warehouse": {"id": self.ANY, "name": "YourCompany"}, + }, + { + "id": self.ANY, + "name": "Shelf 1", + "warehouse": {"id": self.ANY, "name": "YourCompany"}, + }, + ], + }, + ) diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py new file mode 100644 index 0000000000..98952c5c2e --- /dev/null +++ b/shopfloor/tests/test_menu.py @@ -0,0 +1,40 @@ +from .common import CommonCase + + +class MenuCase(CommonCase): + def setUp(self): + super().setUp() + with self.work_on_services() as work: + self.service = work.component(usage="menu") + + def test_to_openapi(self): + # will raise if it fails to generate the openapi specs + self.service.to_openapi() + + def test_menu_search(self): + """Request /menu/search""" + # Simulate the client searching menus + response = self.service.dispatch("search") + self.assert_response( + response, + data={ + "size": 3, + "records": [ + { + "id": self.ANY, + "name": "Put-Away Reach Truck", + "process": {"id": self.ANY, "code": "single_pack_putaway"}, + }, + { + "id": self.ANY, + "name": "Single Pallet Transfer", + "process": {"id": self.ANY, "code": "single_pack_transfer"}, + }, + { + "id": self.ANY, + "name": "Cluster Picking", + "process": {"id": self.ANY, "code": "cluster_picking"}, + }, + ], + }, + ) diff --git a/shopfloor/tests/test_profile.py b/shopfloor/tests/test_profile.py new file mode 100644 index 0000000000..7a2fc1f4e0 --- /dev/null +++ b/shopfloor/tests/test_profile.py @@ -0,0 +1,35 @@ +from .common import CommonCase + + +class ProfileCase(CommonCase): + def setUp(self): + super().setUp() + with self.work_on_services() as work: + self.service = work.component(usage="profile") + + def test_to_openapi(self): + # will raise if it fails to generate the openapi specs + self.service.to_openapi() + + def test_profile_search(self): + """Request /profile/search""" + # Simulate the client searching profiles + response = self.service.dispatch("search") + self.assert_response( + response, + data={ + "size": 2, + "records": [ + { + "id": self.ANY, + "name": "Highbay Truck", + "warehouse": {"id": self.ANY, "name": "YourCompany"}, + }, + { + "id": self.ANY, + "name": "Shelf 1", + "warehouse": {"id": self.ANY, "name": "YourCompany"}, + }, + ], + }, + ) From 20868ea41d0356946fbf68550b220e87d2e4b422 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 20 Feb 2020 10:14:08 +0100 Subject: [PATCH 097/940] rest api: Start documenting Checkout service --- shopfloor/services/__init__.py | 12 +- shopfloor/services/checkout.py | 324 +++++++++++++++++++++++++++++++ shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_checkout.py | 28 +++ 4 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 shopfloor/services/checkout.py create mode 100644 shopfloor/tests/test_checkout.py diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 7a137fed0d..6a8054d8e0 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -1,11 +1,17 @@ +# core classes from . import service from . import validator + +# generic services from . import app -from . import profile +from . import location from . import menu from . import pack -from . import location +from . import profile + +# process services +from . import checkout from . import cluster_picking +from . import picking_batch from . import single_pack_putaway from . import single_pack_transfer -from . import picking_batch diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py new file mode 100644 index 0000000000..6e2c95d8b4 --- /dev/null +++ b/shopfloor/services/checkout.py @@ -0,0 +1,324 @@ +from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import Component + +from .service import to_float + + +class Checkout(Component): + """ + Methods for the Checkout Process + + This process runs on existing moves. + It happens on the "Packing" step of a pick/pack/ship. + + Use cases: + + 1) Products are packed (e.g. full pallet shipping) and we keep the packages + 2) Products are packed (e.g. rollercage bins) and we create a new package + with same content for shipping + 3) Products are packed (e.g. half-pallet ) and we merge several into one + 4) Products are packed (e.g. too high pallet) and we split it on several + 5) Products are not packed (e.g. raw products) and we create new packages + 6) Products are not packed (e.g. raw products) and we do not create packages + + Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP + """ + + _inherit = "base.shopfloor.service" + _name = "shopfloor.checkout" + _usage = "checkout" + _description = __doc__ + + def scan_document(self, barcode): + """Scan a package, a stock.picking or a location + + When a location is scanned, if all the move lines from this destination + are for the same stock.picking, the stock.picking is used for the + next steps. + + When a package is scanned, if the package has a move line to move it + from a location/sublocation of the current stock.picking.type, the + stock.picking for the package is used for the next steps. + + When a stock.picking is scanned, it is used for the next steps. + + In every case above, the stock.picking must be entirely available and + must match the current picking type. + + Transitions: + * select_document: when no stock.picking could be found + * select_line: a stock.picking is selected + """ + return self._response() + + def list_stock_picking(self): + """List stock.picking records available + + Returns a list of all the available records for the current picking + type. + + Transitions: + * manual_selection: to the selection screen + """ + return self._response() + + def select(self, picking_id): + """Select a stock picking for the process + + Used from the list of stock pickings (manual_selection), from there, + the user can click on a stock.picking record which calls this method. + + The ``list_stock_picking`` returns only the valid records (same picking + type, fully available, ...), but this method has to check again in case + something changed since the list was sent to the client. + + Transitions: + * manual_selection: stock.picking could finally not be selected (not + available, ...) + * summary: goes straight to this state used to set the moves as done when + all the move lines with a reserved quantity have a 'quantity done' + * select_line: the "normal" case, when the user has to put in pack/move + lines + """ + return self._response() + + def scan_line(self, picking_id, barcode): + """Scan move lines of the stock picking + + It allows to select move lines of the stock picking for the next + screen. Lines can be found either by scanning a package, a product or a + lot. + + There should be no ambiguity, so for instance if a product is scanned but + several packs contain it, the endpoint will ask to scan a pack; if the + product is tracked by lot, to scan a lot. + + Once move lines are found, their ``qty_done`` is set to their reserved + quantity. + + Transitions: + * select_line: nothing could be found for the barcode + * select_pack: lines are selected, user is redirected to this + screen to change the qty done and destination pack if needed + """ + return self._response() + + def select_line(self, picking_id, package_id=None, move_line_id=None): + """Select move lines of the stock picking + + This is the same as ``scan_line``, except that a package id or a + move_line_id is given by the client (user clicked on a list). + + It returns a list of move line ids that will be displayed by the + screen ``select_pack``. This screen will have to send this list to + the endpoints it calls, so we can select/deselect lines but still + show them in the list of the client application. + + Transitions: + * select_line: nothing could be found for the barcode + * select_pack: lines are selected, user is redirected to this + screen to change the qty done and destination pack if needed + """ + assert package_id or move_line_id + return self._response() + + def reset_line_qty(self, move_line_id): + """Reset qty_done of a move line to zero + + Used to deselect a line in the "select_pack" screen. + + Transitions: + * select_pack: goes back to the same state, the line will appear + as deselected + """ + return self._response() + + def set_line_qty(self, move_line_id): + """Set qty_done of a move line to its reserved quantity + + Used to deselect a line in the "select_pack" screen. + + Transitions: + * select_pack: goes back to the same state, the line will appear + as selected + """ + return self._response() + + def scan_pack_action(self, move_line_ids, barcode): + """Scan a package, a lot, a product or a package to handle a line + + When a package is scanned, if the package is known as the destination + package of one of the lines or is the source package of a selected + line, the package is set to be the destination package of all then + selected lines. + + When a product is scanned, it selects (set qty_done = reserved qty) or + deselects (set qty_done = 0) the move lines for this product. Only + products not tracked by lot can use this. + + When a lot is scanned, it does the same as for the products but based + on the lot. + + When a packaging type (one without related product) is scanned, a new + package is created and set as destination of the selected lines. + + Selected lines are move lines in the list of ``move_line_ids`` where + ``qty_done`` > 0 and have no destination package. + + Transitions: + * select_pack: when a product or lot is scanned to select/deselect, + the client app has to show the same screen with the updated selection + * select_line: when a package or packaging type is scanned, move lines + have been put in package and we can return back to this state to handle + the other lines + * summary: if there is no other lines, go to the summary screen to be able + to close the stock picking + """ + return self._response() + + def set_custom_qty(self, move_line_id, qty_done): + """Change qty_done of a move line with a custom value + + Transitions: + * select_pack: goes back to this screen showing all the lines after + we changed the qty + """ + return self._response() + + def new_package(self, move_line_ids): + """Add all selected lines in a new package + + It creates a new package and set it as the destination package of all + the selected lines. + + Selected lines are move lines in the list of ``move_line_ids`` where + ``qty_done`` > 0 and have no destination package. + + Transitions: + * select_line: goes back to selection of lines to work on next lines + """ + return self._response() + + # TODO add the rest of the methods + + +class ShopfloorCheckoutValidator(Component): + """Validators for the Checkout endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.checkout.validator" + _usage = "checkout.validator" + + def scan_document(self): + return {"barcode": {"required": True, "type": "string"}} + + def list_stock_picking(self): + return {} + + def select(self): + return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def scan_line(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def select_line(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": False, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": False, "type": "integer"}, + } + + def reset_line_qty(self): + return {"move_line_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def set_line_qty(self): + return {"move_line_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def scan_pack_action(self): + return { + "move_line_ids": { + "type": "list", + "required": True, + "schema": {"coerce": to_int, "required": True, "type": "integer"}, + }, + "barcode": {"required": True, "type": "string"}, + } + + def set_custom_qty(self): + return { + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "qty_done": {"coerce": to_float, "required": True, "type": "float"}, + } + + def new_package(self): + return { + "move_line_ids": { + "type": "list", + "required": True, + "schema": {"coerce": to_int, "required": True, "type": "integer"}, + } + } + + +class ShopfloorCheckoutValidatorResponse(Component): + """Validators for the Checkout endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.checkout.validator.response" + _usage = "checkout.validator.response" + + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + # TODO schemas + return { + "select_document": {}, + "manual_selection": {}, + "select_line": {}, + "select_pack": {}, + "change_quantity": {}, + "select_dest_package": {}, + "summary": {}, + "change_package_type": {}, + "confirm_done": {}, + } + + def scan_document(self): + return self._response_schema(next_states=["select_document", "select_line"]) + + def list_stock_picking(self): + return self._response_schema(next_states=["manual_selection"]) + + def select(self): + return self._response_schema( + next_states=["manual_selection", "summary", "select_line"] + ) + + def scan_line(self): + return self._response_schema(next_states=["select_line", "select_pack"]) + + def select_line(self): + return self.scan_line() + + def reset_line_qty(self): + return self._response_schema(next_states=["select_pack"]) + + def set_line_qty(self): + return self._response_schema(next_states=["select_pack"]) + + def scan_pack_action(self): + return self._response_schema( + next_states=["select_pack", "select_line", "summary"] + ) + + def set_custom_qty(self): + return self._response_schema(next_states=["select_pack"]) + + def new_package(self): + return self._response_schema(next_states=["select_line"]) diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 91bcbf4ea5..e4331331dd 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -5,3 +5,4 @@ from . import test_single_pack_putaway from . import test_single_pack_transfer from . import test_cluster_picking +from . import test_checkout diff --git a/shopfloor/tests/test_checkout.py b/shopfloor/tests/test_checkout.py new file mode 100644 index 0000000000..7cd5133cdd --- /dev/null +++ b/shopfloor/tests/test_checkout.py @@ -0,0 +1,28 @@ +from .common import CommonCase + + +class CheckoutCase(CommonCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.product_a = cls.env["product.product"].create( + {"name": "Product A", "type": "product"} + ) + cls.product_b = cls.env["product.product"].create( + {"name": "Product B", "type": "product"} + ) + # TODO + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") + cls.process = cls.menu.process_id + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + cls.wh = cls.profile.warehouse_id + cls.picking_type = cls.process.picking_type_id + + def setUp(self): + super().setUp() + with self.work_on_services(menu=self.menu, profile=self.profile) as work: + self.service = work.component(usage="checkout") + + def test_to_openapi(self): + # will raise if it fails to generate the openapi specs + self.service.to_openapi() From fd8b843458d1dc1017098ca1aae44003cea13d34 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 20 Feb 2020 11:36:23 +0100 Subject: [PATCH 098/940] cluster_picking: add list_batch endpoint --- shopfloor/services/cluster_picking.py | 26 +++++++++++++- shopfloor/services/picking_batch.py | 9 +++-- shopfloor/tests/test_cluster_picking.py | 46 +++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 7ec38bcfef..9de530828c 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -95,6 +95,19 @@ def find_batch(self): else: return self._response_for_no_batch_found() + def list_batch(self): + """List picking batch on which user can work + + Returns a list of all the available records for the current picking + type. + + Transitions: + * manual_selection: to the selection screen + """ + batch_service = self.component(usage="picking_batch") + batches = batch_service.search()["data"] + return self._response(next_state="manual_selection", data=batches) + # TODO this may be used in other scenarios? if so, extract def _select_a_picking_batch(self, batches): # look for in progress + assigned to self first @@ -454,6 +467,9 @@ class ShopfloorClusterPickingValidator(Component): def find_batch(self): return {} + def list_batch(self): + return {} + def select(self): return { "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"} @@ -553,7 +569,7 @@ def _states(self): "confirm_start": self._schema_for_batch_details, "start_line": self._schema_for_single_line_details, "start": {}, - "manual_selection": {}, + "manual_selection": self._schema_for_batch_selection, "scan_destination": self._schema_for_single_line_details, "zero_check": self._schema_for_zero_check, "unload_all": self._schema_for_unload_all, @@ -567,6 +583,9 @@ def _states(self): def find_batch(self): return self._response_schema(next_states=["confirm_start", "start"]) + def list_batch(self): + return self._response_schema(next_states=["manual_selection"]) + def select(self): return self._response_schema(next_states=["manual_selection", "confirm_start"]) @@ -849,3 +868,8 @@ def _schema_for_completion_info(self): "picking_done": {"type": "string", "nullable": False, "required": True}, "picking_next": {"type": "string", "nullable": False, "required": True}, } + + @property + def _schema_for_batch_selection(self): + batch_validator = self.component(usage="picking_batch.validator.response") + return batch_validator.search()["data"]["schema"] diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py index 83cb0b2d71..2838373dc3 100644 --- a/shopfloor/services/picking_batch.py +++ b/shopfloor/services/picking_batch.py @@ -1,6 +1,5 @@ from odoo.osv import expression -from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -104,7 +103,7 @@ class ShopfloorPickingBatchValidatorResponse(Component): def search(self): return self._response_schema( { - "size": {"coerce": to_int, "required": True, "type": "integer"}, + "size": {"required": True, "type": "integer"}, "records": { "type": "list", "required": True, @@ -116,8 +115,8 @@ def search(self): @property def _record_schema(self): return { - "id": {"coerce": to_int, "required": True, "type": "integer"}, + "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, - "picking_count": {"coerce": to_int, "required": True, "type": "integer"}, - "move_line_count": {"coerce": to_int, "required": True, "type": "integer"}, + "picking_count": {"required": True, "type": "integer"}, + "move_line_count": {"required": True, "type": "integer"}, } diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index 7b12480303..e32c957a93 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -18,9 +18,12 @@ def setUpClass(cls, *args, **kwargs): cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id cls.picking_type = cls.process.picking_type_id + # drop base demo data and create our own batches to work with + cls.env["stock.picking.batch"].search([]).unlink() cls.batch1 = cls._create_picking_batch(cls.product_a) cls.batch2 = cls._create_picking_batch(cls.product_a) cls.batch3 = cls._create_picking_batch(cls.product_a) + cls.batch4 = cls._create_picking_batch(cls.product_a) def setUp(self): super().setUp() @@ -180,6 +183,49 @@ def test_find_batch_not_found(self): }, ) + def test_list_batch(self): + """List all available batches""" + # batches must have all their pickings available to be selected + self._add_stock_and_assign_pickings_for_batches( + self.batch1 | self.batch2 | self.batch3 + ) + self.batch1.write({"state": "in_progress", "user_id": self.env.uid}) + self.batch2.write( + {"state": "in_progress", "user_id": self.env.ref("base.user_demo")} + ) + self.batch3.write({"state": "draft", "user_id": False}) + + self.assertEqual( + self.env["stock.picking.batch"].search([]), + self.batch1 + self.batch2 + self.batch3 + self.batch4, + ) + # Simulate the client asking the list of batches + response = self.service.dispatch("list_batch") + self.assert_response( + response, + next_state="manual_selection", + data={ + "size": 2, + "records": [ + { + "id": self.batch1.id, + "name": self.batch1.name, + "picking_count": 1, + "move_line_count": 1, + }, + # batch 2 is excluded because assigned to someone else + { + "id": self.batch3.id, + "name": self.batch3.name, + "picking_count": 1, + "move_line_count": 1, + }, + # batch 4 is excluded because not all of its pickings are + # assigned + ], + }, + ) + def test_select_in_progress_assigned(self): """Select an in-progress batch assigned to the current user""" self._add_stock_and_assign_pickings_for_batches(self.batch1) From 846e3c510af85fb0585b05f208638b50cab6b27f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 20 Feb 2020 14:47:03 +0100 Subject: [PATCH 099/940] cluster_picking: add /confirm_start Refactor cluster_picking tests along the way to share "pre-conditions" setup for endpoints. --- shopfloor/actions/message.py | 6 + shopfloor/services/cluster_picking.py | 75 ++++++++++- shopfloor/services/service.py | 9 +- shopfloor/tests/common.py | 39 +++++- shopfloor/tests/test_cluster_picking.py | 159 +++++++++++++++++++----- shopfloor/tests/test_picking_batch.py | 23 +--- 6 files changed, 245 insertions(+), 66 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 98557b39cd..0cc3d56ee2 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -59,6 +59,12 @@ def operation_not_found(self): "message": _("This operation does not exist anymore."), } + def record_not_found(self): + return { + "message_type": "error", + "message": _("This record you were working on does not exist anymore."), + } + def operation_has_been_canceled_elsewhere(self): return { "message_type": "warning", diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 9de530828c..65d517f896 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -167,7 +167,7 @@ def _response_for_confirm_start(self, batch): def _response_for_batch_cannot_be_selected(self): return self._response( - next_state="manual_selection", + base_response=self.list_batch(), message={ "message_type": "warning", "message": _("This batch cannot be selected."), @@ -211,7 +211,61 @@ def confirm_start(self, picking_batch_id): package * start: if the condition above is wrong (rare case of race condition...) """ - return self._response() + picking_batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not picking_batch.exists(): + return self._response_batch_does_not_exist() + + remaining_lines = picking_batch.mapped("picking_ids.move_line_ids").filtered( + lambda l: l.state == "assigned" + ) + if not remaining_lines: + # TODO + pass + + return self._response( + next_state="start_line", data=self._data_for_next_move_line(remaining_lines) + ) + + def _response_batch_does_not_exist(self): + message = self.actions_for("message") + return self._response(next_state="start", message=message.record_not_found()) + + def _data_for_next_move_line(self, move_lines): + line = move_lines[0] + picking = line.picking_id + batch = picking.batch_id + product = line.product_id + lot = line.lot_id + package = line.package_id + return { + # TODO have common methods to return general info + # for each model + "id": line.id, + "quantity": line.product_uom_qty, + "picking": { + "id": picking.id, + "name": picking.name, + "origin": picking.origin or "", + "note": picking.note or "", + }, + "batch": {"id": batch.id, "name": batch.name}, + "product": { + "id": product.id, + "name": product.name, + "display_name": product.display_name, + "default_code": product.default_code or "", + "qty_available": product.qty_available, + }, + "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or ""} + if lot + else None, + "location_src": {"id": line.location_id.id, "name": line.location_id.name}, + "location_dst": { + "id": line.location_dest_id.id, + "name": line.location_dest_id.name, + }, + "pack": {"id": package.id, "name": package.name} if package else None, + } def unassign(self, picking_batch_id): """Unassign and reset to draft a started picking batch @@ -754,6 +808,7 @@ def _schema_for_single_line_details(self): return { # id is a stock.move.line "id": {"required": True, "type": "integer"}, + "quantity": {"type": "float", "required": True}, "picking": { "type": "dict", "schema": { @@ -770,13 +825,21 @@ def _schema_for_single_line_details(self): "name": {"type": "string", "nullable": False, "required": True}, }, }, - "quantity": {"type": "float", "required": True}, "product": { "type": "dict", "schema": { "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, - "ref": {"type": "string", "nullable": False, "required": True}, + "display_name": { + "type": "string", + "nullable": False, + "required": True, + }, + "default_code": { + "type": "string", + "nullable": False, + "required": True, + }, "qty_available": { "type": "float", "nullable": False, @@ -786,6 +849,8 @@ def _schema_for_single_line_details(self): }, "lot": { "type": "dict", + "required": False, + "nullable": True, "schema": { "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, @@ -809,6 +874,8 @@ def _schema_for_single_line_details(self): }, "pack": { "type": "dict", + "required": False, + "nullable": True, "schema": { "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index cd2638fc2e..2d82ba2f1a 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -76,11 +76,13 @@ def _get_output_validator(self, method_name): ) return validator_component._get_validator(method_name) - def _response(self, data=None, next_state=None, message=None): + def _response(self, base_response=None, data=None, next_state=None, message=None): """Base "envelope" for the responses All the keys are optional. + :param base_response: optional dictionary of values to extend + (typically already created by a call to _response()) :param data: dictionary of values, when a next_state is provided, the data is enclosed in a key of the same name (to support polymorphism in the schema) @@ -89,7 +91,10 @@ def _response(self, data=None, next_state=None, message=None): :param message: dictionary for the message to show in the client application (see ``_response_schema`` for the keys) """ - response = {} + if base_response: + response = base_response.copy() + else: + response = {} if next_state: # data for a state is always enclosed in a key with the name # of the state, so an endpoint can return to different states diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index bae79cc4ce..06f866967e 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -1,7 +1,7 @@ from contextlib import contextmanager from pprint import pformat -from odoo.tests.common import SavepointCase +from odoo.tests.common import Form, SavepointCase from odoo.addons.base_rest.controllers.main import _PseudoCollection from odoo.addons.component.core import WorkContext @@ -89,14 +89,43 @@ def assert_response(self, response, next_state=None, message=None, data=None): "\n\nExpected:\n%s" % (pformat(response), pformat(expected)), ) - def _update_qty_in_location(self, location, product, quantity): - self.env["stock.quant"]._update_available_quantity(product, location, quantity) + @classmethod + def _update_qty_in_location(cls, location, product, quantity): + cls.env["stock.quant"]._update_available_quantity(product, location, quantity) - def _fill_stock_for_pickings(self, pickings): + @classmethod + def _fill_stock_for_pickings(cls, pickings): product_locations = {} for move in pickings.mapped("move_lines"): key = (move.product_id, move.location_id) product_locations.setdefault(key, 0) product_locations[key] += move.product_qty for (product, location), qty in product_locations.items(): - self._update_qty_in_location(location, product, qty) + cls._update_qty_in_location(location, product, qty) + + +class PickingBatchMixin: + @classmethod + def _create_picking_batch(cls, product): + picking_form = Form(cls.env["stock.picking"]) + picking_form.picking_type_id = cls.picking_type + picking_form.location_id = cls.stock_location + picking_form.location_dest_id = cls.packing_location + picking_form.origin = "test {}".format(product.name) + picking_form.partner_id = cls.customer + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = 1 + picking = picking_form.save() + picking.action_confirm() + picking.action_assign() + + batch_form = Form(cls.env["stock.picking.batch"]) + batch_form.picking_ids.add(picking) + return batch_form.save() + + @classmethod + def _add_stock_and_assign_pickings_for_batches(cls, batches): + pickings = batches.mapped("picking_ids") + cls._fill_stock_for_pickings(pickings) + pickings.action_assign() diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index e32c957a93..d485ce770e 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -1,29 +1,23 @@ -from odoo.tests.common import Form +import unittest -from .common import CommonCase +from .common import CommonCase, PickingBatchMixin -class ClusterPickingCase(CommonCase): +class ClusterPickingCommonCase(CommonCase, PickingBatchMixin): @classmethod def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) cls.product_a = cls.env["product.product"].create( - {"name": "Product A", "type": "product"} + {"name": "Product A", "type": "product", "default_code": "A"} ) cls.product_b = cls.env["product.product"].create( - {"name": "Product B", "type": "product"} + {"name": "Product B", "type": "product", "default_code": "B"} ) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id cls.picking_type = cls.process.picking_type_id - # drop base demo data and create our own batches to work with - cls.env["stock.picking.batch"].search([]).unlink() - cls.batch1 = cls._create_picking_batch(cls.product_a) - cls.batch2 = cls._create_picking_batch(cls.product_a) - cls.batch3 = cls._create_picking_batch(cls.product_a) - cls.batch4 = cls._create_picking_batch(cls.product_a) def setUp(self): super().setUp() @@ -31,32 +25,43 @@ def setUp(self): self.service = work.component(usage="cluster_picking") @classmethod - def _create_picking_batch(cls, product): - picking_form = Form(cls.env["stock.picking"]) - picking_form.picking_type_id = cls.picking_type - picking_form.location_id = cls.stock_location - picking_form.location_dest_id = cls.packing_location - picking_form.origin = "test {}".format(product.name) - picking_form.partner_id = cls.customer - with picking_form.move_ids_without_package.new() as move: - move.product_id = product - move.product_uom_qty = 1 - picking = picking_form.save() - picking.action_confirm() - picking.action_assign() - - batch_form = Form(cls.env["stock.picking.batch"]) - batch_form.picking_ids.add(picking) - return batch_form.save() + def _simulate_batch_selected(cls, batches): + cls._add_stock_and_assign_pickings_for_batches(batches) + batches.write({"state": "in_progress", "user_id": cls.env.uid}) + + +class ClusterPickingAPICase(ClusterPickingCommonCase): + """Base tests for the cluster picking API""" def test_to_openapi(self): # will raise if it fails to generate the openapi specs self.service.to_openapi() - def _add_stock_and_assign_pickings_for_batches(self, batches): - pickings = batches.mapped("picking_ids") - self._fill_stock_for_pickings(pickings) - pickings.action_assign() + +class ClusterPickingSelectionCase(ClusterPickingCommonCase): + """Tests covering the selection of picking batches + + Endpoints: + + * /cluster_picking/find_batch + * /cluster_picking/list_batch + * /cluster_picking/select + * /cluster_picking/unassign + + These endpoints interact with a list of picking batches. + The other endpoints that interact with a single batch (after selection) + are handled in ``ClusterPickingSelectedCase``. + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + # drop base demo data and create our own batches to work with + cls.env["stock.picking.batch"].search([]).unlink() + cls.batch1 = cls._create_picking_batch(cls.product_a) + cls.batch2 = cls._create_picking_batch(cls.product_a) + cls.batch3 = cls._create_picking_batch(cls.product_a) + cls.batch4 = cls._create_picking_batch(cls.product_a) def test_find_batch_in_progress_current_user(self): """Find an in-progress batch assigned to the current user""" @@ -309,6 +314,7 @@ def test_select_not_exists(self): "message_type": "warning", "message": "This batch cannot be selected.", }, + data={"size": 0, "records": []}, ) def test_select_already_assigned(self): @@ -328,12 +334,12 @@ def test_select_already_assigned(self): "message_type": "warning", "message": "This batch cannot be selected.", }, + data={"size": 0, "records": []}, ) def test_unassign_batch(self): """User cancels after selecting a batch, unassign it""" - self._add_stock_and_assign_pickings_for_batches(self.batch1) - self.batch1.write({"state": "in_progress", "user_id": self.env.uid}) + self._simulate_batch_selected(self.batch1) # Simulate the client selecting the batch in a list response = self.service.dispatch( "unassign", params={"picking_batch_id": self.batch1.id} @@ -351,3 +357,88 @@ def test_unassign_batch_not_exists(self): "unassign", params={"picking_batch_id": batch_id} ) self.assert_response(response, next_state="start") + + +class ClusterPickingSelectedCase(ClusterPickingCommonCase): + """Tests covering endpoints working on a single picking batch + + After a batch has been selected, by the tests covered in + ``ClusterPickingSelectionCase``. + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + # TODO add several lines / different products + cls.batch1 = cls._create_picking_batch(cls.product_a) + cls._simulate_batch_selected(cls.batch1) + + def test_confirm_start_ok(self): + """User confirms she starts the selected picking batch (happy path)""" + # batch1 was already selected, we only need to confirm the selection + batch = self.batch1 + self.assertEqual(batch.state, "in_progress") + picking = batch.picking_ids[0] + first_move_line = picking.move_line_ids[0] + self.assertTrue(first_move_line) + + response = self.service.dispatch( + "confirm_start", params={"picking_batch_id": self.batch1.id} + ) + self.assert_response( + response, + data={ + "id": first_move_line.id, + "quantity": 1.0, + "location_dst": { + "id": first_move_line.location_dest_id.id, + "name": first_move_line.location_dest_id.name, + }, + "location_src": { + "id": first_move_line.location_id.id, + "name": first_move_line.location_id.name, + }, + "picking": { + "id": picking.id, + "name": picking.name, + "note": "", + "origin": picking.origin, + }, + "batch": {"id": batch.id, "name": batch.name}, + "product": { + "default_code": first_move_line.product_id.default_code, + "display_name": first_move_line.product_id.display_name, + "id": first_move_line.product_id.id, + "name": first_move_line.product_id.name, + "qty_available": first_move_line.product_id.qty_available, + }, + "lot": None, + "pack": None, + }, + next_state="start_line", + ) + + def test_confirm_start_not_exists(self): + """User confirms she starts but batch has been deleted meanwhile""" + batch_id = self.batch1.id + self.batch1.unlink() + response = self.service.dispatch( + "confirm_start", params={"picking_batch_id": batch_id} + ) + self.assert_response( + response, + message={ + "message_type": "error", + "message": "This record you were working on does not exist anymore.", + }, + next_state="start", + ) + + # TODO + @unittest.skip("not sure yet what we have to do, keep for later") + def test_confirm_start_all_is_done(self): + """User confirms start but all lines are already done""" + # we want to jump to the start because there are no lines + # to process anymore, but we want to set pickings and + # picking batch to done if not done yet (because the process + # was interrupted for instance) diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py index c11bee3a06..d053eefee4 100644 --- a/shopfloor/tests/test_picking_batch.py +++ b/shopfloor/tests/test_picking_batch.py @@ -1,9 +1,7 @@ -from odoo.tests.common import Form +from .common import CommonCase, PickingBatchMixin -from .common import CommonCase - -class BatchPickingCase(CommonCase): +class BatchPickingCase(CommonCase, PickingBatchMixin): @classmethod def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) @@ -34,23 +32,6 @@ def setUp(self): with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="picking_batch") - @classmethod - def _create_picking_batch(cls, product): - picking_form = Form(cls.env["stock.picking"]) - picking_form.picking_type_id = cls.picking_type - picking_form.location_id = cls.stock_location - picking_form.location_dest_id = cls.packing_location - with picking_form.move_ids_without_package.new() as move: - move.product_id = product - move.product_uom_qty = 1 - picking = picking_form.save() - picking.action_confirm() - picking.action_assign() - - batch_form = Form(cls.env["stock.picking.batch"]) - batch_form.picking_ids.add(picking) - return batch_form.save() - def test_to_openapi(self): # will raise if it fails to generate the openapi specs self.service.to_openapi() From 9ee4e0af075d81a396d908c79a2f0f03faf9ca4a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 20 Feb 2020 15:13:29 +0100 Subject: [PATCH 100/940] backend tests: support multi-picking/move line in helper to create batch --- shopfloor/tests/common.py | 49 +++++++++++++++++-------- shopfloor/tests/test_cluster_picking.py | 30 ++++++++++----- shopfloor/tests/test_picking_batch.py | 24 +++++++++--- 3 files changed, 72 insertions(+), 31 deletions(-) diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 06f866967e..99bea23eb8 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -1,3 +1,4 @@ +from collections import namedtuple from contextlib import contextmanager from pprint import pformat @@ -105,24 +106,42 @@ def _fill_stock_for_pickings(cls, pickings): class PickingBatchMixin: + + BatchProduct = namedtuple( + "BatchProduct", + # browse record of the product, + # quantity in float + "product quantity", + ) + @classmethod - def _create_picking_batch(cls, product): - picking_form = Form(cls.env["stock.picking"]) - picking_form.picking_type_id = cls.picking_type - picking_form.location_id = cls.stock_location - picking_form.location_dest_id = cls.packing_location - picking_form.origin = "test {}".format(product.name) - picking_form.partner_id = cls.customer - with picking_form.move_ids_without_package.new() as move: - move.product_id = product - move.product_uom_qty = 1 - picking = picking_form.save() - picking.action_confirm() - picking.action_assign() + def _create_picking_batch(cls, products): + """Create a picking batch + :param products: list of list of BatchProduct. The outer list creates + pickings and the innerr list creates moves in these pickings + """ batch_form = Form(cls.env["stock.picking.batch"]) - batch_form.picking_ids.add(picking) - return batch_form.save() + for transfer in products: + picking_form = Form(cls.env["stock.picking"]) + picking_form.picking_type_id = cls.picking_type + picking_form.location_id = cls.stock_location + picking_form.location_dest_id = cls.packing_location + picking_form.origin = "test" + picking_form.partner_id = cls.customer + for batch_product in transfer: + product = batch_product.product + quantity = batch_product.quantity + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = quantity + picking = picking_form.save() + batch_form.picking_ids.add(picking) + + batch = batch_form.save() + batch.picking_ids.action_confirm() + batch.picking_ids.action_assign() + return batch @classmethod def _add_stock_and_assign_pickings_for_batches(cls, batches): diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index d485ce770e..061f316edc 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -58,10 +58,18 @@ def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) # drop base demo data and create our own batches to work with cls.env["stock.picking.batch"].search([]).unlink() - cls.batch1 = cls._create_picking_batch(cls.product_a) - cls.batch2 = cls._create_picking_batch(cls.product_a) - cls.batch3 = cls._create_picking_batch(cls.product_a) - cls.batch4 = cls._create_picking_batch(cls.product_a) + cls.batch1 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + cls.batch2 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + cls.batch3 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + cls.batch4 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) def test_find_batch_in_progress_current_user(self): """Find an in-progress batch assigned to the current user""" @@ -370,20 +378,22 @@ class ClusterPickingSelectedCase(ClusterPickingCommonCase): def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) # TODO add several lines / different products - cls.batch1 = cls._create_picking_batch(cls.product_a) - cls._simulate_batch_selected(cls.batch1) + cls.batch = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + cls._simulate_batch_selected(cls.batch) def test_confirm_start_ok(self): """User confirms she starts the selected picking batch (happy path)""" # batch1 was already selected, we only need to confirm the selection - batch = self.batch1 + batch = self.batch self.assertEqual(batch.state, "in_progress") picking = batch.picking_ids[0] first_move_line = picking.move_line_ids[0] self.assertTrue(first_move_line) response = self.service.dispatch( - "confirm_start", params={"picking_batch_id": self.batch1.id} + "confirm_start", params={"picking_batch_id": self.batch.id} ) self.assert_response( response, @@ -420,8 +430,8 @@ def test_confirm_start_ok(self): def test_confirm_start_not_exists(self): """User confirms she starts but batch has been deleted meanwhile""" - batch_id = self.batch1.id - self.batch1.unlink() + batch_id = self.batch.id + self.batch.unlink() response = self.service.dispatch( "confirm_start", params={"picking_batch_id": batch_id} ) diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py index d053eefee4..b40aa8726d 100644 --- a/shopfloor/tests/test_picking_batch.py +++ b/shopfloor/tests/test_picking_batch.py @@ -17,12 +17,24 @@ def setUpClass(cls, *args, **kwargs): cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id cls.picking_type = cls.process.picking_type_id - cls.batch1 = cls._create_picking_batch(cls.product_a) - cls.batch2 = cls._create_picking_batch(cls.product_a) - cls.batch3 = cls._create_picking_batch(cls.product_a) - cls.batch4 = cls._create_picking_batch(cls.product_b) - cls.batch5 = cls._create_picking_batch(cls.product_b) - cls.batch6 = cls._create_picking_batch(cls.product_b) + cls.batch1 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + cls.batch2 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + cls.batch3 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + cls.batch4 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_b, quantity=1)]] + ) + cls.batch5 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_b, quantity=1)]] + ) + cls.batch6 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_b, quantity=1)]] + ) cls.all_batches = ( cls.batch1 + cls.batch2 + cls.batch3 + cls.batch4 + cls.batch5 + cls.batch6 ) From cce6a8dd6a38057e8deffb4a15bba9934dd09ab6 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 21 Feb 2020 11:43:12 +0100 Subject: [PATCH 101/940] cluster_picking: add /scan_line endpoint --- shopfloor/actions/message.py | 27 +- shopfloor/services/cluster_picking.py | 92 +++++- shopfloor/tests/common.py | 30 +- shopfloor/tests/test_cluster_picking.py | 317 ++++++++++++++++++- shopfloor/tests/test_picking_batch.py | 2 +- shopfloor/tests/test_single_pack_transfer.py | 2 +- 6 files changed, 447 insertions(+), 23 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 0cc3d56ee2..448e54c315 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -53,6 +53,12 @@ def already_running_ask_confirmation(self): def scan_destination(self): return {"message_type": "info", "message": _("Scan the destination location")} + def scan_lot_on_product_tracked_by_lot(self): + return { + "message_type": "warning", + "message": _("Product tracked by lot, please scan one."), + } + def operation_not_found(self): return { "message_type": "error", @@ -65,6 +71,9 @@ def record_not_found(self): "message": _("This record you were working on does not exist anymore."), } + def barcode_not_found(self): + return {"message_type": "error", "message": _("Barcode not found")} + def operation_has_been_canceled_elsewhere(self): return { "message_type": "warning", @@ -106,12 +115,28 @@ def no_pack_in_location(self, location): def several_packs_in_location(self, location): return { - "message_type": "error", + "message_type": "warning", "message": _( "Several packages found in %s, please scan a package." % location.name ), } + def several_lots_in_location(self, location): + return { + "message_type": "warning", + "message": _( + "Several lots found in %s, please scan a lot." % location.name + ), + } + + def several_products_in_location(self, location): + return { + "message_type": "warning", + "message": _( + "Several products found in %s, please scan a product." % location.name + ), + } + def no_pending_operation_for_pack(self, pack): return { "message_type": "error", diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 65d517f896..835cbac492 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -301,7 +301,97 @@ def scan_line(self, move_line_id, barcode): pack meanwhile (race condition). * scan_destination: if the barcode matches. """ - return self._response() + message = self.actions_for("message") + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + # TODO go to next line? (but then handle if it's the last one) + return self._response(next_state="start") + # TODO check again the state of the move line, if already processed + # move to the next state or next line + if move_line.package_id.name == barcode: + return self._response_for_scan_line_ok(move_line) + elif move_line.product_id.barcode == barcode: + if move_line.product_id.tracking in ("lot", "serial"): + return self._response_for_scan_line_product_need_lot(move_line) + return self._response_for_scan_line_ok(move_line) + elif move_line.lot_id.name == barcode: + return self._response_for_scan_line_ok(move_line) + elif move_line.location_id.barcode == barcode: + # When a user scan a location, we accept only when we knows that + # they scanned the good thing, so if in the location we have + # several lots (on a package or a product), several packages, + # several products or a mix of several products and packages, we + # ask to scan a more precise barcode. + location = move_line.location_id + packages = set() + products = set() + lots = set() + for quant in location.quant_ids: + if quant.quantity <= 0: + continue + if quant.package_id: + packages.add(quant.package_id) + else: + products.add(quant.product_id) + if quant.lot_id: + lots.add(quant.lot_id) + + if len(lots) > 1: + return self._response_for_scan_line_several_lots_in_loc(move_line) + if len(packages | products) > 1: + if move_line.package_id: + return self._response_for_scan_line_several_packages_in_loc( + move_line + ) + else: + return self._response_for_scan_line_several_products_in_loc( + move_line + ) + + return self._response_for_scan_line_ok(move_line) + + return self._response( + next_state="start_line", + data=self._data_for_next_move_line(move_line), + message=message.barcode_not_found(), + ) + + def _response_for_scan_line_several_lots_in_loc(self, move_line): + message = self.actions_for("message") + return self._response( + next_state="start_line", + data=self._data_for_next_move_line(move_line), + message=message.several_lots_in_location(move_line.location_id), + ) + + def _response_for_scan_line_several_products_in_loc(self, move_line): + message = self.actions_for("message") + return self._response( + next_state="start_line", + data=self._data_for_next_move_line(move_line), + message=message.several_products_in_location(move_line.location_id), + ) + + def _response_for_scan_line_several_packages_in_loc(self, move_line): + message = self.actions_for("message") + return self._response( + next_state="start_line", + data=self._data_for_next_move_line(move_line), + message=message.several_packs_in_location(move_line.location_id), + ) + + def _response_for_scan_line_product_need_lot(self, move_line): + message = self.actions_for("message") + return self._response( + next_state="start_line", + data=self._data_for_next_move_line(move_line), + message=message.scan_lot_on_product_tracked_by_lot(), + ) + + def _response_for_scan_line_ok(self, move_line): + return self._response( + next_state="scan_destination", data=self._data_for_next_move_line(move_line) + ) def scan_destination_pack(self, move_line_id, barcode, quantity): """Scan the destination package (bin) for a move line diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 99bea23eb8..8f4a5e5925 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -91,18 +91,32 @@ def assert_response(self, response, next_state=None, message=None, data=None): ) @classmethod - def _update_qty_in_location(cls, location, product, quantity): - cls.env["stock.quant"]._update_available_quantity(product, location, quantity) + def _update_qty_in_location( + cls, location, product, quantity, package=None, lot=None + ): + cls.env["stock.quant"]._update_available_quantity( + product, location, quantity, package_id=package, lot_id=lot + ) @classmethod - def _fill_stock_for_pickings(cls, pickings): + def _fill_stock_for_moves(cls, moves, in_package=False, in_lot=False): product_locations = {} - for move in pickings.mapped("move_lines"): + package = None + if in_package: + package = cls.env["stock.quant.package"].create({}) + for move in moves: key = (move.product_id, move.location_id) product_locations.setdefault(key, 0) product_locations[key] += move.product_qty for (product, location), qty in product_locations.items(): - cls._update_qty_in_location(location, product, qty) + lot = None + if in_lot: + lot = cls.env["stock.production.lot"].create( + {"product_id": product.id, "company_id": cls.env.company.id} + ) + cls._update_qty_in_location( + location, product, qty, package=package, lot=lot + ) class PickingBatchMixin: @@ -142,9 +156,3 @@ def _create_picking_batch(cls, products): batch.picking_ids.action_confirm() batch.picking_ids.action_assign() return batch - - @classmethod - def _add_stock_and_assign_pickings_for_batches(cls, batches): - pickings = batches.mapped("picking_ids") - cls._fill_stock_for_pickings(pickings) - pickings.action_assign() diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index 061f316edc..091a8aa2a9 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -8,10 +8,20 @@ class ClusterPickingCommonCase(CommonCase, PickingBatchMixin): def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) cls.product_a = cls.env["product.product"].create( - {"name": "Product A", "type": "product", "default_code": "A"} + { + "name": "Product A", + "type": "product", + "default_code": "A", + "barcode": "A", + } ) cls.product_b = cls.env["product.product"].create( - {"name": "Product B", "type": "product", "default_code": "B"} + { + "name": "Product B", + "type": "product", + "default_code": "B", + "barcode": "B", + } ) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") cls.process = cls.menu.process_id @@ -25,8 +35,21 @@ def setUp(self): self.service = work.component(usage="cluster_picking") @classmethod - def _simulate_batch_selected(cls, batches): - cls._add_stock_and_assign_pickings_for_batches(batches) + def _simulate_batch_selected(cls, batches, in_package=False, in_lot=False): + """Create a state as if a batch was selected by the user + + * The picking batch is in progress + * It is assigned to the current user + * All the move lines are available + + Note: currently, this method create a source package that contains + all the products of the batch. It is enough for the current tests. + """ + pickings = batches.mapped("picking_ids") + cls._fill_stock_for_moves( + pickings.mapped("move_lines"), in_package=in_package, in_lot=in_lot + ) + pickings.action_assign() batches.write({"state": "in_progress", "user_id": cls.env.uid}) @@ -50,7 +73,7 @@ class ClusterPickingSelectionCase(ClusterPickingCommonCase): These endpoints interact with a list of picking batches. The other endpoints that interact with a single batch (after selection) - are handled in ``ClusterPickingSelectedCase``. + are handled in other classes. """ @classmethod @@ -71,6 +94,11 @@ def setUpClass(cls, *args, **kwargs): [[cls.BatchProduct(product=cls.product_a, quantity=1)]] ) + def _add_stock_and_assign_pickings_for_batches(self, batches): + pickings = batches.mapped("picking_ids") + self._fill_stock_for_moves(pickings.mapped("move_lines")) + pickings.action_assign() + def test_find_batch_in_progress_current_user(self): """Find an in-progress batch assigned to the current user""" # Simulate the client asking a batch by clicking on "get work" @@ -377,11 +405,10 @@ class ClusterPickingSelectedCase(ClusterPickingCommonCase): @classmethod def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) - # TODO add several lines / different products cls.batch = cls._create_picking_batch( [[cls.BatchProduct(product=cls.product_a, quantity=1)]] ) - cls._simulate_batch_selected(cls.batch) + cls._simulate_batch_selected(cls.batch, in_package=True) def test_confirm_start_ok(self): """User confirms she starts the selected picking batch (happy path)""" @@ -391,6 +418,10 @@ def test_confirm_start_ok(self): picking = batch.picking_ids[0] first_move_line = picking.move_line_ids[0] self.assertTrue(first_move_line) + # A package exists on the move line, because the quant created + # by ``_simulate_batch_selected`` has a package. + package = first_move_line.package_id + self.assertTrue(package) response = self.service.dispatch( "confirm_start", params={"picking_batch_id": self.batch.id} @@ -423,7 +454,7 @@ def test_confirm_start_ok(self): "qty_available": first_move_line.product_id.qty_available, }, "lot": None, - "pack": None, + "pack": {"id": package.id, "name": package.name}, }, next_state="start_line", ) @@ -452,3 +483,273 @@ def test_confirm_start_all_is_done(self): # to process anymore, but we want to set pickings and # picking batch to done if not done yet (because the process # was interrupted for instance) + + +class ClusterPickingScanLineCase(ClusterPickingCommonCase): + """Tests covering the /scan_line endpoint + + After a batch has been selected and the user confirmed they are + working on it. + + User scans something and the scan_line endpoints validates they + scanned the proper thing to pick. + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + # quants already existing are from demo data + cls.env["stock.quant"].search( + [("location_id", "=", cls.stock_location.id)] + ).unlink() + cls.batch = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + + def _line_data(self, move_line): + picking = move_line.picking_id + batch = picking.batch_id + # A package exists on the move line, because the quant created + # by ``_simulate_batch_selected`` has a package. + package = move_line.package_id + lot = move_line.lot_id + return { + "id": move_line.id, + "quantity": 1.0, + "location_dst": { + "id": move_line.location_dest_id.id, + "name": move_line.location_dest_id.name, + }, + "location_src": { + "id": move_line.location_id.id, + "name": move_line.location_id.name, + }, + "picking": { + "id": picking.id, + "name": picking.name, + "note": "", + "origin": picking.origin, + }, + "batch": {"id": batch.id, "name": batch.name}, + "product": { + "default_code": move_line.product_id.default_code, + "display_name": move_line.product_id.display_name, + "id": move_line.product_id.id, + "name": move_line.product_id.name, + "qty_available": move_line.product_id.qty_available, + }, + "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or ""} + if lot + else None, + "pack": {"id": package.id, "name": package.name} if package else None, + } + + def test_scan_line_pack_ok(self): + """Scan to check if user picks the correct pack for current line""" + self._simulate_batch_selected(self.batch, in_package=True) + # we only have one line in this test case + selected_line = self.batch.picking_ids.move_line_ids + package = selected_line.package_id + response = self.service.dispatch( + "scan_line", + params={"move_line_id": selected_line.id, "barcode": package.name}, + ) + self.assert_response( + response, next_state="scan_destination", data=self._line_data(selected_line) + ) + + def test_scan_line_product_ok(self): + """Scan to check if user picks the correct product for current line""" + self._simulate_batch_selected(self.batch) + # we only have one line in this test case + selected_line = self.batch.picking_ids.move_line_ids + product = selected_line.product_id + response = self.service.dispatch( + "scan_line", + params={"move_line_id": selected_line.id, "barcode": product.barcode}, + ) + self.assert_response( + response, next_state="scan_destination", data=self._line_data(selected_line) + ) + + def _scan_line_serial_or_lot_ok(self, tracking): + self.product_a.tracking = tracking + self._simulate_batch_selected(self.batch, in_lot=True) + # we only have one line in this test case + selected_line = self.batch.picking_ids.move_line_ids + lot = selected_line.lot_id + response = self.service.dispatch( + "scan_line", params={"move_line_id": selected_line.id, "barcode": lot.name} + ) + self.assert_response( + response, next_state="scan_destination", data=self._line_data(selected_line) + ) + + def test_scan_line_lot_ok(self): + """Scan to check if user picks the correct lot for current line""" + self._scan_line_serial_or_lot_ok("lot") + + def test_scan_line_serial_ok(self): + """Scan to check if user picks the correct serial for current line""" + self._scan_line_serial_or_lot_ok("serial") + + def test_scan_line_error_product_tracked(self): + """Scan a product tracked by lot, must scan the lot""" + self.product_a.tracking = "lot" + self._simulate_batch_selected(self.batch, in_lot=True) + # we only have one line in this test case + selected_line = self.batch.picking_ids.move_line_ids + product = selected_line.product_id + response = self.service.dispatch( + "scan_line", + params={"move_line_id": selected_line.id, "barcode": product.barcode}, + ) + self.assert_response( + response, + next_state="start_line", + data=self._line_data(selected_line), + message={ + "message_type": "warning", + "message": "Product tracked by lot, please scan one.", + }, + ) + + def _scan_line_location_ok(self): + # we only have one line in this test case + selected_line = self.batch.picking_ids.move_line_ids + location = selected_line.location_id + response = self.service.dispatch( + "scan_line", + params={"move_line_id": selected_line.id, "barcode": location.barcode}, + ) + self.assert_response( + response, next_state="scan_destination", data=self._line_data(selected_line) + ) + + def test_scan_line_location_ok_single_package(self): + """Scan to check if user scans a correct location for current line + + If there is only one single package in the location, there is no + ambiguity so we can use it. + """ + self._simulate_batch_selected(self.batch, in_package=True) + self._scan_line_location_ok() + + def test_scan_line_location_ok_single_product(self): + """Scan to check if user scans a correct location for current line + + If there is only one single product in the location, there is no + ambiguity so we can use it. + """ + self._simulate_batch_selected(self.batch) + self._scan_line_location_ok() + + def test_scan_line_location_ok_single_lot(self): + """Scan to check if user scans a correct location for current line + + If there is only one single lot in the location, there is no + ambiguity so we can use it. + """ + self._simulate_batch_selected(self.batch, in_lot=True) + self._scan_line_location_ok() + + def test_scan_line_location_error_several_package(self): + """Scan to check if user scans a correct location for current line + + If there are several packages in the location, user has to scan one. + """ + self._simulate_batch_selected(self.batch, in_package=True) + # we only have one line in this test case + selected_line = self.batch.picking_ids.move_line_ids + location = selected_line.location_id + + # add a second package in the location + self._update_qty_in_location( + location, + self.product_b, + 10, + package=self.env["stock.quant.package"].create({}), + ) + response = self.service.dispatch( + "scan_line", + params={"move_line_id": selected_line.id, "barcode": location.barcode}, + ) + self.assert_response( + response, + next_state="start_line", + data=self._line_data(selected_line), + message={ + "message_type": "warning", + "message": "Several packages found in Stock, please scan a package.", + }, + ) + + def test_scan_line_location_error_several_products(self): + """Scan to check if user scans a correct location for current line + + If there are several products in the location, user has to scan one. + """ + self._simulate_batch_selected(self.batch) + # we only have one line in this test case + selected_line = self.batch.picking_ids.move_line_ids + location = selected_line.location_id + + # add a second product in the location + self._update_qty_in_location(location, self.product_b, 10) + response = self.service.dispatch( + "scan_line", + params={"move_line_id": selected_line.id, "barcode": location.barcode}, + ) + self.assert_response( + response, + next_state="start_line", + data=self._line_data(selected_line), + message={ + "message_type": "warning", + "message": "Several products found in Stock, please scan a product.", + }, + ) + + def test_scan_line_location_error_several_lots(self): + """Scan to check if user scans a correct location for current line + + If there are several lots in the location, user has to scan one. + """ + self._simulate_batch_selected(self.batch, in_lot=True) + # we only have one line in this test case + selected_line = self.batch.picking_ids.move_line_ids + location = selected_line.location_id + lot = self.env["stock.production.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + # add a second lot in the location + self._update_qty_in_location(location, self.product_a, 10, lot=lot) + response = self.service.dispatch( + "scan_line", + params={"move_line_id": selected_line.id, "barcode": location.barcode}, + ) + self.assert_response( + response, + next_state="start_line", + data=self._line_data(selected_line), + message={ + "message_type": "warning", + "message": "Several lots found in Stock, please scan a lot.", + }, + ) + + def test_scan_line_error_not_found(self): + """Nothing found for the barcode""" + self._simulate_batch_selected(self.batch, in_package=True) + # we only have one line in this test case + selected_line = self.batch.picking_ids.move_line_ids + response = self.service.dispatch( + "scan_line", + params={"move_line_id": selected_line.id, "barcode": "NO_EXISTING_BARCODE"}, + ) + self.assert_response( + response, + next_state="start_line", + data=self._line_data(selected_line), + message={"message_type": "error", "message": "Barcode not found"}, + ) diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py index b40aa8726d..2be2ab16c8 100644 --- a/shopfloor/tests/test_picking_batch.py +++ b/shopfloor/tests/test_picking_batch.py @@ -58,7 +58,7 @@ def test_search_empty(self): def test_search(self): """Return only draft batches with assigned pickings """ pickings = self.all_batches.mapped("picking_ids") - self._fill_stock_for_pickings(pickings) + self._fill_stock_for_moves(pickings.mapped("move_lines")) pickings.action_assign() self.assertTrue(all(p.state == "assigned" for p in pickings)) # we should not have done batches in list diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index 7679bef1b7..e2d57047d3 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -256,7 +256,7 @@ def test_start_pack_from_location_several_packs(self): response, next_state="start", message={ - "message_type": "error", + "message_type": "warning", "message": "Several packages found in %s, please scan a package." % (self.shelf1.name,), }, From aff1515ee8cde33ba46985e87a48707f5a774254 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 21 Feb 2020 12:24:09 +0100 Subject: [PATCH 102/940] cluster_picking: refactor scan_line tests --- shopfloor/tests/test_cluster_picking.py | 210 ++++++++++++------------ 1 file changed, 102 insertions(+), 108 deletions(-) diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index 091a8aa2a9..b060439b8d 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -52,6 +52,44 @@ def _simulate_batch_selected(cls, batches, in_package=False, in_lot=False): pickings.action_assign() batches.write({"state": "in_progress", "user_id": cls.env.uid}) + def _line_data(self, move_line): + picking = move_line.picking_id + batch = picking.batch_id + # A package exists on the move line, because the quant created + # by ``_simulate_batch_selected`` has a package. + package = move_line.package_id + lot = move_line.lot_id + return { + "id": move_line.id, + "quantity": move_line.product_uom_qty, + "location_dst": { + "id": move_line.location_dest_id.id, + "name": move_line.location_dest_id.name, + }, + "location_src": { + "id": move_line.location_id.id, + "name": move_line.location_id.name, + }, + "picking": { + "id": picking.id, + "name": picking.name, + "note": "", + "origin": picking.origin, + }, + "batch": {"id": batch.id, "name": batch.name}, + "product": { + "default_code": move_line.product_id.default_code, + "display_name": move_line.product_id.display_name, + "id": move_line.product_id.id, + "name": move_line.product_id.name, + "qty_available": move_line.product_id.qty_available, + }, + "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or ""} + if lot + else None, + "pack": {"id": package.id, "name": package.name} if package else None, + } + class ClusterPickingAPICase(ClusterPickingCommonCase): """Base tests for the cluster picking API""" @@ -544,88 +582,65 @@ def _line_data(self, move_line): "pack": {"id": package.id, "name": package.name} if package else None, } - def test_scan_line_pack_ok(self): - """Scan to check if user picks the correct pack for current line""" - self._simulate_batch_selected(self.batch, in_package=True) - # we only have one line in this test case - selected_line = self.batch.picking_ids.move_line_ids - package = selected_line.package_id + def _scan_line_ok(self, line, scanned): response = self.service.dispatch( - "scan_line", - params={"move_line_id": selected_line.id, "barcode": package.name}, + "scan_line", params={"move_line_id": line.id, "barcode": scanned} ) self.assert_response( - response, next_state="scan_destination", data=self._line_data(selected_line) + response, next_state="scan_destination", data=self._line_data(line) ) - def test_scan_line_product_ok(self): - """Scan to check if user picks the correct product for current line""" - self._simulate_batch_selected(self.batch) - # we only have one line in this test case - selected_line = self.batch.picking_ids.move_line_ids - product = selected_line.product_id + def _scan_line_error(self, line, scanned, message): response = self.service.dispatch( - "scan_line", - params={"move_line_id": selected_line.id, "barcode": product.barcode}, + "scan_line", params={"move_line_id": line.id, "barcode": scanned} ) self.assert_response( - response, next_state="scan_destination", data=self._line_data(selected_line) + response, + next_state="start_line", + data=self._line_data(line), + message=message, ) - def _scan_line_serial_or_lot_ok(self, tracking): - self.product_a.tracking = tracking - self._simulate_batch_selected(self.batch, in_lot=True) - # we only have one line in this test case - selected_line = self.batch.picking_ids.move_line_ids - lot = selected_line.lot_id - response = self.service.dispatch( - "scan_line", params={"move_line_id": selected_line.id, "barcode": lot.name} - ) - self.assert_response( - response, next_state="scan_destination", data=self._line_data(selected_line) - ) + def test_scan_line_pack_ok(self): + """Scan to check if user picks the correct pack for current line""" + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.package_id.name) + + def test_scan_line_product_ok(self): + """Scan to check if user picks the correct product for current line""" + self._simulate_batch_selected(self.batch) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.product_id.barcode) def test_scan_line_lot_ok(self): """Scan to check if user picks the correct lot for current line""" - self._scan_line_serial_or_lot_ok("lot") + self.product_a.tracking = "lot" + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.lot_id.name) def test_scan_line_serial_ok(self): """Scan to check if user picks the correct serial for current line""" - self._scan_line_serial_or_lot_ok("serial") + self.product_a.tracking = "serial" + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.lot_id.name) def test_scan_line_error_product_tracked(self): """Scan a product tracked by lot, must scan the lot""" self.product_a.tracking = "lot" self._simulate_batch_selected(self.batch, in_lot=True) - # we only have one line in this test case - selected_line = self.batch.picking_ids.move_line_ids - product = selected_line.product_id - response = self.service.dispatch( - "scan_line", - params={"move_line_id": selected_line.id, "barcode": product.barcode}, - ) - self.assert_response( - response, - next_state="start_line", - data=self._line_data(selected_line), - message={ + line = self.batch.picking_ids.move_line_ids + self._scan_line_error( + line, + line.product_id.barcode, + { "message_type": "warning", "message": "Product tracked by lot, please scan one.", }, ) - def _scan_line_location_ok(self): - # we only have one line in this test case - selected_line = self.batch.picking_ids.move_line_ids - location = selected_line.location_id - response = self.service.dispatch( - "scan_line", - params={"move_line_id": selected_line.id, "barcode": location.barcode}, - ) - self.assert_response( - response, next_state="scan_destination", data=self._line_data(selected_line) - ) - def test_scan_line_location_ok_single_package(self): """Scan to check if user scans a correct location for current line @@ -633,7 +648,8 @@ def test_scan_line_location_ok_single_package(self): ambiguity so we can use it. """ self._simulate_batch_selected(self.batch, in_package=True) - self._scan_line_location_ok() + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.location_id.barcode) def test_scan_line_location_ok_single_product(self): """Scan to check if user scans a correct location for current line @@ -642,7 +658,8 @@ def test_scan_line_location_ok_single_product(self): ambiguity so we can use it. """ self._simulate_batch_selected(self.batch) - self._scan_line_location_ok() + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.location_id.barcode) def test_scan_line_location_ok_single_lot(self): """Scan to check if user scans a correct location for current line @@ -651,7 +668,8 @@ def test_scan_line_location_ok_single_lot(self): ambiguity so we can use it. """ self._simulate_batch_selected(self.batch, in_lot=True) - self._scan_line_location_ok() + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.location_id.barcode) def test_scan_line_location_error_several_package(self): """Scan to check if user scans a correct location for current line @@ -659,10 +677,8 @@ def test_scan_line_location_error_several_package(self): If there are several packages in the location, user has to scan one. """ self._simulate_batch_selected(self.batch, in_package=True) - # we only have one line in this test case - selected_line = self.batch.picking_ids.move_line_ids - location = selected_line.location_id - + line = self.batch.picking_ids.move_line_ids + location = line.location_id # add a second package in the location self._update_qty_in_location( location, @@ -670,15 +686,10 @@ def test_scan_line_location_error_several_package(self): 10, package=self.env["stock.quant.package"].create({}), ) - response = self.service.dispatch( - "scan_line", - params={"move_line_id": selected_line.id, "barcode": location.barcode}, - ) - self.assert_response( - response, - next_state="start_line", - data=self._line_data(selected_line), - message={ + self._scan_line_error( + line, + location.barcode, + { "message_type": "warning", "message": "Several packages found in Stock, please scan a package.", }, @@ -690,21 +701,14 @@ def test_scan_line_location_error_several_products(self): If there are several products in the location, user has to scan one. """ self._simulate_batch_selected(self.batch) - # we only have one line in this test case - selected_line = self.batch.picking_ids.move_line_ids - location = selected_line.location_id - + line = self.batch.picking_ids.move_line_ids + location = line.location_id # add a second product in the location self._update_qty_in_location(location, self.product_b, 10) - response = self.service.dispatch( - "scan_line", - params={"move_line_id": selected_line.id, "barcode": location.barcode}, - ) - self.assert_response( - response, - next_state="start_line", - data=self._line_data(selected_line), - message={ + self._scan_line_error( + line, + location.barcode, + { "message_type": "warning", "message": "Several products found in Stock, please scan a product.", }, @@ -716,23 +720,17 @@ def test_scan_line_location_error_several_lots(self): If there are several lots in the location, user has to scan one. """ self._simulate_batch_selected(self.batch, in_lot=True) - # we only have one line in this test case - selected_line = self.batch.picking_ids.move_line_ids - location = selected_line.location_id + line = self.batch.picking_ids.move_line_ids + location = line.location_id lot = self.env["stock.production.lot"].create( {"product_id": self.product_a.id, "company_id": self.env.company.id} ) # add a second lot in the location self._update_qty_in_location(location, self.product_a, 10, lot=lot) - response = self.service.dispatch( - "scan_line", - params={"move_line_id": selected_line.id, "barcode": location.barcode}, - ) - self.assert_response( - response, - next_state="start_line", - data=self._line_data(selected_line), - message={ + self._scan_line_error( + line, + location.barcode, + { "message_type": "warning", "message": "Several lots found in Stock, please scan a lot.", }, @@ -741,15 +739,11 @@ def test_scan_line_location_error_several_lots(self): def test_scan_line_error_not_found(self): """Nothing found for the barcode""" self._simulate_batch_selected(self.batch, in_package=True) - # we only have one line in this test case - selected_line = self.batch.picking_ids.move_line_ids - response = self.service.dispatch( - "scan_line", - params={"move_line_id": selected_line.id, "barcode": "NO_EXISTING_BARCODE"}, - ) - self.assert_response( - response, - next_state="start_line", - data=self._line_data(selected_line), - message={"message_type": "error", "message": "Barcode not found"}, + self._scan_line_error( + self.batch.picking_ids.move_line_ids, + "NO_EXISTING_BARCODE", + {"message_type": "error", "message": "Barcode not found"}, ) + + +# TODO tests for transitions to next line / no next lines, ... From 48f2ea0244f04acbcc95549d057265e6dd154d59 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 21 Feb 2020 13:05:29 +0100 Subject: [PATCH 103/940] cluster_picking: add happy path for /scan_destination_pack --- shopfloor/services/cluster_picking.py | 47 ++++++++++++++++++++- shopfloor/tests/test_cluster_picking.py | 55 +++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 835cbac492..e0757705d3 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -413,7 +413,51 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): have the same destination. * start_line: to pick the next line if any. """ - return self._response() + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + # TODO go to next line? (but then handle if it's the last one) + return self._response(next_state="start") + # TODO if another line of the picking has a destination package, handle + # it (note: should be added to the 'single line' schema /data as well, + # maybe use a computed field). + + # TODO handle partial pick + if quantity > move_line.product_uom_qty: + # TODO (+ use float_tools) + return self._response() + # TODO handle destination bin not empty + search = self.actions_for("search") + bin_package = search.package_from_scan(barcode) + if not bin_package: + # TODO + return self._response() + move_line.write({"qty_done": quantity, "result_package_id": bin_package.id}) + # TODO zero check + # TODO handle next line and no next line (in a shared way with other + # endpoints) + batch = move_line.picking_id.batch_id + remaining_lines = batch.mapped("picking_ids.move_line_ids").filtered( + lambda line: not line.result_package_id + ) + if not remaining_lines: + return self._response( + next_state="start", + message={"message_type": "info", "message": "Not implemented"}, + ) + next_line = remaining_lines[0] + return self._response( + next_state="start_line", + data=self._data_for_next_move_line(next_line), + message={ + "message_type": "info", + # TODO different message for products/packs? + "message": _("{} {} put in {}").format( + move_line.qty_done, + move_line.product_id.display_name, + bin_package.name, + ), + }, + ) def prepare_unload(self, picking_batch_id): """Initiate the unloading phase of the process @@ -962,6 +1006,7 @@ def _schema_for_single_line_details(self): "name": {"type": "string", "nullable": False, "required": True}, }, }, + # TODO add destination pack "pack": { "type": "dict", "required": False, diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index b060439b8d..6916a94943 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -746,4 +746,59 @@ def test_scan_line_error_not_found(self): ) +class ClusterPickingScanDestinationPackCase(ClusterPickingCommonCase): + """Tests covering the /scan_destination_pack endpoint + + After a batch has been selected and the user confirmed they are + working on it, user picked the good, now they scan the location + destination. + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.batch = cls._create_picking_batch( + [ + [ + cls.BatchProduct(product=cls.product_a, quantity=10), + cls.BatchProduct(product=cls.product_b, quantity=10), + ], + [cls.BatchProduct(product=cls.product_a, quantity=10)], + ] + ) + cls.bin1 = cls.env["stock.quant.package"].create({}) + + def test_scan_destination_pack_ok(self): + """Happy path for scan destination package + + It sets the line in the pack for the full qty + """ + self._simulate_batch_selected(self.batch) + line = self.batch.picking_ids.move_line_ids[0] + next_line = self.batch.picking_ids.move_line_ids[1] + qty_done = line.product_uom_qty + response = self.service.dispatch( + "scan_destination_pack", + params={ + "move_line_id": line.id, + "barcode": self.bin1.name, + "quantity": qty_done, + }, + ) + self.assertRecordValues( + line, [{"qty_done": qty_done, "result_package_id": self.bin1.id}] + ) + self.assert_response( + response, + next_state="start_line", + data=self._line_data(next_line), + message={ + "message_type": "info", + "message": "{} {} put in {}".format( + line.qty_done, line.product_id.display_name, self.bin1.name + ), + }, + ) + + # TODO tests for transitions to next line / no next lines, ... From 7950def53ad39cce971079146c61d4c41cfcaa04 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 24 Feb 2020 16:14:02 +0100 Subject: [PATCH 104/940] cluster_picking: split move lines getter and common test case --- shopfloor/services/cluster_picking.py | 23 ++++++++++++++++------- shopfloor/tests/test_cluster_picking.py | 25 +++++++++++++++---------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index e0757705d3..430bb30a04 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -215,17 +215,28 @@ def confirm_start(self, picking_batch_id): if not picking_batch.exists(): return self._response_batch_does_not_exist() - remaining_lines = picking_batch.mapped("picking_ids.move_line_ids").filtered( - lambda l: l.state == "assigned" - ) + remaining_lines = self._assigned_lines_for_picking_batch(picking_batch) if not remaining_lines: # TODO pass - return self._response( next_state="start_line", data=self._data_for_next_move_line(remaining_lines) ) + def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x): + lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(filter_func) + return lines + + def _assigned_lines_for_picking_batch(self, picking_batch): + return self._lines_for_picking_batch( + picking_batch, filter_func=lambda l: l.state == 'assigned' + ) + + def _unpackaged_lines_for_picking_batch(self, picking_batch): + return self._lines_for_picking_batch( + picking_batch, filter_func=lambda l: not l.result_package_id + ) + def _response_batch_does_not_exist(self): message = self.actions_for("message") return self._response(next_state="start", message=message.record_not_found()) @@ -436,9 +447,7 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): # TODO handle next line and no next line (in a shared way with other # endpoints) batch = move_line.picking_id.batch_id - remaining_lines = batch.mapped("picking_ids.move_line_ids").filtered( - lambda line: not line.result_package_id - ) + remaining_lines = self._unpackaged_lines_for_picking_batch(batch) if not remaining_lines: return self._response( next_state="start", diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index 6916a94943..287f0ae50c 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -523,15 +523,7 @@ def test_confirm_start_all_is_done(self): # was interrupted for instance) -class ClusterPickingScanLineCase(ClusterPickingCommonCase): - """Tests covering the /scan_line endpoint - - After a batch has been selected and the user confirmed they are - working on it. - - User scans something and the scan_line endpoints validates they - scanned the proper thing to pick. - """ +class ClusterPickingLineCommonCase(ClusterPickingCommonCase): @classmethod def setUpClass(cls, *args, **kwargs): @@ -582,6 +574,17 @@ def _line_data(self, move_line): "pack": {"id": package.id, "name": package.name} if package else None, } + +class ClusterPickingScanLineCase(ClusterPickingLineCommonCase): + """Tests covering the /scan_line endpoint + + After a batch has been selected and the user confirmed they are + working on it. + + User scans something and the scan_line endpoints validates they + scanned the proper thing to pick. + """ + def _scan_line_ok(self, line, scanned): response = self.service.dispatch( "scan_line", params={"move_line_id": line.id, "barcode": scanned} @@ -763,7 +766,9 @@ def setUpClass(cls, *args, **kwargs): cls.BatchProduct(product=cls.product_a, quantity=10), cls.BatchProduct(product=cls.product_b, quantity=10), ], - [cls.BatchProduct(product=cls.product_a, quantity=10)], + [ + cls.BatchProduct(product=cls.product_a, quantity=10) + ], ] ) cls.bin1 = cls.env["stock.quant.package"].create({}) From dfe1089100795b6bf9159b2000923b8b5898b81b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 24 Feb 2020 16:41:12 +0100 Subject: [PATCH 105/940] cluster_picking: add /prepare_unload happy paths --- shopfloor/models/__init__.py | 1 + shopfloor/models/stock_move_line.py | 7 ++ shopfloor/services/cluster_picking.py | 48 ++++++++++- shopfloor/tests/test_cluster_picking.py | 103 +++++++++++++++++++++++- 4 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 shopfloor/models/stock_move_line.py diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index b39ee610aa..1cc7ee589a 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -4,5 +4,6 @@ from . import stock_picking_type from . import shopfloor_profile from . import stock_location +from . import stock_move_line from . import stock_picking_batch from . import res_users diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py new file mode 100644 index 0000000000..d3b67bab63 --- /dev/null +++ b/shopfloor/models/stock_move_line.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class StockMoveLine(models.Model): + _inherit = "stock.move.line" + + shopfloor_unloaded = fields.Boolean(default=False) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 430bb30a04..9cb9b2804a 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -229,7 +229,7 @@ def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x): def _assigned_lines_for_picking_batch(self, picking_batch): return self._lines_for_picking_batch( - picking_batch, filter_func=lambda l: l.state == 'assigned' + picking_batch, filter_func=lambda l: l.state == "assigned" ) def _unpackaged_lines_for_picking_batch(self, picking_batch): @@ -481,7 +481,51 @@ def prepare_unload(self, picking_batch_id): * unload_all: when ``cluster_picking_unload_all`` is True * unload_single: when ``cluster_picking_unload_all`` is False """ - return self._response() + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() + if len(batch.mapped("picking_ids.move_line_ids.location_dest_id")) == 1: + batch.cluster_picking_unload_all = True + return self._response_for_unload_all(batch) + else: + # the lines have different destinations + batch.cluster_picking_unload_all = False + return self._response_for_unload_single(batch) + + def _data_for_unload(self, move_line): + batch = move_line.picking_id.batch_id + return { + "id": batch.id, + "name": batch.name, + "location_dst": { + "id": move_line.location_dest_id.id, + "name": move_line.location_dest_id.name, + }, + } + + def _response_for_unload_all(self, batch): + # all the lines destinations are the same here + first_line = batch.mapped("picking_ids.move_line_ids")[0] + return self._response( + next_state="unload_all", data=self._data_for_unload(first_line) + ) + + def _next_line_for_unload_single(self, batch): + lines = batch.mapped("picking_ids.move_line_ids") + for line in lines: + if line.shopfloor_unloaded: + continue + return line + return self.env["stock.move.line"].browse() + + def _response_for_unload_single(self, batch): + next_line = self._next_line_for_unload_single(batch) + if not next_line: + # TODO ensure batch is 'done' and go to start? + return self._response() + return self._response( + next_state="unload_single", data=self._data_for_unload(next_line) + ) def is_zero(self, move_line_id, zero): """Confirm or not if the source location of a move has zero qty diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index 287f0ae50c..d6267314aa 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -524,7 +524,6 @@ def test_confirm_start_all_is_done(self): class ClusterPickingLineCommonCase(ClusterPickingCommonCase): - @classmethod def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) @@ -766,9 +765,7 @@ def setUpClass(cls, *args, **kwargs): cls.BatchProduct(product=cls.product_a, quantity=10), cls.BatchProduct(product=cls.product_b, quantity=10), ], - [ - cls.BatchProduct(product=cls.product_a, quantity=10) - ], + [cls.BatchProduct(product=cls.product_a, quantity=10)], ] ) cls.bin1 = cls.env["stock.quant.package"].create({}) @@ -806,4 +803,102 @@ def test_scan_destination_pack_ok(self): ) +class ClusterPickingPrepareUnloadPackCase(ClusterPickingCommonCase): + """Tests covering the /prepare_unload endpoint + + Destination packages have been set on all the move lines of the batch. + The unload operation will start, but we have 2 paths for this: + + 1. unload all the destination packages at the same place + 2. unload the destination packages one by one at different places + + By default, if all the move lines have the same destination, the + first path is used. A flag on the batch picking keeps track of which + path is used. + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.batch = cls._create_picking_batch( + [ + [ + cls.BatchProduct(product=cls.product_a, quantity=10), + cls.BatchProduct(product=cls.product_b, quantity=10), + ], + [cls.BatchProduct(product=cls.product_a, quantity=10)], + ] + ) + cls._simulate_batch_selected(cls.batch) + cls.bin1 = cls.env["stock.quant.package"].create({}) + cls.bin2 = cls.env["stock.quant.package"].create({}) + cls.packing_a_location = cls.env["stock.location"].create( + { + "name": "Packing A", + "barcode": "Packing-A", + "location_id": cls.packing_location.id, + } + ) + cls.packing_b_location = cls.env["stock.location"].create( + { + "name": "Packing B", + "barcode": "Packing-B", + "location_id": cls.packing_location.id, + } + ) + + def _set_dest_package_and_done(self, move_lines, dest_package): + """Simulate what would have been done in the previous steps""" + for line in move_lines: + line.write({"qty_done": line.qty_done, "package_id": dest_package.id}) + + def test_prepare_unload_all_same_dest(self): + """All move lines have the same destination location""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + self._set_dest_package_and_done(move_lines[:1], self.bin1) + self._set_dest_package_and_done(move_lines[1:], self.bin2) + move_lines.write({"location_dest_id": self.packing_location.id}) + response = self.service.dispatch( + "prepare_unload", params={"picking_batch_id": self.batch.id} + ) + self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": True}]) + self.assert_response( + response, + next_state="unload_all", + data={ + "id": self.batch.id, + "name": self.batch.name, + "location_dst": { + "id": self.packing_location.id, + "name": self.packing_location.name, + }, + }, + ) + + def test_prepare_unload_different_dest(self): + """All move lines have different destination locations""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + self._set_dest_package_and_done(move_lines[:1], self.bin1) + self._set_dest_package_and_done(move_lines[1:], self.bin2) + move_lines[:1].write({"location_dest_id": self.packing_a_location.id}) + move_lines[:1].write({"location_dest_id": self.packing_b_location.id}) + response = self.service.dispatch( + "prepare_unload", params={"picking_batch_id": self.batch.id} + ) + self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}]) + first_line = move_lines[0] + self.assert_response( + response, + next_state="unload_single", + data={ + "id": self.batch.id, + "name": self.batch.name, + "location_dst": { + "id": first_line.location_dest_id.id, + "name": first_line.location_dest_id.name, + }, + }, + ) + + # TODO tests for transitions to next line / no next lines, ... From db5b6fb142bd491c9a90b4aec57128a6f77baeb1 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 25 Feb 2020 09:33:09 +0100 Subject: [PATCH 106/940] backend: prepare cluster_picking for /skip_line --- shopfloor/models/stock_move_line.py | 6 +++ shopfloor/services/cluster_picking.py | 2 + shopfloor/tests/test_cluster_picking.py | 69 +++++++++++-------------- 3 files changed, 38 insertions(+), 39 deletions(-) diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py index d3b67bab63..d3e9997ac0 100644 --- a/shopfloor/models/stock_move_line.py +++ b/shopfloor/models/stock_move_line.py @@ -5,3 +5,9 @@ class StockMoveLine(models.Model): _inherit = "stock.move.line" shopfloor_unloaded = fields.Boolean(default=False) + shopfloor_postponed = fields.Boolean( + default=False, + copy=False, + help="Technical field. " + "Indicates if a the move has been postponed in a process.", + ) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 9cb9b2804a..1736ee711f 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -253,6 +253,7 @@ def _data_for_next_move_line(self, move_lines): # for each model "id": line.id, "quantity": line.product_uom_qty, + "postponed": line.shopfloor_postponed, "picking": { "id": picking.id, "name": picking.name, @@ -996,6 +997,7 @@ def _schema_for_single_line_details(self): # id is a stock.move.line "id": {"required": True, "type": "integer"}, "quantity": {"type": "float", "required": True}, + "postponed": {"type": "boolean", "required": False}, "picking": { "type": "dict", "schema": { diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index d6267314aa..74879c0b30 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -52,7 +52,7 @@ def _simulate_batch_selected(cls, batches, in_package=False, in_lot=False): pickings.action_assign() batches.write({"state": "in_progress", "user_id": cls.env.uid}) - def _line_data(self, move_line): + def _line_data(self, move_line, qty=None): picking = move_line.picking_id batch = picking.batch_id # A package exists on the move line, because the quant created @@ -61,7 +61,8 @@ def _line_data(self, move_line): lot = move_line.lot_id return { "id": move_line.id, - "quantity": move_line.product_uom_qty, + "quantity": qty or move_line.product_uom_qty, + "postponed": move_line.shopfloor_postponed, "location_dst": { "id": move_line.location_dest_id.id, "name": move_line.location_dest_id.name, @@ -469,6 +470,7 @@ def test_confirm_start_ok(self): data={ "id": first_move_line.id, "quantity": 1.0, + "postponed": False, "location_dst": { "id": first_move_line.location_dest_id.id, "name": first_move_line.location_dest_id.name, @@ -535,43 +537,9 @@ def setUpClass(cls, *args, **kwargs): [[cls.BatchProduct(product=cls.product_a, quantity=1)]] ) - def _line_data(self, move_line): - picking = move_line.picking_id - batch = picking.batch_id - # A package exists on the move line, because the quant created - # by ``_simulate_batch_selected`` has a package. - package = move_line.package_id - lot = move_line.lot_id - return { - "id": move_line.id, - "quantity": 1.0, - "location_dst": { - "id": move_line.location_dest_id.id, - "name": move_line.location_dest_id.name, - }, - "location_src": { - "id": move_line.location_id.id, - "name": move_line.location_id.name, - }, - "picking": { - "id": picking.id, - "name": picking.name, - "note": "", - "origin": picking.origin, - }, - "batch": {"id": batch.id, "name": batch.name}, - "product": { - "default_code": move_line.product_id.default_code, - "display_name": move_line.product_id.display_name, - "id": move_line.product_id.id, - "name": move_line.product_id.name, - "qty_available": move_line.product_id.qty_available, - }, - "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or ""} - if lot - else None, - "pack": {"id": package.id, "name": package.name} if package else None, - } + def _line_data(self, move_line, qty=1.0): + # just force qty to 1.0 + return super()._line_data(move_line, qty=qty) class ClusterPickingScanLineCase(ClusterPickingLineCommonCase): @@ -899,6 +867,29 @@ def test_prepare_unload_different_dest(self): }, }, ) +# TODO +# class ClusterPickingSkipLineCase(ClusterPickingLineCommonCase): +# """Tests covering the /skip_line endpoint +# """ + +# def _skip_line(self, line, scanned): +# response = self.service.dispatch( +# "scan_line", params={"move_line_id": line.id, "barcode": scanned} +# ) +# self.assert_response( +# response, next_state="scan_destination", data=self._line_data(line) +# ) + +# @classmethod +# def setUpClass(cls, *args, **kwargs): +# super().setUpClass(*args, **kwargs) +# # quants already existing are from demo data +# cls.env["stock.quant"].search( +# [("location_id", "=", cls.stock_location.id)] +# ).unlink() +# cls.batch = cls._create_picking_batch( +# [[cls.BatchProduct(product=cls.product_a, quantity=1)]] +# ) # TODO tests for transitions to next line / no next lines, ... From 28a5140a2882d0725b1a26e829ac9e7f3bc24c53 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 25 Feb 2020 09:48:12 +0100 Subject: [PATCH 107/940] cluster_picking: order by postponed state, add method for first line --- shopfloor/services/cluster_picking.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 1736ee711f..e2967065e6 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -1,4 +1,4 @@ -from odoo import _ +from odoo import fields, _ from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component @@ -225,7 +225,7 @@ def confirm_start(self, picking_batch_id): def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x): lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(filter_func) - return lines + return lines.sorted(key=lambda x: x.shopfloor_postponed) def _assigned_lines_for_picking_batch(self, picking_batch): return self._lines_for_picking_batch( @@ -237,6 +237,9 @@ def _unpackaged_lines_for_picking_batch(self, picking_batch): picking_batch, filter_func=lambda l: not l.result_package_id ) + def _first_line_for_picking_batch(self, picking_batch, filter_func=lambda x: x): + return fields.first(self._lines_for_picking_batch(picking_batch, filter_func=filter_func)) + def _response_batch_does_not_exist(self): message = self.actions_for("message") return self._response(next_state="start", message=message.record_not_found()) @@ -506,12 +509,13 @@ def _data_for_unload(self, move_line): def _response_for_unload_all(self, batch): # all the lines destinations are the same here - first_line = batch.mapped("picking_ids.move_line_ids")[0] + first_line = self._first_line_for_picking_batch(batch) return self._response( next_state="unload_all", data=self._data_for_unload(first_line) ) def _next_line_for_unload_single(self, batch): + # TODO: shall we use `_first_line_for_picking_batch` + lambda filter? lines = batch.mapped("picking_ids.move_line_ids") for line in lines: if line.shopfloor_unloaded: From 126a771d93799808bb4e25e4a18bcaeb27ab9a14 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 25 Feb 2020 10:45:13 +0100 Subject: [PATCH 108/940] cluster_picking: /skip_line --- shopfloor/services/cluster_picking.py | 31 ++++++++-- shopfloor/tests/test_cluster_picking.py | 78 +++++++++++++++++-------- 2 files changed, 80 insertions(+), 29 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index e2967065e6..71acc8bd03 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -1,4 +1,4 @@ -from odoo import fields, _ +from odoo import _, fields from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component @@ -238,7 +238,12 @@ def _unpackaged_lines_for_picking_batch(self, picking_batch): ) def _first_line_for_picking_batch(self, picking_batch, filter_func=lambda x: x): - return fields.first(self._lines_for_picking_batch(picking_batch, filter_func=filter_func)) + return fields.first( + self._lines_for_picking_batch(picking_batch, filter_func=filter_func) + ) + + def _next_line_for_picking_batch(self, picking_batch): + return fields.first(self._unpackaged_lines_for_picking_batch(picking_batch)) def _response_batch_does_not_exist(self): message = self.actions_for("message") @@ -451,13 +456,12 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): # TODO handle next line and no next line (in a shared way with other # endpoints) batch = move_line.picking_id.batch_id - remaining_lines = self._unpackaged_lines_for_picking_batch(batch) - if not remaining_lines: + next_line = self._next_line_for_picking_batch(batch) + if not next_line: return self._response( next_state="start", message={"message_type": "info", "message": "Not implemented"}, ) - next_line = remaining_lines[0] return self._response( next_state="start_line", data=self._data_for_next_move_line(next_line), @@ -560,7 +564,22 @@ def skip_line(self, move_line_id): * start_line: with data for the next line (or itself if it's the last one, in such case, a helpful message is returned) """ - return self._response() + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + # TODO go to next line? (but then handle if it's the last one) + return self._response(next_state="start") + # flag as postponed + move_line.shopfloor_postponed = True + return self._response_for_skip_line(move_line) + + def _response_for_skip_line(self, move_line): + next_line = self._next_line_for_picking_batch(move_line.picking_id.batch_id) + if not next_line: + # TODO ensure batch is 'done' and go to start? + return self._response() + return self._response( + next_state="start_line", data=self._data_for_next_move_line(next_line) + ) def stock_issue(self, move_line_id): """Declare a stock issue for a line diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index 74879c0b30..463c9951d8 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -867,29 +867,61 @@ def test_prepare_unload_different_dest(self): }, }, ) -# TODO -# class ClusterPickingSkipLineCase(ClusterPickingLineCommonCase): -# """Tests covering the /skip_line endpoint -# """ - -# def _skip_line(self, line, scanned): -# response = self.service.dispatch( -# "scan_line", params={"move_line_id": line.id, "barcode": scanned} -# ) -# self.assert_response( -# response, next_state="scan_destination", data=self._line_data(line) -# ) - -# @classmethod -# def setUpClass(cls, *args, **kwargs): -# super().setUpClass(*args, **kwargs) -# # quants already existing are from demo data -# cls.env["stock.quant"].search( -# [("location_id", "=", cls.stock_location.id)] -# ).unlink() -# cls.batch = cls._create_picking_batch( -# [[cls.BatchProduct(product=cls.product_a, quantity=1)]] -# ) + + +class ClusterPickingSkipLineCase(ClusterPickingCommonCase): + """Tests covering the /skip_line endpoint + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + # quants already existing are from demo data + cls.env["stock.quant"].search( + [("location_id", "=", cls.stock_location.id)] + ).unlink() + cls.batch = cls._create_picking_batch( + [ + [ + cls.BatchProduct(product=cls.product_a, quantity=10), + cls.BatchProduct(product=cls.product_b, quantity=20), + ], + [ + cls.BatchProduct(product=cls.product_a, quantity=30), + cls.BatchProduct(product=cls.product_b, quantity=40), + ], + ] + ) + + def _skip_line(self, line, next_line=None): + response = self.service.dispatch("skip_line", params={"move_line_id": line.id}) + if next_line: + self.assert_response( + response, next_state="start_line", data=self._line_data(next_line) + ) + return response + + def test_skip_line(self): + self._simulate_batch_selected(self.batch, in_package=True) + lines = self.batch.picking_ids.move_line_ids + # 1st line, next is 2nd + self.assertFalse(lines[0].shopfloor_postponed) + self._skip_line(lines[0], lines[1]) + self.assertTrue(lines[0].shopfloor_postponed) + # 2nd line, next is 3rd + self.assertFalse(lines[1].shopfloor_postponed) + self._skip_line(lines[1], lines[2]) + self.assertTrue(lines[1].shopfloor_postponed) + # 3rd line, next is 4th + self.assertFalse(lines[2].shopfloor_postponed) + self._skip_line(lines[2], lines[3]) + self.assertTrue(lines[2].shopfloor_postponed) + # 4th line, next is 1st + # the next line for the last one is the 1st, + # because you'll have to process it anyway + self.assertFalse(lines[3].shopfloor_postponed) + self._skip_line(lines[3], lines[0]) + self.assertTrue(lines[3].shopfloor_postponed) # TODO tests for transitions to next line / no next lines, ... From a7c4410dd621e46f472917c858cec82e2a813497 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 25 Feb 2020 11:01:57 +0100 Subject: [PATCH 109/940] Add fake READMEs --- shopfloor/README.rst | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 shopfloor/README.rst diff --git a/shopfloor/README.rst b/shopfloor/README.rst new file mode 100644 index 0000000000..4adf2c2512 --- /dev/null +++ b/shopfloor/README.rst @@ -0,0 +1,24 @@ +**This file is going to be generated by oca-gen-addon-readme.** + +*Manual changes will be overwritten.* + + +TODO + +Please provide content in the ``readme`` directory: + +* **DESCRIPTION.rst** (required) +* INSTALL.rst (optional) +* CONFIGURE.rst (optional) +* **USAGE.rst** (optional, highly recommended) +* DEVELOP.rst (optional) +* ROADMAP.rst (optional) +* HISTORY.rst (optional, recommended) +* **CONTRIBUTORS.rst** (optional, highly recommended) +* CREDITS.rst (optional) + +Content of this README will also be drawn from the addon manifest, +from keys such as name, authors, maintainers, development_status, +and license. + +A good, one sentence summary in the manifest is also highly recommended. From b488240732091198239229f62597e5f86efd3db8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 25 Feb 2020 11:04:01 +0100 Subject: [PATCH 110/940] backend: linting --- shopfloor/models/stock_move_line.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py index d3e9997ac0..b829cb8d85 100644 --- a/shopfloor/models/stock_move_line.py +++ b/shopfloor/models/stock_move_line.py @@ -9,5 +9,5 @@ class StockMoveLine(models.Model): default=False, copy=False, help="Technical field. " - "Indicates if a the move has been postponed in a process.", + "Indicates if a the move has been postponed in a process.", ) From 0da3095802c0f342cf750db28d6552329973c1fa Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 25 Feb 2020 09:40:47 +0100 Subject: [PATCH 111/940] cluster_picking: add /set_destination_all happy paths --- shopfloor/services/cluster_picking.py | 149 +++++++++---- shopfloor/tests/test_cluster_picking.py | 273 +++++++++++++++++------- 2 files changed, 307 insertions(+), 115 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 71acc8bd03..dc4ab7078d 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -112,8 +112,7 @@ def list_batch(self): def _select_a_picking_batch(self, batches): # look for in progress + assigned to self first candidates = batches.filtered( - lambda batch: batch.state == "in_progress" - and batch.user_id == self.env.user + lambda batch: batch.state == "in_progress" and batch.user_id == self.env.user ) if candidates: return candidates[0] @@ -215,42 +214,53 @@ def confirm_start(self, picking_batch_id): if not picking_batch.exists(): return self._response_batch_does_not_exist() - remaining_lines = self._assigned_lines_for_picking_batch(picking_batch) - if not remaining_lines: + next_line = self._next_line_for_pick(picking_batch) + if not next_line: # TODO - pass + return self._response( + next_state="start", + message={ + "message_type": "error", + "message": "no lines remaining, not implemented", + }, + ) + return self._response( - next_state="start_line", data=self._data_for_next_move_line(remaining_lines) + next_state="start_line", data=self._data_move_line(next_line) ) def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x): lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(filter_func) + # TODO we probably don't care about the postponed order in the 'set + # destination location' step, reset it on 'scan_destination_pack'? return lines.sorted(key=lambda x: x.shopfloor_postponed) - def _assigned_lines_for_picking_batch(self, picking_batch): + def _lines_to_pick(self, picking_batch): return self._lines_for_picking_batch( - picking_batch, filter_func=lambda l: l.state == "assigned" - ) - - def _unpackaged_lines_for_picking_batch(self, picking_batch): - return self._lines_for_picking_batch( - picking_batch, filter_func=lambda l: not l.result_package_id - ) - - def _first_line_for_picking_batch(self, picking_batch, filter_func=lambda x: x): - return fields.first( - self._lines_for_picking_batch(picking_batch, filter_func=filter_func) + picking_batch, + filter_func=lambda l: ( + l.state == "assigned" + # On 'StockPicking.action_assign()', result_package_id is set to + # the same package as 'package_id'. Here, we need to exclude lines + # that were already put into a bin, i.e. the destination package + # is different. + and (not l.result_package_id or l.result_package_id == l.package_id) + ), ) - def _next_line_for_picking_batch(self, picking_batch): - return fields.first(self._unpackaged_lines_for_picking_batch(picking_batch)) + def _next_line_for_pick(self, picking_batch): + remaining_lines = self._lines_to_pick(picking_batch) + return fields.first(remaining_lines) def _response_batch_does_not_exist(self): message = self.actions_for("message") return self._response(next_state="start", message=message.record_not_found()) - def _data_for_next_move_line(self, move_lines): - line = move_lines[0] + def _data_for_next_move_line(self, picking_batch): + remaining_lines = self._lines_to_pick(picking_batch) + return self._data_move_line(remaining_lines[0]) + + def _data_move_line(self, line): picking = line.picking_id batch = picking.batch_id product = line.product_id @@ -372,7 +382,7 @@ def scan_line(self, move_line_id, barcode): return self._response( next_state="start_line", - data=self._data_for_next_move_line(move_line), + data=self._data_move_line(move_line), message=message.barcode_not_found(), ) @@ -380,7 +390,7 @@ def _response_for_scan_line_several_lots_in_loc(self, move_line): message = self.actions_for("message") return self._response( next_state="start_line", - data=self._data_for_next_move_line(move_line), + data=self._data_move_line(move_line), message=message.several_lots_in_location(move_line.location_id), ) @@ -388,7 +398,7 @@ def _response_for_scan_line_several_products_in_loc(self, move_line): message = self.actions_for("message") return self._response( next_state="start_line", - data=self._data_for_next_move_line(move_line), + data=self._data_move_line(move_line), message=message.several_products_in_location(move_line.location_id), ) @@ -396,7 +406,7 @@ def _response_for_scan_line_several_packages_in_loc(self, move_line): message = self.actions_for("message") return self._response( next_state="start_line", - data=self._data_for_next_move_line(move_line), + data=self._data_move_line(move_line), message=message.several_packs_in_location(move_line.location_id), ) @@ -404,13 +414,13 @@ def _response_for_scan_line_product_need_lot(self, move_line): message = self.actions_for("message") return self._response( next_state="start_line", - data=self._data_for_next_move_line(move_line), + data=self._data_move_line(move_line), message=message.scan_lot_on_product_tracked_by_lot(), ) def _response_for_scan_line_ok(self, move_line): return self._response( - next_state="scan_destination", data=self._data_for_next_move_line(move_line) + next_state="scan_destination", data=self._data_move_line(move_line) ) def scan_destination_pack(self, move_line_id, barcode, quantity): @@ -456,15 +466,19 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): # TODO handle next line and no next line (in a shared way with other # endpoints) batch = move_line.picking_id.batch_id - next_line = self._next_line_for_picking_batch(batch) + next_line = self._next_line_for_pick(batch) if not next_line: + # TODO return self._response( next_state="start", - message={"message_type": "info", "message": "Not implemented"}, + message={ + "message_type": "error", + "message": "no lines remaining, not implemented", + }, ) return self._response( next_state="start_line", - data=self._data_for_next_move_line(next_line), + data=self._data_move_line(next_line), message={ "message_type": "info", # TODO different message for products/packs? @@ -512,15 +526,23 @@ def _data_for_unload(self, move_line): } def _response_for_unload_all(self, batch): - # all the lines destinations are the same here - first_line = self._first_line_for_picking_batch(batch) + # all the lines destinations are the same here, it looks + # only for the first one + first_line = self._next_line_for_unload_single(batch) return self._response( next_state="unload_all", data=self._data_for_unload(first_line) ) + def _lines_to_unload(self, batch): + return self._lines_for_picking_batch( + batch, + filter_func=lambda l: l.qty_done > 0 + and l.result_package_id + and not l.shopfloor_unloaded, + ) + def _next_line_for_unload_single(self, batch): - # TODO: shall we use `_first_line_for_picking_batch` + lambda filter? - lines = batch.mapped("picking_ids.move_line_ids") + lines = self._lines_to_unload(batch) for line in lines: if line.shopfloor_unloaded: continue @@ -573,12 +595,18 @@ def skip_line(self, move_line_id): return self._response_for_skip_line(move_line) def _response_for_skip_line(self, move_line): - next_line = self._next_line_for_picking_batch(move_line.picking_id.batch_id) + next_line = self._next_line_for_pick(move_line.picking_id.batch_id) if not next_line: # TODO ensure batch is 'done' and go to start? - return self._response() + return self._response( + next_state="start", + message={ + "message_type": "error", + "message": "no lines remaining, not implemented", + }, + ) return self._response( - next_state="start_line", data=self._data_for_next_move_line(next_line) + next_state="start_line", data=self._data_move_line(next_line) ) def stock_issue(self, move_line_id): @@ -648,7 +676,48 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): generic way to share actions happening on transitions such as "close the batch" """ - return self._response() + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() + + lines = self._lines_to_unload(batch) + if not lines: + # TODO a bit unexpected here but deal with it + return self._response() + + lines.shopfloor_unloaded = True + for line in lines: + # We set the picking to done only when the last line is + # unloaded to avoid backorders. + picking = line.picking_id + if picking.state == "done": + continue + picking_lines = picking.mapped("move_line_ids") + if all(l.shopfloor_unloaded for l in picking_lines): + picking.action_done() + + if all(picking.state == "done" for picking in batch.picking_ids): + # do not use the 'done()' method because it does many things we + # don't care about + batch.state = "done" + return self._response_batch_complete() + + # TODO add tests for this + next_line = self._next_line_for_pick(batch) + if next_line: + return self._response( + next_state="start_line", data=self._data_move_line(next_line) + ) + else: + batch.mapped("picking_ids").action_done() + batch.state = "done" + return self._response_batch_complete() + + def _response_batch_complete(self): + return self._response( + next_state="start", + message={"message_type": "info", "message": _("Batch Transfer complete")}, + ) def unload_split(self, picking_batch_id, barcode, confirmation=False): """Indicates that now the batch must be treated line per line @@ -788,7 +857,7 @@ def change_pack_lot(self): def set_destination_all(self): return { - "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, "barcode": {"required": True, "type": "string"}, "confirmation": {"type": "boolean", "nullable": True, "required": False}, } diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index 463c9951d8..0851b14c04 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -8,20 +8,10 @@ class ClusterPickingCommonCase(CommonCase, PickingBatchMixin): def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) cls.product_a = cls.env["product.product"].create( - { - "name": "Product A", - "type": "product", - "default_code": "A", - "barcode": "A", - } + {"name": "Product A", "type": "product", "default_code": "A", "barcode": "A"} ) cls.product_b = cls.env["product.product"].create( - { - "name": "Product B", - "type": "product", - "default_code": "B", - "barcode": "B", - } + {"name": "Product B", "type": "product", "default_code": "B", "barcode": "B"} ) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") cls.process = cls.menu.process_id @@ -379,9 +369,7 @@ def test_select_not_exists(self): batch_id = self.batch1.id self.batch1.unlink() # Simulate the client selecting the batch in a list - response = self.service.dispatch( - "select", params={"picking_batch_id": batch_id} - ) + response = self.service.dispatch("select", params={"picking_batch_id": batch_id}) self.assert_response( response, next_state="manual_selection", @@ -771,20 +759,65 @@ def test_scan_destination_pack_ok(self): ) -class ClusterPickingPrepareUnloadPackCase(ClusterPickingCommonCase): - """Tests covering the /prepare_unload endpoint +# TODO tests for transitions to next line / no next lines, ... - Destination packages have been set on all the move lines of the batch. - The unload operation will start, but we have 2 paths for this: - 1. unload all the destination packages at the same place - 2. unload the destination packages one by one at different places - - By default, if all the move lines have the same destination, the - first path is used. A flag on the batch picking keeps track of which - path is used. +class ClusterPickingSkipLineCase(ClusterPickingCommonCase): + """Tests covering the /skip_line endpoint """ + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + # quants already existing are from demo data + cls.env["stock.quant"].search( + [("location_id", "=", cls.stock_location.id)] + ).unlink() + cls.batch = cls._create_picking_batch( + [ + [ + cls.BatchProduct(product=cls.product_a, quantity=10), + cls.BatchProduct(product=cls.product_b, quantity=20), + ], + [ + cls.BatchProduct(product=cls.product_a, quantity=30), + cls.BatchProduct(product=cls.product_b, quantity=40), + ], + ] + ) + + def _skip_line(self, line, next_line=None): + response = self.service.dispatch("skip_line", params={"move_line_id": line.id}) + if next_line: + self.assert_response( + response, next_state="start_line", data=self._line_data(next_line) + ) + return response + + def test_skip_line(self): + self._simulate_batch_selected(self.batch, in_package=True) + lines = self.batch.picking_ids.move_line_ids + # 1st line, next is 2nd + self.assertFalse(lines[0].shopfloor_postponed) + self._skip_line(lines[0], lines[1]) + self.assertTrue(lines[0].shopfloor_postponed) + # 2nd line, next is 3rd + self.assertFalse(lines[1].shopfloor_postponed) + self._skip_line(lines[1], lines[2]) + self.assertTrue(lines[1].shopfloor_postponed) + # 3rd line, next is 4th + self.assertFalse(lines[2].shopfloor_postponed) + self._skip_line(lines[2], lines[3]) + self.assertTrue(lines[2].shopfloor_postponed) + # 4th line, next is 1st + # the next line for the last one is the 1st, + # because you'll have to process it anyway + self.assertFalse(lines[3].shopfloor_postponed) + self._skip_line(lines[3], lines[0]) + self.assertTrue(lines[3].shopfloor_postponed) + + +class ClusterPickingUnloadingCommonCase(ClusterPickingCommonCase): @classmethod def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) @@ -818,13 +851,30 @@ def setUpClass(cls, *args, **kwargs): def _set_dest_package_and_done(self, move_lines, dest_package): """Simulate what would have been done in the previous steps""" for line in move_lines: - line.write({"qty_done": line.qty_done, "package_id": dest_package.id}) + line.write( + {"qty_done": line.product_uom_qty, "result_package_id": dest_package.id} + ) + + +class ClusterPickingPrepareUnloadCase(ClusterPickingUnloadingCommonCase): + """Tests covering the /prepare_unload endpoint + + Destination packages have been set on all the move lines of the batch. + The unload operation will start, but we have 2 paths for this: + + 1. unload all the destination packages at the same place + 2. unload the destination packages one by one at different places + + By default, if all the move lines have the same destination, the + first path is used. A flag on the batch picking keeps track of which + path is used. + """ def test_prepare_unload_all_same_dest(self): """All move lines have the same destination location""" move_lines = self.batch.mapped("picking_ids.move_line_ids") - self._set_dest_package_and_done(move_lines[:1], self.bin1) - self._set_dest_package_and_done(move_lines[1:], self.bin2) + self._set_dest_package_and_done(move_lines[:2], self.bin1) + self._set_dest_package_and_done(move_lines[2:], self.bin2) move_lines.write({"location_dest_id": self.packing_location.id}) response = self.service.dispatch( "prepare_unload", params={"picking_batch_id": self.batch.id} @@ -846,8 +896,8 @@ def test_prepare_unload_all_same_dest(self): def test_prepare_unload_different_dest(self): """All move lines have different destination locations""" move_lines = self.batch.mapped("picking_ids.move_line_ids") - self._set_dest_package_and_done(move_lines[:1], self.bin1) - self._set_dest_package_and_done(move_lines[1:], self.bin2) + self._set_dest_package_and_done(move_lines[:2], self.bin1) + self._set_dest_package_and_done(move_lines[2:], self.bin2) move_lines[:1].write({"location_dest_id": self.packing_a_location.id}) move_lines[:1].write({"location_dest_id": self.packing_b_location.id}) response = self.service.dispatch( @@ -869,59 +919,132 @@ def test_prepare_unload_different_dest(self): ) -class ClusterPickingSkipLineCase(ClusterPickingCommonCase): - """Tests covering the /skip_line endpoint +class ClusterPickingSetDestinationAllCase(ClusterPickingUnloadingCommonCase): + """Tests covering the /set_destination_all endpoint + + All the picked lines go to the same destination, a single call to this + endpoint set them as "unloaded" and set the destination. When the last + available line of a picking is unloaded, the picking is set to 'done'. """ @classmethod def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) - # quants already existing are from demo data - cls.env["stock.quant"].search( - [("location_id", "=", cls.stock_location.id)] - ).unlink() - cls.batch = cls._create_picking_batch( + # this is what the /prepare_endpoint method would have set as all the + # destinations are the same: + cls.batch.cluster_picking_unload_all = True + + def test_set_destination_all_ok(self): + """Set destination on all lines for the full batch and end the process""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + # put destination packages, the whole quantity on lines and a similar + # destination (when /set_destination_all is called, all the lines to + # unload must have the same destination) + self._set_dest_package_and_done(move_lines[:2], self.bin1) + self._set_dest_package_and_done(move_lines[2:], self.bin2) + move_lines.write({"location_dest_id": self.packing_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_location.barcode, + }, + ) + # since the whole batch is complete, we expect the batch and all + # pickings to be 'done' + self.assertRecordValues( + move_lines.mapped("picking_id"), [{"state": "done"}, {"state": "done"}] + ) + self.assertRecordValues( + move_lines, [ - [ - cls.BatchProduct(product=cls.product_a, quantity=10), - cls.BatchProduct(product=cls.product_b, quantity=20), - ], - [ - cls.BatchProduct(product=cls.product_a, quantity=30), - cls.BatchProduct(product=cls.product_b, quantity=40), - ], - ] + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "done", + "location_dest_id": self.packing_location.id, + }, + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "done", + "location_dest_id": self.packing_location.id, + }, + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "done", + "location_dest_id": self.packing_location.id, + }, + ], + ) + self.assertRecordValues(self.batch, [{"state": "done"}]) + self.assert_response( + response, + next_state="start", + message={"message_type": "info", "message": "Batch Transfer complete"}, ) - def _skip_line(self, line, next_line=None): - response = self.service.dispatch("skip_line", params={"move_line_id": line.id}) - if next_line: - self.assert_response( - response, next_state="start_line", data=self._line_data(next_line) - ) - return response + def test_set_destination_all_remaining_lines(self): + """Set destination on all lines for a part of the batch""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + # Put destination packages, the whole quantity on lines and a similar + # destination (when /set_destination_all is called, all the lines to + # unload must have the same destination). + # However, we keep a line without qty_done and destination package, + # so when the dest location is set, the endpoint should route back + # to the 'start_line' state to work on the remaining line. + lines_to_unload = move_lines[:2] + self._set_dest_package_and_done(lines_to_unload, self.bin1) + lines_to_unload.write({"location_dest_id": self.packing_location.id}) - def test_skip_line(self): - self._simulate_batch_selected(self.batch, in_package=True) - lines = self.batch.picking_ids.move_line_ids - # 1st line, next is 2nd - self.assertFalse(lines[0].shopfloor_postponed) - self._skip_line(lines[0], lines[1]) - self.assertTrue(lines[0].shopfloor_postponed) - # 2nd line, next is 3rd - self.assertFalse(lines[1].shopfloor_postponed) - self._skip_line(lines[1], lines[2]) - self.assertTrue(lines[1].shopfloor_postponed) - # 3rd line, next is 4th - self.assertFalse(lines[2].shopfloor_postponed) - self._skip_line(lines[2], lines[3]) - self.assertTrue(lines[2].shopfloor_postponed) - # 4th line, next is 1st - # the next line for the last one is the 1st, - # because you'll have to process it anyway - self.assertFalse(lines[3].shopfloor_postponed) - self._skip_line(lines[3], lines[0]) - self.assertTrue(lines[3].shopfloor_postponed) + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_location.barcode, + }, + ) + # Since the whole batch is not complete, state should not be done. + # The picking with one line should be "done" because we unloaded its line. + # The second one still has a line to pick. + one_line_picking = self.batch.picking_ids[0] + two_lines_picking = self.batch.picking_ids[1] + self.assertRecordValues(one_line_picking, [{"state": "done"}]) + self.assertRecordValues(two_lines_picking, [{"state": "assigned"}]) + self.assertRecordValues( + move_lines, + [ + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "done", + "picking_id": one_line_picking.id, + "location_dest_id": self.packing_location.id, + }, + { + "shopfloor_unloaded": True, + "qty_done": 10, + # will be done when the second line of the picking is unloaded + "state": "assigned", + "picking_id": two_lines_picking.id, + "location_dest_id": self.packing_location.id, + }, + { + "shopfloor_unloaded": False, + "qty_done": 0, + "state": "assigned", + "picking_id": two_lines_picking.id, + "location_dest_id": self.packing_location.id, + }, + ], + ) + self.assertRecordValues(self.batch, [{"state": "in_progress"}]) + self.assert_response( + response, next_state="start_line", data=self._line_data(move_lines[2]) + ) -# TODO tests for transitions to next line / no next lines, ... + # TODO add test when /set_destination_all is called but not all lines + # have the same destination From 181df25dfd72c524d15e9b2acde23fbd24e1067a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 25 Feb 2020 15:30:08 +0100 Subject: [PATCH 112/940] cluster_picking: refine /set_destination_all --- shopfloor/models/__init__.py | 1 + shopfloor/models/stock_quant_package.py | 12 ++ shopfloor/services/cluster_picking.py | 120 +++++++++++++---- shopfloor/tests/test_cluster_picking.py | 169 ++++++++++++++++++++++-- 4 files changed, 265 insertions(+), 37 deletions(-) create mode 100644 shopfloor/models/stock_quant_package.py diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index 1cc7ee589a..e9f59c02e3 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -6,4 +6,5 @@ from . import stock_location from . import stock_move_line from . import stock_picking_batch +from . import stock_quant_package from . import res_users diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py new file mode 100644 index 0000000000..63cbf4ab8c --- /dev/null +++ b/shopfloor/models/stock_quant_package.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class StockQuantPackage(models.Model): + _inherit = "stock.quant.package" + + dest_move_line_ids = fields.One2many( + comodel_name="stock.move.line", + inverse_name="result_package_id", + readonly=True, + help="Technical field. Move lines for which destination" " is this package.", + ) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index dc4ab7078d..95a399d73c 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -112,7 +112,8 @@ def list_batch(self): def _select_a_picking_batch(self, batches): # look for in progress + assigned to self first candidates = batches.filtered( - lambda batch: batch.state == "in_progress" and batch.user_id == self.env.user + lambda batch: batch.state == "in_progress" + and batch.user_id == self.env.user ) if candidates: return candidates[0] @@ -490,6 +491,10 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): }, ) + def _are_all_dest_location_same(self, batch): + lines_to_unload = self._lines_to_unload(batch) + return len(lines_to_unload.mapped("location_dest_id")) == 1 + def prepare_unload(self, picking_batch_id): """Initiate the unloading phase of the process @@ -506,7 +511,7 @@ def prepare_unload(self, picking_batch_id): batch = self.env["stock.picking.batch"].browse(picking_batch_id) if not batch.exists(): return self._response_batch_does_not_exist() - if len(batch.mapped("picking_ids.move_line_ids.location_dest_id")) == 1: + if self._are_all_dest_location_same(batch): batch.cluster_picking_unload_all = True return self._response_for_unload_all(batch) else: @@ -514,48 +519,73 @@ def prepare_unload(self, picking_batch_id): batch.cluster_picking_unload_all = False return self._response_for_unload_single(batch) - def _data_for_unload(self, move_line): - batch = move_line.picking_id.batch_id + def _data_for_unload_all(self, batch): + lines = self._lines_to_unload(batch) + # all the lines destinations are the same here, it looks + # only for the first one + first_line = fields.first(lines) return { "id": batch.id, "name": batch.name, "location_dst": { - "id": move_line.location_dest_id.id, - "name": move_line.location_dest_id.name, + "id": first_line.location_dest_id.id, + "name": first_line.location_dest_id.name, }, } - def _response_for_unload_all(self, batch): - # all the lines destinations are the same here, it looks - # only for the first one - first_line = self._next_line_for_unload_single(batch) + def _data_for_unload_single(self, batch, package): + line = fields.first( + package.dest_move_line_ids.filtered(self._filter_for_unload) + ) + return { + # TODO disambiguate "id" everywhere? (id -> package_id) + "id": package.id, + "name": package.name, + "location_dst": { + "id": line.location_dest_id.id, + "name": line.location_dest_id.name, + }, + } + + def _response_for_unload_all(self, batch, message=None): return self._response( - next_state="unload_all", data=self._data_for_unload(first_line) + next_state="unload_all", + data=self._data_for_unload_all(batch), + message=message, ) - def _lines_to_unload(self, batch): - return self._lines_for_picking_batch( - batch, - filter_func=lambda l: l.qty_done > 0 - and l.result_package_id - and not l.shopfloor_unloaded, + def _response_for_unload_all_need_confirm(self, batch, message=None): + return self._response( + next_state="confirm_unload_all", + data=self._data_for_unload_all(batch), + message=message, ) - def _next_line_for_unload_single(self, batch): + def _filter_for_unload(self, line): + return ( + line.qty_done > 0 and line.result_package_id and not line.shopfloor_unloaded + ) + + def _lines_to_unload(self, batch): + return self._lines_for_picking_batch(batch, filter_func=self._filter_for_unload) + + def _bin_packages_to_unload(self, batch): lines = self._lines_to_unload(batch) - for line in lines: - if line.shopfloor_unloaded: - continue - return line - return self.env["stock.move.line"].browse() + packages = lines.mapped("result_package_id") + return packages + + def _next_bin_package_for_unload_single(self, batch): + packages = self._bin_packages_to_unload(batch) + return fields.first(packages) def _response_for_unload_single(self, batch): - next_line = self._next_line_for_unload_single(batch) - if not next_line: + next_package = self._next_bin_package_for_unload_single(batch) + if not next_package: # TODO ensure batch is 'done' and go to start? return self._response() return self._response( - next_state="unload_single", data=self._data_for_unload(next_line) + next_state="unload_single", + data=self._data_for_unload_single(batch, next_package), ) def is_zero(self, move_line_id, zero): @@ -680,12 +710,39 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): if not batch.exists(): return self._response_batch_does_not_exist() + message = self.actions_for("message") + + # In case /set_destination_all was called and the destinations were + # in fact no the same... restart the unloading step over + if not self._are_all_dest_location_same(batch): + return self.prepare_unload(batch.id) + lines = self._lines_to_unload(batch) if not lines: # TODO a bit unexpected here but deal with it return self._response() - lines.shopfloor_unloaded = True + first_line = fields.first(lines) + picking_type = fields.first(batch.picking_ids).picking_type_id + scanned_location = self.actions_for("search").location_from_scan(barcode) + if not scanned_location: + return self._response_for_unload_all( + batch, message=message.no_location_found() + ) + if not scanned_location.is_sublocation_of( + picking_type.default_location_dest_id + ): + return self._response_for_unload_all( + batch, message=message.dest_location_not_allowed() + ) + + if not scanned_location.is_sublocation_of(first_line.location_dest_id): + if not confirmation: + return self._response_for_unload_all_need_confirm(batch) + + lines.write( + {"shopfloor_unloaded": True, "location_dest_id": scanned_location.id} + ) for line in lines: # We set the picking to done only when the last line is # unloaded to avoid backorders. @@ -702,13 +759,15 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): batch.state = "done" return self._response_batch_complete() - # TODO add tests for this next_line = self._next_line_for_pick(batch) if next_line: return self._response( next_state="start_line", data=self._data_move_line(next_line) ) else: + # TODO add tests for this (for instance a picking is not 'done' + # because a move was unassigned, we want to validate the batch to + # produce backorders) batch.mapped("picking_ids").action_done() batch.state = "done" return self._response_batch_complete() @@ -1012,6 +1071,9 @@ def set_destination_all(self): "start_line", # invalid destination, have to scan a valid one "unload_all", + # this endpoint was called but after checking, lines + # have different destination locations + "unload_single", # different destination to confirm "confirm_unload_all", # batch finished @@ -1183,7 +1245,7 @@ def _schema_for_unload_all(self): @property def _schema_for_unload_single(self): return { - # stock.move.line + # stock.quant.package "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, "location_dst": { diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index 0851b14c04..7547df2f46 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -8,15 +8,26 @@ class ClusterPickingCommonCase(CommonCase, PickingBatchMixin): def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) cls.product_a = cls.env["product.product"].create( - {"name": "Product A", "type": "product", "default_code": "A", "barcode": "A"} + { + "name": "Product A", + "type": "product", + "default_code": "A", + "barcode": "A", + } ) cls.product_b = cls.env["product.product"].create( - {"name": "Product B", "type": "product", "default_code": "B", "barcode": "B"} + { + "name": "Product B", + "type": "product", + "default_code": "B", + "barcode": "B", + } ) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id + cls.wh.delivery_steps = "pick_pack_ship" cls.picking_type = cls.process.picking_type_id def setUp(self): @@ -369,7 +380,9 @@ def test_select_not_exists(self): batch_id = self.batch1.id self.batch1.unlink() # Simulate the client selecting the batch in a list - response = self.service.dispatch("select", params={"picking_batch_id": batch_id}) + response = self.service.dispatch( + "select", params={"picking_batch_id": batch_id} + ) self.assert_response( response, next_state="manual_selection", @@ -909,8 +922,8 @@ def test_prepare_unload_different_dest(self): response, next_state="unload_single", data={ - "id": self.batch.id, - "name": self.batch.name, + "id": self.bin1.id, + "name": self.bin1.name, "location_dst": { "id": first_line.location_dest_id.id, "name": first_line.location_dest_id.name, @@ -1043,8 +1056,148 @@ def test_set_destination_all_remaining_lines(self): self.assertRecordValues(self.batch, [{"state": "in_progress"}]) self.assert_response( - response, next_state="start_line", data=self._line_data(move_lines[2]) + # the remaining move line still needs to be picked + response, + next_state="start_line", + data=self._line_data(move_lines[2]), ) - # TODO add test when /set_destination_all is called but not all lines - # have the same destination + def test_set_destination_all_but_different_dest(self): + """Endpoint was called but destinations are different""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines[:2].write({"location_dest_id": self.packing_a_location.id}) + move_lines[2:].write({"location_dest_id": self.packing_b_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_location.barcode, + }, + ) + self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}]) + self.assert_response( + response, + next_state="unload_single", + data={ + "id": self.bin1.id, + "name": self.bin1.name, + "location_dst": { + "id": move_lines[0].location_dest_id.id, + "name": move_lines[0].location_dest_id.name, + }, + }, + ) + + def test_set_destination_all_error_location_not_found(self): + """Endpoint called with a barcode not existing for a location""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines.write({"location_dest_id": self.packing_a_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={"picking_batch_id": self.batch.id, "barcode": "NOTFOUND"}, + ) + self.assert_response( + response, + next_state="unload_all", + data={ + "id": self.batch.id, + "name": self.batch.name, + "location_dst": { + "id": move_lines[0].location_dest_id.id, + "name": move_lines[0].location_dest_id.name, + }, + }, + message={ + "message_type": "error", + "message": "No location found for this barcode.", + }, + ) + + def test_set_destination_all_error_location_invalid(self): + """Endpoint called with a barcode for an invalid location + + It is invalid when the location is not the destination location or + sublocation of the picking type. + """ + move_lines = self.batch.mapped("picking_ids.move_line_ids") + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines.write({"location_dest_id": self.packing_a_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.dispatch_location.barcode, + }, + ) + self.assert_response( + response, + next_state="unload_all", + data={ + "id": self.batch.id, + "name": self.batch.name, + "location_dst": { + "id": move_lines[0].location_dest_id.id, + "name": move_lines[0].location_dest_id.name, + }, + }, + message={"message_type": "error", "message": "You cannot place it here"}, + ) + + def test_set_destination_all_need_confirmation(self): + """Endpoint called with a barcode for another (valid) location""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines.write({"location_dest_id": self.packing_a_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_b_location.barcode, + }, + ) + self.assert_response( + response, + next_state="confirm_unload_all", + data={ + "id": self.batch.id, + "name": self.batch.name, + "location_dst": { + "id": move_lines[0].location_dest_id.id, + "name": move_lines[0].location_dest_id.name, + }, + }, + ) + + def test_set_destination_all_with_confirmation(self): + """Endpoint called with a barcode for another (valid) location, confirm""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines.write({"location_dest_id": self.packing_a_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_b_location.barcode, + "confirmation": True, + }, + ) + self.assertRecordValues( + move_lines, + [ + {"location_dest_id": self.packing_b_location.id}, + {"location_dest_id": self.packing_b_location.id}, + {"location_dest_id": self.packing_b_location.id}, + ], + ) + self.assert_response( + response, + next_state="start", + message={"message_type": "info", "message": "Batch Transfer complete"}, + ) From ec4c97dc741d8460feaea2950ca2ba781a719278 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 25 Feb 2020 16:14:19 +0100 Subject: [PATCH 113/940] cluster_picking: add /unload_split --- shopfloor/services/cluster_picking.py | 10 ++++-- shopfloor/tests/test_cluster_picking.py | 44 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 95a399d73c..4edaf8bb26 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -778,7 +778,7 @@ def _response_batch_complete(self): message={"message_type": "info", "message": _("Batch Transfer complete")}, ) - def unload_split(self, picking_batch_id, barcode, confirmation=False): + def unload_split(self, picking_batch_id): """Indicates that now the batch must be treated line per line Even if the move lines to unload all have the same destination. @@ -792,7 +792,13 @@ def unload_split(self, picking_batch_id, barcode, confirmation=False): Transitions: * unload_single: always goes here since we now want to unload line per line """ - return self._response() + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() + + batch.cluster_picking_unload_all = False + + return self._response_for_unload_single(batch) def unload_router(self, picking_batch_id): """Called after the info screen, route to the next state diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index 7547df2f46..8609c989da 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -1201,3 +1201,47 @@ def test_set_destination_all_with_confirmation(self): next_state="start", message={"message_type": "info", "message": "Batch Transfer complete"}, ) + + +class ClusterPickingUnloadSplitCase(ClusterPickingUnloadingCommonCase): + """Tests covering the /unload_split endpoint + + All the destinations of the bins were the same so the "unload all" screen + was presented to the user, but they want different destination, so they hit + the "split" button. From now on, the workflow should use the "unload single" + screen even if the destinations are the same. + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + # this is what the /prepare_endpoint method would have set as all the + # destinations are the same: + cls.batch.cluster_picking_unload_all = True + + def test_unload_split_ok(self): + """Call /unload_split and continue to unload single""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + # put destination packages, the whole quantity on lines and a similar + # destination (when /set_destination_all is called, all the lines to + # unload must have the same destination) + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines.write({"location_dest_id": self.packing_location.id}) + + response = self.service.dispatch( + "unload_split", params={"picking_batch_id": self.batch.id} + ) + self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}]) + self.assert_response( + # the remaining move line still needs to be picked + response, + next_state="unload_single", + data={ + "id": self.bin1.id, + "name": self.bin1.name, + "location_dst": { + "id": move_lines[0].location_dest_id.id, + "name": move_lines[0].location_dest_id.name, + }, + }, + ) From cb85a9b269e78cae9588e0e79b6afe2c7f98b04a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 25 Feb 2020 17:01:47 +0100 Subject: [PATCH 114/940] cluster_picking add /unload_scan_pack --- shopfloor/services/cluster_picking.py | 44 ++++++++++--- shopfloor/tests/test_cluster_picking.py | 87 +++++++++++++++++++++++-- 2 files changed, 116 insertions(+), 15 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 4edaf8bb26..f3400b21ea 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -538,9 +538,10 @@ def _data_for_unload_single(self, batch, package): package.dest_move_line_ids.filtered(self._filter_for_unload) ) return { - # TODO disambiguate "id" everywhere? (id -> package_id) - "id": package.id, - "name": package.name, + # TODO disambiguate "id" everywhere? (id -> picking_batch_id) + "id": batch.id, + "name": batch.name, + "package": {"id": package.id, "name": package.name}, "location_dst": { "id": line.location_dest_id.id, "name": line.location_dest_id.name, @@ -815,7 +816,7 @@ def unload_router(self, picking_batch_id): """ return self._response() - def unload_scan_pack(self, package_id, barcode): + def unload_scan_pack(self, picking_batch_id, package_id, barcode): """Check that the operator scans the correct package (bin) on unload If the scanned barcode is not the one of the Bin (package), ask to scan @@ -825,9 +826,27 @@ def unload_scan_pack(self, package_id, barcode): * unload_single: if the barcode does not match * unload_set_destination: barcode is correct """ - return self._response() + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() + package = self.env["stock.quant.package"].browse(package_id) + if not package.exists(): + # TODO: next package? if no next package, close and go to start? + return self._response() + if package.name != barcode: + return self._response( + next_state="unload_single", + data=self._data_for_unload_single(batch, package), + message={"message_type": "error", "message": _("Wrong bin")}, + ) + return self._response( + next_state="unload_set_destination", + data=self._data_for_unload_single(batch, package), + ) - def unload_scan_destination(self, package_id, barcode, confirmation=False): + def unload_scan_destination( + self, picking_batch_id, package_id, barcode, confirmation=False + ): """Scan the final destination for all the move lines moved with the Bin It updates all the assigned move lines with the package to the @@ -939,13 +958,15 @@ def unload_router(self): def unload_scan_pack(self): return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, "package_id": {"coerce": to_int, "required": True, "type": "integer"}, "barcode": {"required": True, "type": "string"}, } def unload_scan_destination(self): return { - "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, "barcode": {"required": True, "type": "string"}, "confirmation": {"type": "boolean", "nullable": True, "required": False}, } @@ -1251,9 +1272,16 @@ def _schema_for_unload_all(self): @property def _schema_for_unload_single(self): return { - # stock.quant.package + # stock.batch.picking "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, + "package": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, "location_dst": { "type": "dict", "schema": { diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py index 8609c989da..e9a5674a0d 100644 --- a/shopfloor/tests/test_cluster_picking.py +++ b/shopfloor/tests/test_cluster_picking.py @@ -861,7 +861,8 @@ def setUpClass(cls, *args, **kwargs): } ) - def _set_dest_package_and_done(self, move_lines, dest_package): + @classmethod + def _set_dest_package_and_done(cls, move_lines, dest_package): """Simulate what would have been done in the previous steps""" for line in move_lines: line.write( @@ -922,8 +923,9 @@ def test_prepare_unload_different_dest(self): response, next_state="unload_single", data={ - "id": self.bin1.id, - "name": self.bin1.name, + "id": self.batch.id, + "name": self.batch.name, + "package": {"id": self.bin1.id, "name": self.bin1.name}, "location_dst": { "id": first_line.location_dest_id.id, "name": first_line.location_dest_id.name, @@ -1081,8 +1083,9 @@ def test_set_destination_all_but_different_dest(self): response, next_state="unload_single", data={ - "id": self.bin1.id, - "name": self.bin1.name, + "id": self.batch.id, + "name": self.batch.name, + "package": {"id": self.bin1.id, "name": self.bin1.name}, "location_dst": { "id": move_lines[0].location_dest_id.id, "name": move_lines[0].location_dest_id.name, @@ -1237,11 +1240,81 @@ def test_unload_split_ok(self): response, next_state="unload_single", data={ - "id": self.bin1.id, - "name": self.bin1.name, + "id": self.batch.id, + "name": self.batch.name, + "package": {"id": self.bin1.id, "name": self.bin1.name}, "location_dst": { "id": move_lines[0].location_dest_id.id, "name": move_lines[0].location_dest_id.name, }, }, ) + + +class ClusterPickingUnloadScanPackCase(ClusterPickingUnloadingCommonCase): + """Tests covering the /unload_scan_pack endpoint + + Goods have been put in the package bins, they have different destinations + or /unload_split has been called, now user has to unload package per + package. For this, they'll first scan the bin package, which will call the + endpoint /unload_scan_pack. (second step will be to set the destination + with /unload_scan_destination, in a different test case) + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.batch.cluster_picking_unload_all = False + cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids") + cls._set_dest_package_and_done(cls.move_lines, cls.bin1) + cls.move_lines[:2].write({"location_dest_id": cls.packing_a_location.id}) + cls.move_lines[2:].write({"location_dest_id": cls.packing_b_location.id}) + + def test_unload_scan_pack_ok(self): + """Endpoint /unload_scan_pack is called, result ok""" + response = self.service.dispatch( + "unload_scan_pack", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": self.bin1.name, + }, + ) + self.assert_response( + response, + next_state="unload_set_destination", + data={ + "id": self.batch.id, + "name": self.batch.name, + "package": {"id": self.bin1.id, "name": self.bin1.name}, + "location_dst": { + "id": self.move_lines[0].location_dest_id.id, + "name": self.move_lines[0].location_dest_id.name, + }, + }, + ) + + def test_unload_scan_pack_wrong_barcode(self): + """Endpoint /unload_scan_pack is called, wrong barcode scanned""" + response = self.service.dispatch( + "unload_scan_pack", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": self.bin2.name, + }, + ) + self.assert_response( + response, + next_state="unload_single", + data={ + "id": self.batch.id, + "name": self.batch.name, + "package": {"id": self.bin1.id, "name": self.bin1.name}, + "location_dst": { + "id": self.move_lines[0].location_dest_id.id, + "name": self.move_lines[0].location_dest_id.name, + }, + }, + message={"message_type": "error", "message": "Wrong bin"}, + ) From bc4c90781a5694c03f52ebe8a7cdb2578bd3cb54 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 26 Feb 2020 10:31:37 +0100 Subject: [PATCH 115/940] backend: split cluster_picking tests --- shopfloor/tests/__init__.py | 6 +- shopfloor/tests/test_cluster_picking.py | 1320 ----------------- shopfloor/tests/test_cluster_picking_base.py | 116 ++ shopfloor/tests/test_cluster_picking_scan.py | 236 +++ .../tests/test_cluster_picking_select.py | 428 ++++++ shopfloor/tests/test_cluster_picking_skip.py | 59 + .../tests/test_cluster_picking_unload.py | 491 ++++++ 7 files changed, 1335 insertions(+), 1321 deletions(-) delete mode 100644 shopfloor/tests/test_cluster_picking.py create mode 100644 shopfloor/tests/test_cluster_picking_base.py create mode 100644 shopfloor/tests/test_cluster_picking_scan.py create mode 100644 shopfloor/tests/test_cluster_picking_select.py create mode 100644 shopfloor/tests/test_cluster_picking_skip.py create mode 100644 shopfloor/tests/test_cluster_picking_unload.py diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index e4331331dd..8e179399c0 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -4,5 +4,9 @@ from . import test_picking_batch from . import test_single_pack_putaway from . import test_single_pack_transfer -from . import test_cluster_picking +from . import test_cluster_picking_base +from . import test_cluster_picking_select +from . import test_cluster_picking_scan +from . import test_cluster_picking_skip +from . import test_cluster_picking_unload from . import test_checkout diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py deleted file mode 100644 index e9a5674a0d..0000000000 --- a/shopfloor/tests/test_cluster_picking.py +++ /dev/null @@ -1,1320 +0,0 @@ -import unittest - -from .common import CommonCase, PickingBatchMixin - - -class ClusterPickingCommonCase(CommonCase, PickingBatchMixin): - @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - cls.product_a = cls.env["product.product"].create( - { - "name": "Product A", - "type": "product", - "default_code": "A", - "barcode": "A", - } - ) - cls.product_b = cls.env["product.product"].create( - { - "name": "Product B", - "type": "product", - "default_code": "B", - "barcode": "B", - } - ) - cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") - cls.process = cls.menu.process_id - cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") - cls.wh = cls.profile.warehouse_id - cls.wh.delivery_steps = "pick_pack_ship" - cls.picking_type = cls.process.picking_type_id - - def setUp(self): - super().setUp() - with self.work_on_services(menu=self.menu, profile=self.profile) as work: - self.service = work.component(usage="cluster_picking") - - @classmethod - def _simulate_batch_selected(cls, batches, in_package=False, in_lot=False): - """Create a state as if a batch was selected by the user - - * The picking batch is in progress - * It is assigned to the current user - * All the move lines are available - - Note: currently, this method create a source package that contains - all the products of the batch. It is enough for the current tests. - """ - pickings = batches.mapped("picking_ids") - cls._fill_stock_for_moves( - pickings.mapped("move_lines"), in_package=in_package, in_lot=in_lot - ) - pickings.action_assign() - batches.write({"state": "in_progress", "user_id": cls.env.uid}) - - def _line_data(self, move_line, qty=None): - picking = move_line.picking_id - batch = picking.batch_id - # A package exists on the move line, because the quant created - # by ``_simulate_batch_selected`` has a package. - package = move_line.package_id - lot = move_line.lot_id - return { - "id": move_line.id, - "quantity": qty or move_line.product_uom_qty, - "postponed": move_line.shopfloor_postponed, - "location_dst": { - "id": move_line.location_dest_id.id, - "name": move_line.location_dest_id.name, - }, - "location_src": { - "id": move_line.location_id.id, - "name": move_line.location_id.name, - }, - "picking": { - "id": picking.id, - "name": picking.name, - "note": "", - "origin": picking.origin, - }, - "batch": {"id": batch.id, "name": batch.name}, - "product": { - "default_code": move_line.product_id.default_code, - "display_name": move_line.product_id.display_name, - "id": move_line.product_id.id, - "name": move_line.product_id.name, - "qty_available": move_line.product_id.qty_available, - }, - "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or ""} - if lot - else None, - "pack": {"id": package.id, "name": package.name} if package else None, - } - - -class ClusterPickingAPICase(ClusterPickingCommonCase): - """Base tests for the cluster picking API""" - - def test_to_openapi(self): - # will raise if it fails to generate the openapi specs - self.service.to_openapi() - - -class ClusterPickingSelectionCase(ClusterPickingCommonCase): - """Tests covering the selection of picking batches - - Endpoints: - - * /cluster_picking/find_batch - * /cluster_picking/list_batch - * /cluster_picking/select - * /cluster_picking/unassign - - These endpoints interact with a list of picking batches. - The other endpoints that interact with a single batch (after selection) - are handled in other classes. - """ - - @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - # drop base demo data and create our own batches to work with - cls.env["stock.picking.batch"].search([]).unlink() - cls.batch1 = cls._create_picking_batch( - [[cls.BatchProduct(product=cls.product_a, quantity=1)]] - ) - cls.batch2 = cls._create_picking_batch( - [[cls.BatchProduct(product=cls.product_a, quantity=1)]] - ) - cls.batch3 = cls._create_picking_batch( - [[cls.BatchProduct(product=cls.product_a, quantity=1)]] - ) - cls.batch4 = cls._create_picking_batch( - [[cls.BatchProduct(product=cls.product_a, quantity=1)]] - ) - - def _add_stock_and_assign_pickings_for_batches(self, batches): - pickings = batches.mapped("picking_ids") - self._fill_stock_for_moves(pickings.mapped("move_lines")) - pickings.action_assign() - - def test_find_batch_in_progress_current_user(self): - """Find an in-progress batch assigned to the current user""" - # Simulate the client asking a batch by clicking on "get work" - self._add_stock_and_assign_pickings_for_batches( - self.batch1 | self.batch2 | self.batch3 - ) - self.batch3.user_id = self.env.uid - self.batch3.confirm_picking() # set to in progress - response = self.service.dispatch("find_batch") - - # we expect to find batch 3 as it's assigned to the current - # user and in progress (first priority) - self.assert_response( - response, - next_state="confirm_start", - data={ - "id": self.batch3.id, - "name": self.batch3.name, - # TODO - "weight": 0, - "pickings": [ - { - "id": self.batch3.picking_ids.id, - "name": self.batch3.picking_ids.name, - "move_line_count": len(self.batch3.picking_ids.move_line_ids), - "origin": self.batch3.picking_ids.origin, - "partner": { - "id": self.batch3.picking_ids.partner_id.id, - "name": self.batch3.picking_ids.partner_id.name, - }, - } - ], - }, - ) - - def test_find_batch_assigned(self): - """Find a draft batch assigned to the current user""" - # batches must have all their pickings available to be selected - self._add_stock_and_assign_pickings_for_batches( - self.batch1 | self.batch2 | self.batch3 - ) - # batch2 in draft but assigned to the current user should be - # selected before the others - self.batch2.user_id = self.env.uid - response = self.service.dispatch("find_batch") - - # The endpoint starts the batch - self.assertEqual(self.batch2.state, "in_progress") - - # we expect to find batch 2 as it's assigned to the current user - self.assert_response( - response, - next_state="confirm_start", - data={ - "id": self.batch2.id, - "name": self.batch2.name, - # TODO - "weight": 0, - "pickings": [ - { - "id": self.batch2.picking_ids.id, - "name": self.batch2.picking_ids.name, - "move_line_count": len(self.batch2.picking_ids.move_line_ids), - "origin": self.batch2.picking_ids.origin, - "partner": { - "id": self.batch2.picking_ids.partner_id.id, - "name": self.batch2.picking_ids.partner_id.name, - }, - } - ], - }, - ) - - def test_find_batch_unassigned_draft(self): - """Find a draft batch""" - # batches must have all their pickings available to be selected - self._add_stock_and_assign_pickings_for_batches(self.batch2 | self.batch3) - # batch1 has not all pickings available, so the first draft - # is batch2, should be selected - response = self.service.dispatch("find_batch") - - # The endpoint starts the batch and assign it to self - self.assertEqual(self.batch2.user_id, self.env.user) - self.assertEqual(self.batch2.state, "in_progress") - - # we expect to find batch 2 as it's the first one with all pickings - # available - self.assert_response( - response, - next_state="confirm_start", - data={ - "id": self.batch2.id, - "name": self.batch2.name, - # TODO - "weight": 0, - "pickings": [ - { - "id": self.batch2.picking_ids.id, - "name": self.batch2.picking_ids.name, - "move_line_count": len(self.batch2.picking_ids.move_line_ids), - "origin": self.batch2.picking_ids.origin, - "partner": { - "id": self.batch2.picking_ids.partner_id.id, - "name": self.batch2.picking_ids.partner_id.name, - }, - } - ], - }, - ) - - def test_find_batch_not_found(self): - """No batch to work on""" - # No batch match the rules to work on them, because - # their pickings are not available - response = self.service.dispatch("find_batch") - - self.assert_response( - response, - next_state="start", - message={ - "message_type": "info", - "message": "No more work to do, please create a new batch transfer", - }, - ) - - def test_list_batch(self): - """List all available batches""" - # batches must have all their pickings available to be selected - self._add_stock_and_assign_pickings_for_batches( - self.batch1 | self.batch2 | self.batch3 - ) - self.batch1.write({"state": "in_progress", "user_id": self.env.uid}) - self.batch2.write( - {"state": "in_progress", "user_id": self.env.ref("base.user_demo")} - ) - self.batch3.write({"state": "draft", "user_id": False}) - - self.assertEqual( - self.env["stock.picking.batch"].search([]), - self.batch1 + self.batch2 + self.batch3 + self.batch4, - ) - # Simulate the client asking the list of batches - response = self.service.dispatch("list_batch") - self.assert_response( - response, - next_state="manual_selection", - data={ - "size": 2, - "records": [ - { - "id": self.batch1.id, - "name": self.batch1.name, - "picking_count": 1, - "move_line_count": 1, - }, - # batch 2 is excluded because assigned to someone else - { - "id": self.batch3.id, - "name": self.batch3.name, - "picking_count": 1, - "move_line_count": 1, - }, - # batch 4 is excluded because not all of its pickings are - # assigned - ], - }, - ) - - def test_select_in_progress_assigned(self): - """Select an in-progress batch assigned to the current user""" - self._add_stock_and_assign_pickings_for_batches(self.batch1) - self.batch1.write({"state": "in_progress", "user_id": self.env.uid}) - # Simulate the client selecting the batch in a list - response = self.service.dispatch( - "select", params={"picking_batch_id": self.batch1.id} - ) - self.assert_response( - response, - next_state="confirm_start", - data={ - "id": self.batch1.id, - "name": self.batch1.name, - # we don't care in these tests, the 'find_batch' tests already - # check this - "weight": self.ANY, - "pickings": self.ANY, - }, - ) - - def test_select_draft_assigned(self): - """Select a draft batch assigned to the current user""" - self._add_stock_and_assign_pickings_for_batches(self.batch1) - self.batch1.write({"user_id": self.env.uid}) - # Simulate the client selecting the batch in a list - response = self.service.dispatch( - "select", params={"picking_batch_id": self.batch1.id} - ) - # The endpoint starts the batch and assign it to self - self.assertEqual(self.batch1.user_id, self.env.user) - self.assertEqual(self.batch1.state, "in_progress") - self.assert_response( - response, - next_state="confirm_start", - data={ - "id": self.batch1.id, - "name": self.batch1.name, - # we don't care in these tests, the 'find_batch' tests already - # check this - "weight": self.ANY, - "pickings": self.ANY, - }, - ) - - def test_select_draft_unassigned(self): - """Select a draft batch not assigned to a user""" - self._add_stock_and_assign_pickings_for_batches(self.batch1) - # Simulate the client selecting the batch in a list - response = self.service.dispatch( - "select", params={"picking_batch_id": self.batch1.id} - ) - # The endpoint starts the batch and assign it to self - self.assertEqual(self.batch1.user_id, self.env.user) - self.assertEqual(self.batch1.state, "in_progress") - self.assert_response( - response, - next_state="confirm_start", - data={ - "id": self.batch1.id, - "name": self.batch1.name, - # we don't care in these tests, the 'find_batch' tests already - # check this - "weight": self.ANY, - "pickings": self.ANY, - }, - ) - - def test_select_not_exists(self): - """Select a draft that does not exist""" - batch_id = self.batch1.id - self.batch1.unlink() - # Simulate the client selecting the batch in a list - response = self.service.dispatch( - "select", params={"picking_batch_id": batch_id} - ) - self.assert_response( - response, - next_state="manual_selection", - message={ - "message_type": "warning", - "message": "This batch cannot be selected.", - }, - data={"size": 0, "records": []}, - ) - - def test_select_already_assigned(self): - """Select a draft that does not exist""" - self._add_stock_and_assign_pickings_for_batches(self.batch1) - self.batch1.write( - {"state": "in_progress", "user_id": self.env.ref("base.user_demo")} - ) - # Simulate the client selecting the batch in a list - response = self.service.dispatch( - "select", params={"picking_batch_id": self.batch1.id} - ) - self.assert_response( - response, - next_state="manual_selection", - message={ - "message_type": "warning", - "message": "This batch cannot be selected.", - }, - data={"size": 0, "records": []}, - ) - - def test_unassign_batch(self): - """User cancels after selecting a batch, unassign it""" - self._simulate_batch_selected(self.batch1) - # Simulate the client selecting the batch in a list - response = self.service.dispatch( - "unassign", params={"picking_batch_id": self.batch1.id} - ) - self.assertEqual(self.batch1.state, "draft") - self.assertFalse(self.batch1.user_id) - self.assert_response(response, next_state="start") - - def test_unassign_batch_not_exists(self): - """User cancels after selecting a batch deleted meanwhile""" - batch_id = self.batch1.id - self.batch1.unlink() - # Simulate the client selecting the batch in a list - response = self.service.dispatch( - "unassign", params={"picking_batch_id": batch_id} - ) - self.assert_response(response, next_state="start") - - -class ClusterPickingSelectedCase(ClusterPickingCommonCase): - """Tests covering endpoints working on a single picking batch - - After a batch has been selected, by the tests covered in - ``ClusterPickingSelectionCase``. - """ - - @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - cls.batch = cls._create_picking_batch( - [[cls.BatchProduct(product=cls.product_a, quantity=1)]] - ) - cls._simulate_batch_selected(cls.batch, in_package=True) - - def test_confirm_start_ok(self): - """User confirms she starts the selected picking batch (happy path)""" - # batch1 was already selected, we only need to confirm the selection - batch = self.batch - self.assertEqual(batch.state, "in_progress") - picking = batch.picking_ids[0] - first_move_line = picking.move_line_ids[0] - self.assertTrue(first_move_line) - # A package exists on the move line, because the quant created - # by ``_simulate_batch_selected`` has a package. - package = first_move_line.package_id - self.assertTrue(package) - - response = self.service.dispatch( - "confirm_start", params={"picking_batch_id": self.batch.id} - ) - self.assert_response( - response, - data={ - "id": first_move_line.id, - "quantity": 1.0, - "postponed": False, - "location_dst": { - "id": first_move_line.location_dest_id.id, - "name": first_move_line.location_dest_id.name, - }, - "location_src": { - "id": first_move_line.location_id.id, - "name": first_move_line.location_id.name, - }, - "picking": { - "id": picking.id, - "name": picking.name, - "note": "", - "origin": picking.origin, - }, - "batch": {"id": batch.id, "name": batch.name}, - "product": { - "default_code": first_move_line.product_id.default_code, - "display_name": first_move_line.product_id.display_name, - "id": first_move_line.product_id.id, - "name": first_move_line.product_id.name, - "qty_available": first_move_line.product_id.qty_available, - }, - "lot": None, - "pack": {"id": package.id, "name": package.name}, - }, - next_state="start_line", - ) - - def test_confirm_start_not_exists(self): - """User confirms she starts but batch has been deleted meanwhile""" - batch_id = self.batch.id - self.batch.unlink() - response = self.service.dispatch( - "confirm_start", params={"picking_batch_id": batch_id} - ) - self.assert_response( - response, - message={ - "message_type": "error", - "message": "This record you were working on does not exist anymore.", - }, - next_state="start", - ) - - # TODO - @unittest.skip("not sure yet what we have to do, keep for later") - def test_confirm_start_all_is_done(self): - """User confirms start but all lines are already done""" - # we want to jump to the start because there are no lines - # to process anymore, but we want to set pickings and - # picking batch to done if not done yet (because the process - # was interrupted for instance) - - -class ClusterPickingLineCommonCase(ClusterPickingCommonCase): - @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - # quants already existing are from demo data - cls.env["stock.quant"].search( - [("location_id", "=", cls.stock_location.id)] - ).unlink() - cls.batch = cls._create_picking_batch( - [[cls.BatchProduct(product=cls.product_a, quantity=1)]] - ) - - def _line_data(self, move_line, qty=1.0): - # just force qty to 1.0 - return super()._line_data(move_line, qty=qty) - - -class ClusterPickingScanLineCase(ClusterPickingLineCommonCase): - """Tests covering the /scan_line endpoint - - After a batch has been selected and the user confirmed they are - working on it. - - User scans something and the scan_line endpoints validates they - scanned the proper thing to pick. - """ - - def _scan_line_ok(self, line, scanned): - response = self.service.dispatch( - "scan_line", params={"move_line_id": line.id, "barcode": scanned} - ) - self.assert_response( - response, next_state="scan_destination", data=self._line_data(line) - ) - - def _scan_line_error(self, line, scanned, message): - response = self.service.dispatch( - "scan_line", params={"move_line_id": line.id, "barcode": scanned} - ) - self.assert_response( - response, - next_state="start_line", - data=self._line_data(line), - message=message, - ) - - def test_scan_line_pack_ok(self): - """Scan to check if user picks the correct pack for current line""" - self._simulate_batch_selected(self.batch, in_package=True) - line = self.batch.picking_ids.move_line_ids - self._scan_line_ok(line, line.package_id.name) - - def test_scan_line_product_ok(self): - """Scan to check if user picks the correct product for current line""" - self._simulate_batch_selected(self.batch) - line = self.batch.picking_ids.move_line_ids - self._scan_line_ok(line, line.product_id.barcode) - - def test_scan_line_lot_ok(self): - """Scan to check if user picks the correct lot for current line""" - self.product_a.tracking = "lot" - self._simulate_batch_selected(self.batch, in_lot=True) - line = self.batch.picking_ids.move_line_ids - self._scan_line_ok(line, line.lot_id.name) - - def test_scan_line_serial_ok(self): - """Scan to check if user picks the correct serial for current line""" - self.product_a.tracking = "serial" - self._simulate_batch_selected(self.batch, in_lot=True) - line = self.batch.picking_ids.move_line_ids - self._scan_line_ok(line, line.lot_id.name) - - def test_scan_line_error_product_tracked(self): - """Scan a product tracked by lot, must scan the lot""" - self.product_a.tracking = "lot" - self._simulate_batch_selected(self.batch, in_lot=True) - line = self.batch.picking_ids.move_line_ids - self._scan_line_error( - line, - line.product_id.barcode, - { - "message_type": "warning", - "message": "Product tracked by lot, please scan one.", - }, - ) - - def test_scan_line_location_ok_single_package(self): - """Scan to check if user scans a correct location for current line - - If there is only one single package in the location, there is no - ambiguity so we can use it. - """ - self._simulate_batch_selected(self.batch, in_package=True) - line = self.batch.picking_ids.move_line_ids - self._scan_line_ok(line, line.location_id.barcode) - - def test_scan_line_location_ok_single_product(self): - """Scan to check if user scans a correct location for current line - - If there is only one single product in the location, there is no - ambiguity so we can use it. - """ - self._simulate_batch_selected(self.batch) - line = self.batch.picking_ids.move_line_ids - self._scan_line_ok(line, line.location_id.barcode) - - def test_scan_line_location_ok_single_lot(self): - """Scan to check if user scans a correct location for current line - - If there is only one single lot in the location, there is no - ambiguity so we can use it. - """ - self._simulate_batch_selected(self.batch, in_lot=True) - line = self.batch.picking_ids.move_line_ids - self._scan_line_ok(line, line.location_id.barcode) - - def test_scan_line_location_error_several_package(self): - """Scan to check if user scans a correct location for current line - - If there are several packages in the location, user has to scan one. - """ - self._simulate_batch_selected(self.batch, in_package=True) - line = self.batch.picking_ids.move_line_ids - location = line.location_id - # add a second package in the location - self._update_qty_in_location( - location, - self.product_b, - 10, - package=self.env["stock.quant.package"].create({}), - ) - self._scan_line_error( - line, - location.barcode, - { - "message_type": "warning", - "message": "Several packages found in Stock, please scan a package.", - }, - ) - - def test_scan_line_location_error_several_products(self): - """Scan to check if user scans a correct location for current line - - If there are several products in the location, user has to scan one. - """ - self._simulate_batch_selected(self.batch) - line = self.batch.picking_ids.move_line_ids - location = line.location_id - # add a second product in the location - self._update_qty_in_location(location, self.product_b, 10) - self._scan_line_error( - line, - location.barcode, - { - "message_type": "warning", - "message": "Several products found in Stock, please scan a product.", - }, - ) - - def test_scan_line_location_error_several_lots(self): - """Scan to check if user scans a correct location for current line - - If there are several lots in the location, user has to scan one. - """ - self._simulate_batch_selected(self.batch, in_lot=True) - line = self.batch.picking_ids.move_line_ids - location = line.location_id - lot = self.env["stock.production.lot"].create( - {"product_id": self.product_a.id, "company_id": self.env.company.id} - ) - # add a second lot in the location - self._update_qty_in_location(location, self.product_a, 10, lot=lot) - self._scan_line_error( - line, - location.barcode, - { - "message_type": "warning", - "message": "Several lots found in Stock, please scan a lot.", - }, - ) - - def test_scan_line_error_not_found(self): - """Nothing found for the barcode""" - self._simulate_batch_selected(self.batch, in_package=True) - self._scan_line_error( - self.batch.picking_ids.move_line_ids, - "NO_EXISTING_BARCODE", - {"message_type": "error", "message": "Barcode not found"}, - ) - - -class ClusterPickingScanDestinationPackCase(ClusterPickingCommonCase): - """Tests covering the /scan_destination_pack endpoint - - After a batch has been selected and the user confirmed they are - working on it, user picked the good, now they scan the location - destination. - """ - - @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - cls.batch = cls._create_picking_batch( - [ - [ - cls.BatchProduct(product=cls.product_a, quantity=10), - cls.BatchProduct(product=cls.product_b, quantity=10), - ], - [cls.BatchProduct(product=cls.product_a, quantity=10)], - ] - ) - cls.bin1 = cls.env["stock.quant.package"].create({}) - - def test_scan_destination_pack_ok(self): - """Happy path for scan destination package - - It sets the line in the pack for the full qty - """ - self._simulate_batch_selected(self.batch) - line = self.batch.picking_ids.move_line_ids[0] - next_line = self.batch.picking_ids.move_line_ids[1] - qty_done = line.product_uom_qty - response = self.service.dispatch( - "scan_destination_pack", - params={ - "move_line_id": line.id, - "barcode": self.bin1.name, - "quantity": qty_done, - }, - ) - self.assertRecordValues( - line, [{"qty_done": qty_done, "result_package_id": self.bin1.id}] - ) - self.assert_response( - response, - next_state="start_line", - data=self._line_data(next_line), - message={ - "message_type": "info", - "message": "{} {} put in {}".format( - line.qty_done, line.product_id.display_name, self.bin1.name - ), - }, - ) - - -# TODO tests for transitions to next line / no next lines, ... - - -class ClusterPickingSkipLineCase(ClusterPickingCommonCase): - """Tests covering the /skip_line endpoint - """ - - @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - # quants already existing are from demo data - cls.env["stock.quant"].search( - [("location_id", "=", cls.stock_location.id)] - ).unlink() - cls.batch = cls._create_picking_batch( - [ - [ - cls.BatchProduct(product=cls.product_a, quantity=10), - cls.BatchProduct(product=cls.product_b, quantity=20), - ], - [ - cls.BatchProduct(product=cls.product_a, quantity=30), - cls.BatchProduct(product=cls.product_b, quantity=40), - ], - ] - ) - - def _skip_line(self, line, next_line=None): - response = self.service.dispatch("skip_line", params={"move_line_id": line.id}) - if next_line: - self.assert_response( - response, next_state="start_line", data=self._line_data(next_line) - ) - return response - - def test_skip_line(self): - self._simulate_batch_selected(self.batch, in_package=True) - lines = self.batch.picking_ids.move_line_ids - # 1st line, next is 2nd - self.assertFalse(lines[0].shopfloor_postponed) - self._skip_line(lines[0], lines[1]) - self.assertTrue(lines[0].shopfloor_postponed) - # 2nd line, next is 3rd - self.assertFalse(lines[1].shopfloor_postponed) - self._skip_line(lines[1], lines[2]) - self.assertTrue(lines[1].shopfloor_postponed) - # 3rd line, next is 4th - self.assertFalse(lines[2].shopfloor_postponed) - self._skip_line(lines[2], lines[3]) - self.assertTrue(lines[2].shopfloor_postponed) - # 4th line, next is 1st - # the next line for the last one is the 1st, - # because you'll have to process it anyway - self.assertFalse(lines[3].shopfloor_postponed) - self._skip_line(lines[3], lines[0]) - self.assertTrue(lines[3].shopfloor_postponed) - - -class ClusterPickingUnloadingCommonCase(ClusterPickingCommonCase): - @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - cls.batch = cls._create_picking_batch( - [ - [ - cls.BatchProduct(product=cls.product_a, quantity=10), - cls.BatchProduct(product=cls.product_b, quantity=10), - ], - [cls.BatchProduct(product=cls.product_a, quantity=10)], - ] - ) - cls._simulate_batch_selected(cls.batch) - cls.bin1 = cls.env["stock.quant.package"].create({}) - cls.bin2 = cls.env["stock.quant.package"].create({}) - cls.packing_a_location = cls.env["stock.location"].create( - { - "name": "Packing A", - "barcode": "Packing-A", - "location_id": cls.packing_location.id, - } - ) - cls.packing_b_location = cls.env["stock.location"].create( - { - "name": "Packing B", - "barcode": "Packing-B", - "location_id": cls.packing_location.id, - } - ) - - @classmethod - def _set_dest_package_and_done(cls, move_lines, dest_package): - """Simulate what would have been done in the previous steps""" - for line in move_lines: - line.write( - {"qty_done": line.product_uom_qty, "result_package_id": dest_package.id} - ) - - -class ClusterPickingPrepareUnloadCase(ClusterPickingUnloadingCommonCase): - """Tests covering the /prepare_unload endpoint - - Destination packages have been set on all the move lines of the batch. - The unload operation will start, but we have 2 paths for this: - - 1. unload all the destination packages at the same place - 2. unload the destination packages one by one at different places - - By default, if all the move lines have the same destination, the - first path is used. A flag on the batch picking keeps track of which - path is used. - """ - - def test_prepare_unload_all_same_dest(self): - """All move lines have the same destination location""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") - self._set_dest_package_and_done(move_lines[:2], self.bin1) - self._set_dest_package_and_done(move_lines[2:], self.bin2) - move_lines.write({"location_dest_id": self.packing_location.id}) - response = self.service.dispatch( - "prepare_unload", params={"picking_batch_id": self.batch.id} - ) - self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": True}]) - self.assert_response( - response, - next_state="unload_all", - data={ - "id": self.batch.id, - "name": self.batch.name, - "location_dst": { - "id": self.packing_location.id, - "name": self.packing_location.name, - }, - }, - ) - - def test_prepare_unload_different_dest(self): - """All move lines have different destination locations""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") - self._set_dest_package_and_done(move_lines[:2], self.bin1) - self._set_dest_package_and_done(move_lines[2:], self.bin2) - move_lines[:1].write({"location_dest_id": self.packing_a_location.id}) - move_lines[:1].write({"location_dest_id": self.packing_b_location.id}) - response = self.service.dispatch( - "prepare_unload", params={"picking_batch_id": self.batch.id} - ) - self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}]) - first_line = move_lines[0] - self.assert_response( - response, - next_state="unload_single", - data={ - "id": self.batch.id, - "name": self.batch.name, - "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dst": { - "id": first_line.location_dest_id.id, - "name": first_line.location_dest_id.name, - }, - }, - ) - - -class ClusterPickingSetDestinationAllCase(ClusterPickingUnloadingCommonCase): - """Tests covering the /set_destination_all endpoint - - All the picked lines go to the same destination, a single call to this - endpoint set them as "unloaded" and set the destination. When the last - available line of a picking is unloaded, the picking is set to 'done'. - """ - - @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - # this is what the /prepare_endpoint method would have set as all the - # destinations are the same: - cls.batch.cluster_picking_unload_all = True - - def test_set_destination_all_ok(self): - """Set destination on all lines for the full batch and end the process""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") - # put destination packages, the whole quantity on lines and a similar - # destination (when /set_destination_all is called, all the lines to - # unload must have the same destination) - self._set_dest_package_and_done(move_lines[:2], self.bin1) - self._set_dest_package_and_done(move_lines[2:], self.bin2) - move_lines.write({"location_dest_id": self.packing_location.id}) - - response = self.service.dispatch( - "set_destination_all", - params={ - "picking_batch_id": self.batch.id, - "barcode": self.packing_location.barcode, - }, - ) - # since the whole batch is complete, we expect the batch and all - # pickings to be 'done' - self.assertRecordValues( - move_lines.mapped("picking_id"), [{"state": "done"}, {"state": "done"}] - ) - self.assertRecordValues( - move_lines, - [ - { - "shopfloor_unloaded": True, - "qty_done": 10, - "state": "done", - "location_dest_id": self.packing_location.id, - }, - { - "shopfloor_unloaded": True, - "qty_done": 10, - "state": "done", - "location_dest_id": self.packing_location.id, - }, - { - "shopfloor_unloaded": True, - "qty_done": 10, - "state": "done", - "location_dest_id": self.packing_location.id, - }, - ], - ) - self.assertRecordValues(self.batch, [{"state": "done"}]) - self.assert_response( - response, - next_state="start", - message={"message_type": "info", "message": "Batch Transfer complete"}, - ) - - def test_set_destination_all_remaining_lines(self): - """Set destination on all lines for a part of the batch""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") - # Put destination packages, the whole quantity on lines and a similar - # destination (when /set_destination_all is called, all the lines to - # unload must have the same destination). - # However, we keep a line without qty_done and destination package, - # so when the dest location is set, the endpoint should route back - # to the 'start_line' state to work on the remaining line. - lines_to_unload = move_lines[:2] - self._set_dest_package_and_done(lines_to_unload, self.bin1) - lines_to_unload.write({"location_dest_id": self.packing_location.id}) - - response = self.service.dispatch( - "set_destination_all", - params={ - "picking_batch_id": self.batch.id, - "barcode": self.packing_location.barcode, - }, - ) - # Since the whole batch is not complete, state should not be done. - # The picking with one line should be "done" because we unloaded its line. - # The second one still has a line to pick. - one_line_picking = self.batch.picking_ids[0] - two_lines_picking = self.batch.picking_ids[1] - self.assertRecordValues(one_line_picking, [{"state": "done"}]) - self.assertRecordValues(two_lines_picking, [{"state": "assigned"}]) - self.assertRecordValues( - move_lines, - [ - { - "shopfloor_unloaded": True, - "qty_done": 10, - "state": "done", - "picking_id": one_line_picking.id, - "location_dest_id": self.packing_location.id, - }, - { - "shopfloor_unloaded": True, - "qty_done": 10, - # will be done when the second line of the picking is unloaded - "state": "assigned", - "picking_id": two_lines_picking.id, - "location_dest_id": self.packing_location.id, - }, - { - "shopfloor_unloaded": False, - "qty_done": 0, - "state": "assigned", - "picking_id": two_lines_picking.id, - "location_dest_id": self.packing_location.id, - }, - ], - ) - self.assertRecordValues(self.batch, [{"state": "in_progress"}]) - - self.assert_response( - # the remaining move line still needs to be picked - response, - next_state="start_line", - data=self._line_data(move_lines[2]), - ) - - def test_set_destination_all_but_different_dest(self): - """Endpoint was called but destinations are different""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") - self._set_dest_package_and_done(move_lines, self.bin1) - move_lines[:2].write({"location_dest_id": self.packing_a_location.id}) - move_lines[2:].write({"location_dest_id": self.packing_b_location.id}) - - response = self.service.dispatch( - "set_destination_all", - params={ - "picking_batch_id": self.batch.id, - "barcode": self.packing_location.barcode, - }, - ) - self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}]) - self.assert_response( - response, - next_state="unload_single", - data={ - "id": self.batch.id, - "name": self.batch.name, - "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dst": { - "id": move_lines[0].location_dest_id.id, - "name": move_lines[0].location_dest_id.name, - }, - }, - ) - - def test_set_destination_all_error_location_not_found(self): - """Endpoint called with a barcode not existing for a location""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") - self._set_dest_package_and_done(move_lines, self.bin1) - move_lines.write({"location_dest_id": self.packing_a_location.id}) - - response = self.service.dispatch( - "set_destination_all", - params={"picking_batch_id": self.batch.id, "barcode": "NOTFOUND"}, - ) - self.assert_response( - response, - next_state="unload_all", - data={ - "id": self.batch.id, - "name": self.batch.name, - "location_dst": { - "id": move_lines[0].location_dest_id.id, - "name": move_lines[0].location_dest_id.name, - }, - }, - message={ - "message_type": "error", - "message": "No location found for this barcode.", - }, - ) - - def test_set_destination_all_error_location_invalid(self): - """Endpoint called with a barcode for an invalid location - - It is invalid when the location is not the destination location or - sublocation of the picking type. - """ - move_lines = self.batch.mapped("picking_ids.move_line_ids") - self._set_dest_package_and_done(move_lines, self.bin1) - move_lines.write({"location_dest_id": self.packing_a_location.id}) - - response = self.service.dispatch( - "set_destination_all", - params={ - "picking_batch_id": self.batch.id, - "barcode": self.dispatch_location.barcode, - }, - ) - self.assert_response( - response, - next_state="unload_all", - data={ - "id": self.batch.id, - "name": self.batch.name, - "location_dst": { - "id": move_lines[0].location_dest_id.id, - "name": move_lines[0].location_dest_id.name, - }, - }, - message={"message_type": "error", "message": "You cannot place it here"}, - ) - - def test_set_destination_all_need_confirmation(self): - """Endpoint called with a barcode for another (valid) location""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") - self._set_dest_package_and_done(move_lines, self.bin1) - move_lines.write({"location_dest_id": self.packing_a_location.id}) - - response = self.service.dispatch( - "set_destination_all", - params={ - "picking_batch_id": self.batch.id, - "barcode": self.packing_b_location.barcode, - }, - ) - self.assert_response( - response, - next_state="confirm_unload_all", - data={ - "id": self.batch.id, - "name": self.batch.name, - "location_dst": { - "id": move_lines[0].location_dest_id.id, - "name": move_lines[0].location_dest_id.name, - }, - }, - ) - - def test_set_destination_all_with_confirmation(self): - """Endpoint called with a barcode for another (valid) location, confirm""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") - self._set_dest_package_and_done(move_lines, self.bin1) - move_lines.write({"location_dest_id": self.packing_a_location.id}) - - response = self.service.dispatch( - "set_destination_all", - params={ - "picking_batch_id": self.batch.id, - "barcode": self.packing_b_location.barcode, - "confirmation": True, - }, - ) - self.assertRecordValues( - move_lines, - [ - {"location_dest_id": self.packing_b_location.id}, - {"location_dest_id": self.packing_b_location.id}, - {"location_dest_id": self.packing_b_location.id}, - ], - ) - self.assert_response( - response, - next_state="start", - message={"message_type": "info", "message": "Batch Transfer complete"}, - ) - - -class ClusterPickingUnloadSplitCase(ClusterPickingUnloadingCommonCase): - """Tests covering the /unload_split endpoint - - All the destinations of the bins were the same so the "unload all" screen - was presented to the user, but they want different destination, so they hit - the "split" button. From now on, the workflow should use the "unload single" - screen even if the destinations are the same. - """ - - @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - # this is what the /prepare_endpoint method would have set as all the - # destinations are the same: - cls.batch.cluster_picking_unload_all = True - - def test_unload_split_ok(self): - """Call /unload_split and continue to unload single""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") - # put destination packages, the whole quantity on lines and a similar - # destination (when /set_destination_all is called, all the lines to - # unload must have the same destination) - self._set_dest_package_and_done(move_lines, self.bin1) - move_lines.write({"location_dest_id": self.packing_location.id}) - - response = self.service.dispatch( - "unload_split", params={"picking_batch_id": self.batch.id} - ) - self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}]) - self.assert_response( - # the remaining move line still needs to be picked - response, - next_state="unload_single", - data={ - "id": self.batch.id, - "name": self.batch.name, - "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dst": { - "id": move_lines[0].location_dest_id.id, - "name": move_lines[0].location_dest_id.name, - }, - }, - ) - - -class ClusterPickingUnloadScanPackCase(ClusterPickingUnloadingCommonCase): - """Tests covering the /unload_scan_pack endpoint - - Goods have been put in the package bins, they have different destinations - or /unload_split has been called, now user has to unload package per - package. For this, they'll first scan the bin package, which will call the - endpoint /unload_scan_pack. (second step will be to set the destination - with /unload_scan_destination, in a different test case) - """ - - @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - cls.batch.cluster_picking_unload_all = False - cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids") - cls._set_dest_package_and_done(cls.move_lines, cls.bin1) - cls.move_lines[:2].write({"location_dest_id": cls.packing_a_location.id}) - cls.move_lines[2:].write({"location_dest_id": cls.packing_b_location.id}) - - def test_unload_scan_pack_ok(self): - """Endpoint /unload_scan_pack is called, result ok""" - response = self.service.dispatch( - "unload_scan_pack", - params={ - "picking_batch_id": self.batch.id, - "package_id": self.bin1.id, - "barcode": self.bin1.name, - }, - ) - self.assert_response( - response, - next_state="unload_set_destination", - data={ - "id": self.batch.id, - "name": self.batch.name, - "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dst": { - "id": self.move_lines[0].location_dest_id.id, - "name": self.move_lines[0].location_dest_id.name, - }, - }, - ) - - def test_unload_scan_pack_wrong_barcode(self): - """Endpoint /unload_scan_pack is called, wrong barcode scanned""" - response = self.service.dispatch( - "unload_scan_pack", - params={ - "picking_batch_id": self.batch.id, - "package_id": self.bin1.id, - "barcode": self.bin2.name, - }, - ) - self.assert_response( - response, - next_state="unload_single", - data={ - "id": self.batch.id, - "name": self.batch.name, - "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dst": { - "id": self.move_lines[0].location_dest_id.id, - "name": self.move_lines[0].location_dest_id.name, - }, - }, - message={"message_type": "error", "message": "Wrong bin"}, - ) diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py new file mode 100644 index 0000000000..02574d89b4 --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -0,0 +1,116 @@ +from .common import CommonCase, PickingBatchMixin + + +class ClusterPickingCommonCase(CommonCase, PickingBatchMixin): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.product_a = cls.env["product.product"].create( + { + "name": "Product A", + "type": "product", + "default_code": "A", + "barcode": "A", + } + ) + cls.product_b = cls.env["product.product"].create( + { + "name": "Product B", + "type": "product", + "default_code": "B", + "barcode": "B", + } + ) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") + cls.process = cls.menu.process_id + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + cls.wh = cls.profile.warehouse_id + cls.wh.delivery_steps = "pick_pack_ship" + cls.picking_type = cls.process.picking_type_id + + def setUp(self): + super().setUp() + with self.work_on_services(menu=self.menu, profile=self.profile) as work: + self.service = work.component(usage="cluster_picking") + + @classmethod + def _simulate_batch_selected(cls, batches, in_package=False, in_lot=False): + """Create a state as if a batch was selected by the user + + * The picking batch is in progress + * It is assigned to the current user + * All the move lines are available + + Note: currently, this method create a source package that contains + all the products of the batch. It is enough for the current tests. + """ + pickings = batches.mapped("picking_ids") + cls._fill_stock_for_moves( + pickings.mapped("move_lines"), in_package=in_package, in_lot=in_lot + ) + pickings.action_assign() + batches.write({"state": "in_progress", "user_id": cls.env.uid}) + + def _line_data(self, move_line, qty=None): + picking = move_line.picking_id + batch = picking.batch_id + # A package exists on the move line, because the quant created + # by ``_simulate_batch_selected`` has a package. + package = move_line.package_id + lot = move_line.lot_id + return { + "id": move_line.id, + "quantity": qty or move_line.product_uom_qty, + "postponed": move_line.shopfloor_postponed, + "location_dst": { + "id": move_line.location_dest_id.id, + "name": move_line.location_dest_id.name, + }, + "location_src": { + "id": move_line.location_id.id, + "name": move_line.location_id.name, + }, + "picking": { + "id": picking.id, + "name": picking.name, + "note": "", + "origin": picking.origin, + }, + "batch": {"id": batch.id, "name": batch.name}, + "product": { + "default_code": move_line.product_id.default_code, + "display_name": move_line.product_id.display_name, + "id": move_line.product_id.id, + "name": move_line.product_id.name, + "qty_available": move_line.product_id.qty_available, + }, + "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or ""} + if lot + else None, + "pack": {"id": package.id, "name": package.name} if package else None, + } + + +class ClusterPickingAPICase(ClusterPickingCommonCase): + """Base tests for the cluster picking API""" + + def test_to_openapi(self): + # will raise if it fails to generate the openapi specs + self.service.to_openapi() + + +class ClusterPickingLineCommonCase(ClusterPickingCommonCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + # quants already existing are from demo data + cls.env["stock.quant"].search( + [("location_id", "=", cls.stock_location.id)] + ).unlink() + cls.batch = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + + def _line_data(self, move_line, qty=1.0): + # just force qty to 1.0 + return super()._line_data(move_line, qty=qty) diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py new file mode 100644 index 0000000000..38f0b7a40b --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -0,0 +1,236 @@ +from .test_cluster_picking_base import ( + ClusterPickingCommonCase, + ClusterPickingLineCommonCase, +) + + +class ClusterPickingScanLineCase(ClusterPickingLineCommonCase): + """Tests covering the /scan_line endpoint + + After a batch has been selected and the user confirmed they are + working on it. + + User scans something and the scan_line endpoints validates they + scanned the proper thing to pick. + """ + + def _scan_line_ok(self, line, scanned): + response = self.service.dispatch( + "scan_line", params={"move_line_id": line.id, "barcode": scanned} + ) + self.assert_response( + response, next_state="scan_destination", data=self._line_data(line) + ) + + def _scan_line_error(self, line, scanned, message): + response = self.service.dispatch( + "scan_line", params={"move_line_id": line.id, "barcode": scanned} + ) + self.assert_response( + response, + next_state="start_line", + data=self._line_data(line), + message=message, + ) + + def test_scan_line_pack_ok(self): + """Scan to check if user picks the correct pack for current line""" + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.package_id.name) + + def test_scan_line_product_ok(self): + """Scan to check if user picks the correct product for current line""" + self._simulate_batch_selected(self.batch) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.product_id.barcode) + + def test_scan_line_lot_ok(self): + """Scan to check if user picks the correct lot for current line""" + self.product_a.tracking = "lot" + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.lot_id.name) + + def test_scan_line_serial_ok(self): + """Scan to check if user picks the correct serial for current line""" + self.product_a.tracking = "serial" + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.lot_id.name) + + def test_scan_line_error_product_tracked(self): + """Scan a product tracked by lot, must scan the lot""" + self.product_a.tracking = "lot" + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_error( + line, + line.product_id.barcode, + { + "message_type": "warning", + "message": "Product tracked by lot, please scan one.", + }, + ) + + def test_scan_line_location_ok_single_package(self): + """Scan to check if user scans a correct location for current line + + If there is only one single package in the location, there is no + ambiguity so we can use it. + """ + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.location_id.barcode) + + def test_scan_line_location_ok_single_product(self): + """Scan to check if user scans a correct location for current line + + If there is only one single product in the location, there is no + ambiguity so we can use it. + """ + self._simulate_batch_selected(self.batch) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.location_id.barcode) + + def test_scan_line_location_ok_single_lot(self): + """Scan to check if user scans a correct location for current line + + If there is only one single lot in the location, there is no + ambiguity so we can use it. + """ + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.location_id.barcode) + + def test_scan_line_location_error_several_package(self): + """Scan to check if user scans a correct location for current line + + If there are several packages in the location, user has to scan one. + """ + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + location = line.location_id + # add a second package in the location + self._update_qty_in_location( + location, + self.product_b, + 10, + package=self.env["stock.quant.package"].create({}), + ) + self._scan_line_error( + line, + location.barcode, + { + "message_type": "warning", + "message": "Several packages found in Stock, please scan a package.", + }, + ) + + def test_scan_line_location_error_several_products(self): + """Scan to check if user scans a correct location for current line + + If there are several products in the location, user has to scan one. + """ + self._simulate_batch_selected(self.batch) + line = self.batch.picking_ids.move_line_ids + location = line.location_id + # add a second product in the location + self._update_qty_in_location(location, self.product_b, 10) + self._scan_line_error( + line, + location.barcode, + { + "message_type": "warning", + "message": "Several products found in Stock, please scan a product.", + }, + ) + + def test_scan_line_location_error_several_lots(self): + """Scan to check if user scans a correct location for current line + + If there are several lots in the location, user has to scan one. + """ + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + location = line.location_id + lot = self.env["stock.production.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + # add a second lot in the location + self._update_qty_in_location(location, self.product_a, 10, lot=lot) + self._scan_line_error( + line, + location.barcode, + { + "message_type": "warning", + "message": "Several lots found in Stock, please scan a lot.", + }, + ) + + def test_scan_line_error_not_found(self): + """Nothing found for the barcode""" + self._simulate_batch_selected(self.batch, in_package=True) + self._scan_line_error( + self.batch.picking_ids.move_line_ids, + "NO_EXISTING_BARCODE", + {"message_type": "error", "message": "Barcode not found"}, + ) + + +class ClusterPickingScanDestinationPackCase(ClusterPickingCommonCase): + """Tests covering the /scan_destination_pack endpoint + + After a batch has been selected and the user confirmed they are + working on it, user picked the good, now they scan the location + destination. + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.batch = cls._create_picking_batch( + [ + [ + cls.BatchProduct(product=cls.product_a, quantity=10), + cls.BatchProduct(product=cls.product_b, quantity=10), + ], + [cls.BatchProduct(product=cls.product_a, quantity=10)], + ] + ) + cls.bin1 = cls.env["stock.quant.package"].create({}) + + def test_scan_destination_pack_ok(self): + """Happy path for scan destination package + + It sets the line in the pack for the full qty + """ + self._simulate_batch_selected(self.batch) + line = self.batch.picking_ids.move_line_ids[0] + next_line = self.batch.picking_ids.move_line_ids[1] + qty_done = line.product_uom_qty + response = self.service.dispatch( + "scan_destination_pack", + params={ + "move_line_id": line.id, + "barcode": self.bin1.name, + "quantity": qty_done, + }, + ) + self.assertRecordValues( + line, [{"qty_done": qty_done, "result_package_id": self.bin1.id}] + ) + self.assert_response( + response, + next_state="start_line", + data=self._line_data(next_line), + message={ + "message_type": "info", + "message": "{} {} put in {}".format( + line.qty_done, line.product_id.display_name, self.bin1.name + ), + }, + ) + + +# TODO tests for transitions to next line / no next lines, ... diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py new file mode 100644 index 0000000000..f8e8b0d5dc --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -0,0 +1,428 @@ +import unittest + +from .test_cluster_picking_base import ClusterPickingCommonCase + + +class ClusterPickingSelectionCase(ClusterPickingCommonCase): + """Tests covering the selection of picking batches + + Endpoints: + + * /cluster_picking/find_batch + * /cluster_picking/list_batch + * /cluster_picking/select + * /cluster_picking/unassign + + These endpoints interact with a list of picking batches. + The other endpoints that interact with a single batch (after selection) + are handled in other classes. + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + # drop base demo data and create our own batches to work with + cls.env["stock.picking.batch"].search([]).unlink() + cls.batch1 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + cls.batch2 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + cls.batch3 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + cls.batch4 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + + def _add_stock_and_assign_pickings_for_batches(self, batches): + pickings = batches.mapped("picking_ids") + self._fill_stock_for_moves(pickings.mapped("move_lines")) + pickings.action_assign() + + def test_find_batch_in_progress_current_user(self): + """Find an in-progress batch assigned to the current user""" + # Simulate the client asking a batch by clicking on "get work" + self._add_stock_and_assign_pickings_for_batches( + self.batch1 | self.batch2 | self.batch3 + ) + self.batch3.user_id = self.env.uid + self.batch3.confirm_picking() # set to in progress + response = self.service.dispatch("find_batch") + + # we expect to find batch 3 as it's assigned to the current + # user and in progress (first priority) + self.assert_response( + response, + next_state="confirm_start", + data={ + "id": self.batch3.id, + "name": self.batch3.name, + # TODO + "weight": 0, + "pickings": [ + { + "id": self.batch3.picking_ids.id, + "name": self.batch3.picking_ids.name, + "move_line_count": len(self.batch3.picking_ids.move_line_ids), + "origin": self.batch3.picking_ids.origin, + "partner": { + "id": self.batch3.picking_ids.partner_id.id, + "name": self.batch3.picking_ids.partner_id.name, + }, + } + ], + }, + ) + + def test_find_batch_assigned(self): + """Find a draft batch assigned to the current user""" + # batches must have all their pickings available to be selected + self._add_stock_and_assign_pickings_for_batches( + self.batch1 | self.batch2 | self.batch3 + ) + # batch2 in draft but assigned to the current user should be + # selected before the others + self.batch2.user_id = self.env.uid + response = self.service.dispatch("find_batch") + + # The endpoint starts the batch + self.assertEqual(self.batch2.state, "in_progress") + + # we expect to find batch 2 as it's assigned to the current user + self.assert_response( + response, + next_state="confirm_start", + data={ + "id": self.batch2.id, + "name": self.batch2.name, + # TODO + "weight": 0, + "pickings": [ + { + "id": self.batch2.picking_ids.id, + "name": self.batch2.picking_ids.name, + "move_line_count": len(self.batch2.picking_ids.move_line_ids), + "origin": self.batch2.picking_ids.origin, + "partner": { + "id": self.batch2.picking_ids.partner_id.id, + "name": self.batch2.picking_ids.partner_id.name, + }, + } + ], + }, + ) + + def test_find_batch_unassigned_draft(self): + """Find a draft batch""" + # batches must have all their pickings available to be selected + self._add_stock_and_assign_pickings_for_batches(self.batch2 | self.batch3) + # batch1 has not all pickings available, so the first draft + # is batch2, should be selected + response = self.service.dispatch("find_batch") + + # The endpoint starts the batch and assign it to self + self.assertEqual(self.batch2.user_id, self.env.user) + self.assertEqual(self.batch2.state, "in_progress") + + # we expect to find batch 2 as it's the first one with all pickings + # available + self.assert_response( + response, + next_state="confirm_start", + data={ + "id": self.batch2.id, + "name": self.batch2.name, + # TODO + "weight": 0, + "pickings": [ + { + "id": self.batch2.picking_ids.id, + "name": self.batch2.picking_ids.name, + "move_line_count": len(self.batch2.picking_ids.move_line_ids), + "origin": self.batch2.picking_ids.origin, + "partner": { + "id": self.batch2.picking_ids.partner_id.id, + "name": self.batch2.picking_ids.partner_id.name, + }, + } + ], + }, + ) + + def test_find_batch_not_found(self): + """No batch to work on""" + # No batch match the rules to work on them, because + # their pickings are not available + response = self.service.dispatch("find_batch") + + self.assert_response( + response, + next_state="start", + message={ + "message_type": "info", + "message": "No more work to do, please create a new batch transfer", + }, + ) + + def test_list_batch(self): + """List all available batches""" + # batches must have all their pickings available to be selected + self._add_stock_and_assign_pickings_for_batches( + self.batch1 | self.batch2 | self.batch3 + ) + self.batch1.write({"state": "in_progress", "user_id": self.env.uid}) + self.batch2.write( + {"state": "in_progress", "user_id": self.env.ref("base.user_demo")} + ) + self.batch3.write({"state": "draft", "user_id": False}) + + self.assertEqual( + self.env["stock.picking.batch"].search([]), + self.batch1 + self.batch2 + self.batch3 + self.batch4, + ) + # Simulate the client asking the list of batches + response = self.service.dispatch("list_batch") + self.assert_response( + response, + next_state="manual_selection", + data={ + "size": 2, + "records": [ + { + "id": self.batch1.id, + "name": self.batch1.name, + "picking_count": 1, + "move_line_count": 1, + }, + # batch 2 is excluded because assigned to someone else + { + "id": self.batch3.id, + "name": self.batch3.name, + "picking_count": 1, + "move_line_count": 1, + }, + # batch 4 is excluded because not all of its pickings are + # assigned + ], + }, + ) + + def test_select_in_progress_assigned(self): + """Select an in-progress batch assigned to the current user""" + self._add_stock_and_assign_pickings_for_batches(self.batch1) + self.batch1.write({"state": "in_progress", "user_id": self.env.uid}) + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "select", params={"picking_batch_id": self.batch1.id} + ) + self.assert_response( + response, + next_state="confirm_start", + data={ + "id": self.batch1.id, + "name": self.batch1.name, + # we don't care in these tests, the 'find_batch' tests already + # check this + "weight": self.ANY, + "pickings": self.ANY, + }, + ) + + def test_select_draft_assigned(self): + """Select a draft batch assigned to the current user""" + self._add_stock_and_assign_pickings_for_batches(self.batch1) + self.batch1.write({"user_id": self.env.uid}) + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "select", params={"picking_batch_id": self.batch1.id} + ) + # The endpoint starts the batch and assign it to self + self.assertEqual(self.batch1.user_id, self.env.user) + self.assertEqual(self.batch1.state, "in_progress") + self.assert_response( + response, + next_state="confirm_start", + data={ + "id": self.batch1.id, + "name": self.batch1.name, + # we don't care in these tests, the 'find_batch' tests already + # check this + "weight": self.ANY, + "pickings": self.ANY, + }, + ) + + def test_select_draft_unassigned(self): + """Select a draft batch not assigned to a user""" + self._add_stock_and_assign_pickings_for_batches(self.batch1) + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "select", params={"picking_batch_id": self.batch1.id} + ) + # The endpoint starts the batch and assign it to self + self.assertEqual(self.batch1.user_id, self.env.user) + self.assertEqual(self.batch1.state, "in_progress") + self.assert_response( + response, + next_state="confirm_start", + data={ + "id": self.batch1.id, + "name": self.batch1.name, + # we don't care in these tests, the 'find_batch' tests already + # check this + "weight": self.ANY, + "pickings": self.ANY, + }, + ) + + def test_select_not_exists(self): + """Select a draft that does not exist""" + batch_id = self.batch1.id + self.batch1.unlink() + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "select", params={"picking_batch_id": batch_id} + ) + self.assert_response( + response, + next_state="manual_selection", + message={ + "message_type": "warning", + "message": "This batch cannot be selected.", + }, + data={"size": 0, "records": []}, + ) + + def test_select_already_assigned(self): + """Select a draft that does not exist""" + self._add_stock_and_assign_pickings_for_batches(self.batch1) + self.batch1.write( + {"state": "in_progress", "user_id": self.env.ref("base.user_demo")} + ) + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "select", params={"picking_batch_id": self.batch1.id} + ) + self.assert_response( + response, + next_state="manual_selection", + message={ + "message_type": "warning", + "message": "This batch cannot be selected.", + }, + data={"size": 0, "records": []}, + ) + + def test_unassign_batch(self): + """User cancels after selecting a batch, unassign it""" + self._simulate_batch_selected(self.batch1) + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "unassign", params={"picking_batch_id": self.batch1.id} + ) + self.assertEqual(self.batch1.state, "draft") + self.assertFalse(self.batch1.user_id) + self.assert_response(response, next_state="start") + + def test_unassign_batch_not_exists(self): + """User cancels after selecting a batch deleted meanwhile""" + batch_id = self.batch1.id + self.batch1.unlink() + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "unassign", params={"picking_batch_id": batch_id} + ) + self.assert_response(response, next_state="start") + + +class ClusterPickingSelectedCase(ClusterPickingCommonCase): + """Tests covering endpoints working on a single picking batch + + After a batch has been selected, by the tests covered in + ``ClusterPickingSelectionCase``. + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.batch = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + cls._simulate_batch_selected(cls.batch, in_package=True) + + def test_confirm_start_ok(self): + """User confirms she starts the selected picking batch (happy path)""" + # batch1 was already selected, we only need to confirm the selection + batch = self.batch + self.assertEqual(batch.state, "in_progress") + picking = batch.picking_ids[0] + first_move_line = picking.move_line_ids[0] + self.assertTrue(first_move_line) + # A package exists on the move line, because the quant created + # by ``_simulate_batch_selected`` has a package. + package = first_move_line.package_id + self.assertTrue(package) + + response = self.service.dispatch( + "confirm_start", params={"picking_batch_id": self.batch.id} + ) + self.assert_response( + response, + data={ + "id": first_move_line.id, + "quantity": 1.0, + "postponed": False, + "location_dst": { + "id": first_move_line.location_dest_id.id, + "name": first_move_line.location_dest_id.name, + }, + "location_src": { + "id": first_move_line.location_id.id, + "name": first_move_line.location_id.name, + }, + "picking": { + "id": picking.id, + "name": picking.name, + "note": "", + "origin": picking.origin, + }, + "batch": {"id": batch.id, "name": batch.name}, + "product": { + "default_code": first_move_line.product_id.default_code, + "display_name": first_move_line.product_id.display_name, + "id": first_move_line.product_id.id, + "name": first_move_line.product_id.name, + "qty_available": first_move_line.product_id.qty_available, + }, + "lot": None, + "pack": {"id": package.id, "name": package.name}, + }, + next_state="start_line", + ) + + def test_confirm_start_not_exists(self): + """User confirms she starts but batch has been deleted meanwhile""" + batch_id = self.batch.id + self.batch.unlink() + response = self.service.dispatch( + "confirm_start", params={"picking_batch_id": batch_id} + ) + self.assert_response( + response, + message={ + "message_type": "error", + "message": "This record you were working on does not exist anymore.", + }, + next_state="start", + ) + + # TODO + @unittest.skip("not sure yet what we have to do, keep for later") + def test_confirm_start_all_is_done(self): + """User confirms start but all lines are already done""" + # we want to jump to the start because there are no lines + # to process anymore, but we want to set pickings and + # picking batch to done if not done yet (because the process + # was interrupted for instance) diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py new file mode 100644 index 0000000000..a2d83d1a6c --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_skip.py @@ -0,0 +1,59 @@ +from .test_cluster_picking_base import ClusterPickingCommonCase + + +class ClusterPickingSkipLineCase(ClusterPickingCommonCase): + """Tests covering the /skip_line endpoint + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + # quants already existing are from demo data + cls.env["stock.quant"].search( + [("location_id", "=", cls.stock_location.id)] + ).unlink() + cls.batch = cls._create_picking_batch( + [ + [ + cls.BatchProduct(product=cls.product_a, quantity=10), + cls.BatchProduct(product=cls.product_b, quantity=20), + ], + [ + cls.BatchProduct(product=cls.product_a, quantity=30), + cls.BatchProduct(product=cls.product_b, quantity=40), + ], + ] + ) + + def _skip_line(self, line, next_line=None): + response = self.service.dispatch("skip_line", params={"move_line_id": line.id}) + if next_line: + self.assert_response( + response, next_state="start_line", data=self._line_data(next_line) + ) + return response + + def test_skip_line(self): + self._simulate_batch_selected(self.batch, in_package=True) + lines = self.batch.picking_ids.move_line_ids + # 1st line, next is 2nd + self.assertFalse(lines[0].shopfloor_postponed) + self._skip_line(lines[0], lines[1]) + self.assertTrue(lines[0].shopfloor_postponed) + # 2nd line, next is 3rd + self.assertFalse(lines[1].shopfloor_postponed) + self._skip_line(lines[1], lines[2]) + self.assertTrue(lines[1].shopfloor_postponed) + # 3rd line, next is 4th + self.assertFalse(lines[2].shopfloor_postponed) + self._skip_line(lines[2], lines[3]) + self.assertTrue(lines[2].shopfloor_postponed) + # 4th line, next is 1st + # the next line for the last one is the 1st, + # because you'll have to process it anyway + self.assertFalse(lines[3].shopfloor_postponed) + self._skip_line(lines[3], lines[0]) + self.assertTrue(lines[3].shopfloor_postponed) + + +# TODO tests for transitions to next line / no next lines, ... diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py new file mode 100644 index 0000000000..5fc13b3137 --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -0,0 +1,491 @@ +from .test_cluster_picking_base import ClusterPickingCommonCase + + +class ClusterPickingUnloadingCommonCase(ClusterPickingCommonCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.batch = cls._create_picking_batch( + [ + [ + cls.BatchProduct(product=cls.product_a, quantity=10), + cls.BatchProduct(product=cls.product_b, quantity=10), + ], + [cls.BatchProduct(product=cls.product_a, quantity=10)], + ] + ) + cls._simulate_batch_selected(cls.batch) + cls.bin1 = cls.env["stock.quant.package"].create({}) + cls.bin2 = cls.env["stock.quant.package"].create({}) + cls.packing_a_location = cls.env["stock.location"].create( + { + "name": "Packing A", + "barcode": "Packing-A", + "location_id": cls.packing_location.id, + } + ) + cls.packing_b_location = cls.env["stock.location"].create( + { + "name": "Packing B", + "barcode": "Packing-B", + "location_id": cls.packing_location.id, + } + ) + + @classmethod + def _set_dest_package_and_done(cls, move_lines, dest_package): + """Simulate what would have been done in the previous steps""" + for line in move_lines: + line.write( + {"qty_done": line.product_uom_qty, "result_package_id": dest_package.id} + ) + + +class ClusterPickingPrepareUnloadCase(ClusterPickingUnloadingCommonCase): + """Tests covering the /prepare_unload endpoint + + Destination packages have been set on all the move lines of the batch. + The unload operation will start, but we have 2 paths for this: + + 1. unload all the destination packages at the same place + 2. unload the destination packages one by one at different places + + By default, if all the move lines have the same destination, the + first path is used. A flag on the batch picking keeps track of which + path is used. + """ + + def test_prepare_unload_all_same_dest(self): + """All move lines have the same destination location""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + self._set_dest_package_and_done(move_lines[:2], self.bin1) + self._set_dest_package_and_done(move_lines[2:], self.bin2) + move_lines.write({"location_dest_id": self.packing_location.id}) + response = self.service.dispatch( + "prepare_unload", params={"picking_batch_id": self.batch.id} + ) + self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": True}]) + self.assert_response( + response, + next_state="unload_all", + data={ + "id": self.batch.id, + "name": self.batch.name, + "location_dst": { + "id": self.packing_location.id, + "name": self.packing_location.name, + }, + }, + ) + + def test_prepare_unload_different_dest(self): + """All move lines have different destination locations""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + self._set_dest_package_and_done(move_lines[:2], self.bin1) + self._set_dest_package_and_done(move_lines[2:], self.bin2) + move_lines[:1].write({"location_dest_id": self.packing_a_location.id}) + move_lines[:1].write({"location_dest_id": self.packing_b_location.id}) + response = self.service.dispatch( + "prepare_unload", params={"picking_batch_id": self.batch.id} + ) + self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}]) + first_line = move_lines[0] + self.assert_response( + response, + next_state="unload_single", + data={ + "id": self.batch.id, + "name": self.batch.name, + "package": {"id": self.bin1.id, "name": self.bin1.name}, + "location_dst": { + "id": first_line.location_dest_id.id, + "name": first_line.location_dest_id.name, + }, + }, + ) + + +class ClusterPickingSetDestinationAllCase(ClusterPickingUnloadingCommonCase): + """Tests covering the /set_destination_all endpoint + + All the picked lines go to the same destination, a single call to this + endpoint set them as "unloaded" and set the destination. When the last + available line of a picking is unloaded, the picking is set to 'done'. + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + # this is what the /prepare_endpoint method would have set as all the + # destinations are the same: + cls.batch.cluster_picking_unload_all = True + + def test_set_destination_all_ok(self): + """Set destination on all lines for the full batch and end the process""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + # put destination packages, the whole quantity on lines and a similar + # destination (when /set_destination_all is called, all the lines to + # unload must have the same destination) + self._set_dest_package_and_done(move_lines[:2], self.bin1) + self._set_dest_package_and_done(move_lines[2:], self.bin2) + move_lines.write({"location_dest_id": self.packing_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_location.barcode, + }, + ) + # since the whole batch is complete, we expect the batch and all + # pickings to be 'done' + self.assertRecordValues( + move_lines.mapped("picking_id"), [{"state": "done"}, {"state": "done"}] + ) + self.assertRecordValues( + move_lines, + [ + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "done", + "location_dest_id": self.packing_location.id, + }, + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "done", + "location_dest_id": self.packing_location.id, + }, + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "done", + "location_dest_id": self.packing_location.id, + }, + ], + ) + self.assertRecordValues(self.batch, [{"state": "done"}]) + self.assert_response( + response, + next_state="start", + message={"message_type": "info", "message": "Batch Transfer complete"}, + ) + + def test_set_destination_all_remaining_lines(self): + """Set destination on all lines for a part of the batch""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + # Put destination packages, the whole quantity on lines and a similar + # destination (when /set_destination_all is called, all the lines to + # unload must have the same destination). + # However, we keep a line without qty_done and destination package, + # so when the dest location is set, the endpoint should route back + # to the 'start_line' state to work on the remaining line. + lines_to_unload = move_lines[:2] + self._set_dest_package_and_done(lines_to_unload, self.bin1) + lines_to_unload.write({"location_dest_id": self.packing_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_location.barcode, + }, + ) + # Since the whole batch is not complete, state should not be done. + # The picking with one line should be "done" because we unloaded its line. + # The second one still has a line to pick. + one_line_picking = self.batch.picking_ids[0] + two_lines_picking = self.batch.picking_ids[1] + self.assertRecordValues(one_line_picking, [{"state": "done"}]) + self.assertRecordValues(two_lines_picking, [{"state": "assigned"}]) + self.assertRecordValues( + move_lines, + [ + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "done", + "picking_id": one_line_picking.id, + "location_dest_id": self.packing_location.id, + }, + { + "shopfloor_unloaded": True, + "qty_done": 10, + # will be done when the second line of the picking is unloaded + "state": "assigned", + "picking_id": two_lines_picking.id, + "location_dest_id": self.packing_location.id, + }, + { + "shopfloor_unloaded": False, + "qty_done": 0, + "state": "assigned", + "picking_id": two_lines_picking.id, + "location_dest_id": self.packing_location.id, + }, + ], + ) + self.assertRecordValues(self.batch, [{"state": "in_progress"}]) + + self.assert_response( + # the remaining move line still needs to be picked + response, + next_state="start_line", + data=self._line_data(move_lines[2]), + ) + + def test_set_destination_all_but_different_dest(self): + """Endpoint was called but destinations are different""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines[:2].write({"location_dest_id": self.packing_a_location.id}) + move_lines[2:].write({"location_dest_id": self.packing_b_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_location.barcode, + }, + ) + self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}]) + self.assert_response( + response, + next_state="unload_single", + data={ + "id": self.batch.id, + "name": self.batch.name, + "package": {"id": self.bin1.id, "name": self.bin1.name}, + "location_dst": { + "id": move_lines[0].location_dest_id.id, + "name": move_lines[0].location_dest_id.name, + }, + }, + ) + + def test_set_destination_all_error_location_not_found(self): + """Endpoint called with a barcode not existing for a location""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines.write({"location_dest_id": self.packing_a_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={"picking_batch_id": self.batch.id, "barcode": "NOTFOUND"}, + ) + self.assert_response( + response, + next_state="unload_all", + data={ + "id": self.batch.id, + "name": self.batch.name, + "location_dst": { + "id": move_lines[0].location_dest_id.id, + "name": move_lines[0].location_dest_id.name, + }, + }, + message={ + "message_type": "error", + "message": "No location found for this barcode.", + }, + ) + + def test_set_destination_all_error_location_invalid(self): + """Endpoint called with a barcode for an invalid location + + It is invalid when the location is not the destination location or + sublocation of the picking type. + """ + move_lines = self.batch.mapped("picking_ids.move_line_ids") + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines.write({"location_dest_id": self.packing_a_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.dispatch_location.barcode, + }, + ) + self.assert_response( + response, + next_state="unload_all", + data={ + "id": self.batch.id, + "name": self.batch.name, + "location_dst": { + "id": move_lines[0].location_dest_id.id, + "name": move_lines[0].location_dest_id.name, + }, + }, + message={"message_type": "error", "message": "You cannot place it here"}, + ) + + def test_set_destination_all_need_confirmation(self): + """Endpoint called with a barcode for another (valid) location""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines.write({"location_dest_id": self.packing_a_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_b_location.barcode, + }, + ) + self.assert_response( + response, + next_state="confirm_unload_all", + data={ + "id": self.batch.id, + "name": self.batch.name, + "location_dst": { + "id": move_lines[0].location_dest_id.id, + "name": move_lines[0].location_dest_id.name, + }, + }, + ) + + def test_set_destination_all_with_confirmation(self): + """Endpoint called with a barcode for another (valid) location, confirm""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines.write({"location_dest_id": self.packing_a_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_b_location.barcode, + "confirmation": True, + }, + ) + self.assertRecordValues( + move_lines, + [ + {"location_dest_id": self.packing_b_location.id}, + {"location_dest_id": self.packing_b_location.id}, + {"location_dest_id": self.packing_b_location.id}, + ], + ) + self.assert_response( + response, + next_state="start", + message={"message_type": "info", "message": "Batch Transfer complete"}, + ) + + +class ClusterPickingUnloadSplitCase(ClusterPickingUnloadingCommonCase): + """Tests covering the /unload_split endpoint + + All the destinations of the bins were the same so the "unload all" screen + was presented to the user, but they want different destination, so they hit + the "split" button. From now on, the workflow should use the "unload single" + screen even if the destinations are the same. + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + # this is what the /prepare_endpoint method would have set as all the + # destinations are the same: + cls.batch.cluster_picking_unload_all = True + + def test_unload_split_ok(self): + """Call /unload_split and continue to unload single""" + move_lines = self.batch.mapped("picking_ids.move_line_ids") + # put destination packages, the whole quantity on lines and a similar + # destination (when /set_destination_all is called, all the lines to + # unload must have the same destination) + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines.write({"location_dest_id": self.packing_location.id}) + + response = self.service.dispatch( + "unload_split", params={"picking_batch_id": self.batch.id} + ) + self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}]) + self.assert_response( + # the remaining move line still needs to be picked + response, + next_state="unload_single", + data={ + "id": self.batch.id, + "name": self.batch.name, + "package": {"id": self.bin1.id, "name": self.bin1.name}, + "location_dst": { + "id": move_lines[0].location_dest_id.id, + "name": move_lines[0].location_dest_id.name, + }, + }, + ) + + +class ClusterPickingUnloadScanPackCase(ClusterPickingUnloadingCommonCase): + """Tests covering the /unload_scan_pack endpoint + + Goods have been put in the package bins, they have different destinations + or /unload_split has been called, now user has to unload package per + package. For this, they'll first scan the bin package, which will call the + endpoint /unload_scan_pack. (second step will be to set the destination + with /unload_scan_destination, in a different test case) + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.batch.cluster_picking_unload_all = False + cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids") + cls._set_dest_package_and_done(cls.move_lines, cls.bin1) + cls.move_lines[:2].write({"location_dest_id": cls.packing_a_location.id}) + cls.move_lines[2:].write({"location_dest_id": cls.packing_b_location.id}) + + def test_unload_scan_pack_ok(self): + """Endpoint /unload_scan_pack is called, result ok""" + response = self.service.dispatch( + "unload_scan_pack", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": self.bin1.name, + }, + ) + self.assert_response( + response, + next_state="unload_set_destination", + data={ + "id": self.batch.id, + "name": self.batch.name, + "package": {"id": self.bin1.id, "name": self.bin1.name}, + "location_dst": { + "id": self.move_lines[0].location_dest_id.id, + "name": self.move_lines[0].location_dest_id.name, + }, + }, + ) + + def test_unload_scan_pack_wrong_barcode(self): + """Endpoint /unload_scan_pack is called, wrong barcode scanned""" + response = self.service.dispatch( + "unload_scan_pack", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": self.bin2.name, + }, + ) + self.assert_response( + response, + next_state="unload_single", + data={ + "id": self.batch.id, + "name": self.batch.name, + "package": {"id": self.bin1.id, "name": self.bin1.name}, + "location_dst": { + "id": self.move_lines[0].location_dest_id.id, + "name": self.move_lines[0].location_dest_id.name, + }, + }, + message={"message_type": "error", "message": "Wrong bin"}, + ) From e771900793eb0736a59a0f16c2f962b02e4c8ce4 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 27 Feb 2020 10:54:42 +0100 Subject: [PATCH 116/940] cluster_picking: adjust information for batch --- shopfloor/services/cluster_picking.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index f3400b21ea..3675796e94 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -142,10 +142,16 @@ def _response_for_no_batch_found(self): def _response_for_confirm_start(self, batch): pickings = [] for picking in batch.picking_ids: + # TODO: is this correct? + p_weight = sum( + picking.mapped('move_line_ids.product_id.weight') + ) p_values = { "id": picking.id, "name": picking.name, + "partner": None, "move_line_count": len(picking.move_line_ids), + "weight": p_weight, "origin": picking.origin or "", } if picking.partner_id: @@ -159,8 +165,6 @@ def _response_for_confirm_start(self, batch): data={ "id": batch.id, "name": batch.name, - # TODO - "weight": 0, "pickings": pickings, }, ) @@ -1140,7 +1144,6 @@ def _schema_for_batch_details(self): # id is a stock.picking.batch "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, - "weight": {"type": "float", "nullable": False, "required": True}, "pickings": { "type": "list", "required": True, @@ -1150,6 +1153,7 @@ def _schema_for_batch_details(self): "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, "move_line_count": {"required": True, "type": "integer"}, + "weight": {"type": "float", "nullable": False, "required": True}, "origin": { "type": "string", "nullable": False, @@ -1158,6 +1162,7 @@ def _schema_for_batch_details(self): "partner": { "type": "dict", "required": False, + "nullable": True, "schema": { "id": {"required": True, "type": "integer"}, "name": { From 7baa0d09feb1e223c1e65bc987c212b9dce49342 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 27 Feb 2020 11:15:15 +0100 Subject: [PATCH 117/940] backend: show barcode on location view --- shopfloor/__manifest__.py | 1 + shopfloor/views/stock_location.xml | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 shopfloor/views/stock_location.xml diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index b48d72b819..ba49f92715 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -25,6 +25,7 @@ "views/shopfloor_menu.xml", "views/shopfloor_process.xml", "views/stock_picking_type.xml", + "views/stock_location.xml", "views/shopfloor_profile_views.xml", "views/menus.xml", ], diff --git a/shopfloor/views/stock_location.xml b/shopfloor/views/stock_location.xml new file mode 100644 index 0000000000..58d2869cb4 --- /dev/null +++ b/shopfloor/views/stock_location.xml @@ -0,0 +1,15 @@ + + + + Shopfloor stock.location form + stock.location + + + + + + + + From 333429eb374716c85b693bbea12f3dec4b88749d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 08:58:26 +0100 Subject: [PATCH 118/940] backend: allow `success` message type --- shopfloor/services/validator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py index 67b2b16b38..f4e1e3373c 100644 --- a/shopfloor/services/validator.py +++ b/shopfloor/services/validator.py @@ -82,7 +82,7 @@ def _response_schema(self, data_schema=None, next_states=None): "message_type": { "type": "string", "required": True, - "allowed": ["info", "warning", "error"], + "allowed": ["info", "warning", "error", "success"], }, "message": {"type": "string", "required": True}, }, From db7c9610e0eb09fd03fdde0ddb5bf684c049b989 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 09:01:53 +0100 Subject: [PATCH 119/940] backend: successful ended operation -> `success` msg type --- shopfloor/actions/message.py | 2 +- shopfloor/services/cluster_picking.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 448e54c315..49612a4ac0 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -94,7 +94,7 @@ def need_confirmation(self): def confirm_pack_moved(self): return { - "message_type": "info", + "message_type": "success", "message": _("The pack has been moved, you can scan a new pack."), } diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 3675796e94..13894d40f5 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -485,7 +485,7 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): next_state="start_line", data=self._data_move_line(next_line), message={ - "message_type": "info", + "message_type": "success", # TODO different message for products/packs? "message": _("{} {} put in {}").format( move_line.qty_done, @@ -780,7 +780,7 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): def _response_batch_complete(self): return self._response( next_state="start", - message={"message_type": "info", "message": _("Batch Transfer complete")}, + message={"message_type": "success", "message": _("Batch Transfer complete")}, ) def unload_split(self, picking_batch_id): From 927ec7ab68f6d6b32446b183f24a0d17a042f143 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 09:07:22 +0100 Subject: [PATCH 120/940] backend: cluster_picking sort lines by location --- shopfloor/services/cluster_picking.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 13894d40f5..f3340991e3 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -238,7 +238,13 @@ def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x): lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(filter_func) # TODO we probably don't care about the postponed order in the 'set # destination location' step, reset it on 'scan_destination_pack'? - return lines.sorted(key=lambda x: x.shopfloor_postponed) + # TODO test line sorting and all these methods to retrieve lines + + # Sort line by source location, + # so that the picker start w/ products in the same location. + # Postponed lines must come always + # after ALL the other lines in the batch are processed. + return lines.sorted(key=lambda x: (x.location_id, x.shopfloor_postponed)) def _lines_to_pick(self, picking_batch): return self._lines_for_picking_batch( From 8f227d9eda060e53ee77566b36a7618c3a8da3ad Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 09:11:40 +0100 Subject: [PATCH 121/940] backend: cluster_picking/scan_line suggest destination bin --- shopfloor/services/cluster_picking.py | 29 ++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index f3340991e3..3d18bb2c08 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -259,6 +259,14 @@ def _lines_to_pick(self, picking_batch): ), ) + def _last_picked_line(self, picking): + """Get the last line packed for this picking.""" + return fields.first( + picking.move_line_ids.filtered( + lambda l: l.qty_done > 0 and l.result_package_id + ) + ) + def _next_line_for_pick(self, picking_batch): remaining_lines = self._lines_to_pick(picking_batch) return fields.first(remaining_lines) @@ -430,9 +438,15 @@ def _response_for_scan_line_product_need_lot(self, move_line): ) def _response_for_scan_line_ok(self, move_line): - return self._response( - next_state="scan_destination", data=self._data_move_line(move_line) - ) + data = self._data_move_line(move_line) + last_picked_line = self._last_picked_line(move_line.picking_id) + if last_picked_line: + # suggest pack to be used for the next line + data["destination_pack"] = { + "id": last_picked_line.result_package_id.id, + "name": last_picked_line.result_package_id.name, + } + return self._response(next_state="scan_destination", data=data) def scan_destination_pack(self, move_line_id, barcode, quantity): """Scan the destination package (bin) for a move line @@ -1263,6 +1277,15 @@ def _schema_for_single_line_details(self): "name": {"type": "string", "nullable": False, "required": True}, }, }, + "destination_pack": { + "type": "dict", + "required": False, + "nullable": True, + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, } @property From c71f948335c124354b38aa2790f1df913bcab9f2 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 09:13:06 +0100 Subject: [PATCH 122/940] backend: cluster_picking include `partner` in move line data --- shopfloor/services/cluster_picking.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 3d18bb2c08..bb2e60a4f5 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -285,7 +285,7 @@ def _data_move_line(self, line): product = line.product_id lot = line.lot_id package = line.package_id - return { + data = { # TODO have common methods to return general info # for each model "id": line.id, @@ -296,6 +296,7 @@ def _data_move_line(self, line): "name": picking.name, "origin": picking.origin or "", "note": picking.note or "", + "partner": None, }, "batch": {"id": batch.id, "name": batch.name}, "product": { @@ -315,6 +316,14 @@ def _data_move_line(self, line): }, "pack": {"id": package.id, "name": package.name} if package else None, } + if picking.partner_id: + # TODO retrieve info always in the same way + # maybe using base_jsonify + data["picking"]["partner"] = { + "id": picking.partner_id.id, + "name": picking.partner_id.name, + } + return data def unassign(self, picking_batch_id): """Unassign and reset to draft a started picking batch @@ -1211,6 +1220,19 @@ def _schema_for_single_line_details(self): "name": {"type": "string", "nullable": False, "required": True}, "origin": {"type": "string", "nullable": False, "required": True}, "note": {"type": "string", "nullable": False, "required": True}, + "partner": { + "type": "dict", + "required": False, + "nullable": True, + "schema": { + "id": {"required": True, "type": "integer"}, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, + }, + }, }, }, "batch": { @@ -1267,7 +1289,6 @@ def _schema_for_single_line_details(self): "name": {"type": "string", "nullable": False, "required": True}, }, }, - # TODO add destination pack "pack": { "type": "dict", "required": False, From 2692cbea7a46021cec7b46e73045f5f30680e015 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 09:13:50 +0100 Subject: [PATCH 123/940] backend: just linting --- shopfloor/services/cluster_picking.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index bb2e60a4f5..57e8b73f98 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -143,9 +143,7 @@ def _response_for_confirm_start(self, batch): pickings = [] for picking in batch.picking_ids: # TODO: is this correct? - p_weight = sum( - picking.mapped('move_line_ids.product_id.weight') - ) + p_weight = sum(picking.mapped("move_line_ids.product_id.weight")) p_values = { "id": picking.id, "name": picking.name, @@ -162,11 +160,7 @@ def _response_for_confirm_start(self, batch): pickings.append(p_values) return self._response( next_state="confirm_start", - data={ - "id": batch.id, - "name": batch.name, - "pickings": pickings, - }, + data={"id": batch.id, "name": batch.name, "pickings": pickings}, ) def _response_for_batch_cannot_be_selected(self): @@ -1182,7 +1176,11 @@ def _schema_for_batch_details(self): "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, "move_line_count": {"required": True, "type": "integer"}, - "weight": {"type": "float", "nullable": False, "required": True}, + "weight": { + "type": "float", + "nullable": False, + "required": True, + }, "origin": { "type": "string", "nullable": False, From 1c803fed012c438c64fd4894a04f43f1268299c3 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 09:48:33 +0100 Subject: [PATCH 124/940] backend: cluster_picking improve not implemented msg --- shopfloor/services/cluster_picking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 57e8b73f98..40dea0b9da 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -220,7 +220,7 @@ def confirm_start(self, picking_batch_id): next_state="start", message={ "message_type": "error", - "message": "no lines remaining, not implemented", + "message": "No lines remaining, should go to 'Prepare unload' but is not implemented yet", }, ) From 7b05ad3d7fd6c81f19a38277d320787f68fc70ad Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 09:49:42 +0100 Subject: [PATCH 125/940] backend: cluster_picking/scan_destination_pack -> prepare_unload when ready --- shopfloor/services/cluster_picking.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 40dea0b9da..a1a61d6593 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -496,14 +496,7 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): batch = move_line.picking_id.batch_id next_line = self._next_line_for_pick(batch) if not next_line: - # TODO - return self._response( - next_state="start", - message={ - "message_type": "error", - "message": "no lines remaining, not implemented", - }, - ) + return self.prepare_unload(batch.id) return self._response( next_state="start_line", data=self._data_move_line(next_line), From 459ffdd7d14db1771021483d773f0b224e55bc7c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 14:47:48 +0100 Subject: [PATCH 126/940] backend: just linting --- shopfloor/services/cluster_picking.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index a1a61d6593..b5b33ff2c2 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -796,7 +796,10 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): def _response_batch_complete(self): return self._response( next_state="start", - message={"message_type": "success", "message": _("Batch Transfer complete")}, + message={ + "message_type": "success", + "message": _("Batch Transfer complete"), + }, ) def unload_split(self, picking_batch_id): From d96bbc677e4a308a7f00946c56d96be58baf18ca Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Tue, 25 Feb 2020 11:54:49 +0100 Subject: [PATCH 127/940] Fix confirmation location changed on PutAway --- shopfloor/actions/message.py | 7 +++++++ shopfloor/services/single_pack_putaway.py | 14 ++++++++++---- shopfloor/tests/test_single_pack_putaway.py | 17 ++++++++++++++++- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 49612a4ac0..5bee87dbef 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -92,6 +92,13 @@ def dest_location_not_allowed(self): def need_confirmation(self): return {"message_type": "warning", "message": _("Are you sure?")} + def confirm_location_changed(self, from_location, to_location): + return { + "message_type": "warning", + "message": _("Confirm location change from %s to %s?") + % (from_location.name, to_location.name), + } + def confirm_pack_moved(self): return { "message_type": "success", diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index a4239c0880..70cdfdc92a 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -191,10 +191,14 @@ def _response_for_forbidden_location(self, move_line, pack): data=self._data_after_package_scanned(move_line, pack), ) - def _response_for_location_need_confirm(self): + def _response_for_location_need_confirm(self, move_line, pack, to_location): message = self.actions_for("message") return self._response( - next_state="confirm_location", message=message.need_confirmation() + next_state="confirm_location", + message=message.confirm_location_changed( + move_line.location_dest_id, to_location + ), + data=self._data_after_package_scanned(move_line, pack), ) def _response_for_validate_success(self): @@ -228,7 +232,9 @@ def validate(self, package_level_id, location_barcode, confirmation=False): if not scanned_location.is_sublocation_of(move.location_dest_id): move.location_dest_id = scanned_location.id else: - return self._response_for_location_need_confirm() + return self._response_for_location_need_confirm( + move_line, package.package_id, scanned_location + ) pack_transfer.set_destination_and_done(move, scanned_location) return self._response_for_validate_success() @@ -296,7 +302,7 @@ def _states(self): "start": {}, "confirm_start": self._schema_for_location, "scan_location": self._schema_for_location, - "confirm_location": {}, + "confirm_location": self._schema_for_location, } def cancel(self): diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 1ff1fefe84..c447aebace 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -403,6 +403,7 @@ def test_validate_location_to_confirm(self): # was already started by the first step (start operation) package_level = self._simulate_started() + move = package_level.move_line_ids.move_id # expected destination is 'shelf1', we'll scan shelf2 which must # ask a confirmation to the user (it's still in the same picking type) response = self.service.dispatch( @@ -412,11 +413,25 @@ def test_validate_location_to_confirm(self): "location_barcode": self.shelf2.barcode, }, ) + message = self.service.actions_for("message").confirm_location_changed( + self.shelf1, self.shelf2 + ) self.assert_response( response, next_state="confirm_location", - message={"message_type": "warning", "message": "Are you sure?"}, + message=message, + data={ + "id": self.ANY, + "location_src": { + "id": self.dispatch_location.id, + "name": self.dispatch_location.name, + }, + "location_dst": {"id": self.shelf1.id, "name": self.shelf1.name}, + "name": package_level.package_id.name, + "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, + "product": {"id": move.product_id.id, "name": move.product_id.name}, + }, ) def test_validate_location_with_confirm(self): From 16085635965baf9eb9379bb96b0373d403343816 Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Wed, 26 Feb 2020 09:38:47 +0100 Subject: [PATCH 128/940] Fix confirm location changed on Pallet Transer --- shopfloor/services/single_pack_transfer.py | 14 ++++++++++---- shopfloor/tests/test_single_pack_transfer.py | 13 ++++++++++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 550ee27ec8..30120c4fd2 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -151,10 +151,14 @@ def _response_for_forbidden_location(self, move_line, pack): data=self._data_after_package_scanned(move_line, pack), ) - def _response_for_location_need_confirm(self): + def _response_for_location_need_confirm(self, move_line, pack, to_location): message = self.actions_for("message") return self._response( - next_state="confirm_location", message=message.need_confirmation() + next_state="confirm_location", + message=message.confirm_location_changed( + move_line.location_dest_id, to_location + ), + data=self._data_after_package_scanned(move_line, pack), ) def _response_for_validate_success(self, last=False): @@ -194,7 +198,9 @@ def validate(self, package_level_id, location_barcode, confirmation=False): if not scanned_location.is_sublocation_of(move.location_dest_id): move.location_dest_id = scanned_location.id else: - return self._response_for_location_need_confirm() + return self._response_for_location_need_confirm( + move_line, package.package_id, scanned_location + ) pack_transfer.set_destination_and_done(move, scanned_location) last = move.picking_id.completion_info == "next_picking_ready" @@ -263,7 +269,7 @@ def _states(self): "start": {}, "confirm_start": self._schema_for_location, "scan_location": self._schema_for_location, - "confirm_location": {}, + "confirm_location": self._schema_for_location, "show_completion_info": {}, } diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index e2d57047d3..c6bada016b 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -542,10 +542,21 @@ def test_validate_location_to_confirm(self): }, ) + message = self.service.actions_for("message").confirm_location_changed( + self.shelf2, self.shelf1 + ) self.assert_response( response, next_state="confirm_location", - message={"message_type": "warning", "message": "Are you sure?"}, + message=message, + data={ + "id": self.ANY, + "name": package_level.package_id.name, + "location_src": {"id": self.shelf1.id, "name": self.shelf1.name}, + "location_dst": {"id": self.shelf2.id, "name": self.shelf2.name}, + "picking": {"id": self.picking.id, "name": self.picking.name}, + "product": {"id": self.product_a.id, "name": self.product_a.name}, + }, ) def test_validate_location_with_confirm(self): From 905f6479fdf2c750d84844f5408b08bbc23f80f5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 15:06:17 +0100 Subject: [PATCH 129/940] backend: scan location msg provided by frontend --- shopfloor/services/cluster_picking.py | 3 ++- shopfloor/services/single_pack_transfer.py | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index b5b33ff2c2..b1e34382ee 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -220,7 +220,8 @@ def confirm_start(self, picking_batch_id): next_state="start", message={ "message_type": "error", - "message": "No lines remaining, should go to 'Prepare unload' but is not implemented yet", + "message": "No lines remaining. " + "Should go to 'Prepare unload' but is not implemented yet", }, ) diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 30120c4fd2..01f5c8b5f0 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -74,10 +74,8 @@ def _response_for_start_to_confirm(self, move_line, pack): ) def _response_for_start_success(self, move_line, pack): - message = self.actions_for("message") return self._response( next_state="scan_location", - message=message.scan_destination(), data=self._data_after_package_scanned(move_line, pack), ) From 43aa99838e3bc8b95acf5f385429f51e03855151 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 15:08:39 +0100 Subject: [PATCH 130/940] backend: fix xmlid --- shopfloor/views/stock_location.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/views/stock_location.xml b/shopfloor/views/stock_location.xml index 58d2869cb4..3bb76f9884 100644 --- a/shopfloor/views/stock_location.xml +++ b/shopfloor/views/stock_location.xml @@ -1,6 +1,6 @@ - + Shopfloor stock.location form stock.location From 4c9baef34753d72116a052f20c57392946252807 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 2 Mar 2020 13:46:55 +0100 Subject: [PATCH 131/940] backend: fix tests --- shopfloor/services/cluster_picking.py | 2 +- shopfloor/tests/test_cluster_picking_base.py | 1 + shopfloor/tests/test_cluster_picking_scan.py | 2 +- shopfloor/tests/test_cluster_picking_select.py | 16 +++++++--------- shopfloor/tests/test_cluster_picking_unload.py | 4 ++-- shopfloor/tests/test_single_pack_putaway.py | 4 ++-- shopfloor/tests/test_single_pack_transfer.py | 11 +++-------- 7 files changed, 17 insertions(+), 23 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index b1e34382ee..20e076ac8c 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -142,7 +142,7 @@ def _response_for_no_batch_found(self): def _response_for_confirm_start(self, batch): pickings = [] for picking in batch.picking_ids: - # TODO: is this correct? + # FIXME weight must be multiplied by quantity p_weight = sum(picking.mapped("move_line_ids.product_id.weight")) p_values = { "id": picking.id, diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py index 02574d89b4..0756b05fef 100644 --- a/shopfloor/tests/test_cluster_picking_base.py +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -75,6 +75,7 @@ def _line_data(self, move_line, qty=None): "name": picking.name, "note": "", "origin": picking.origin, + "partner": {"id": self.customer.id, "name": self.customer.name}, }, "batch": {"id": batch.id, "name": batch.name}, "product": { diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index 38f0b7a40b..1b258273cc 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -225,7 +225,7 @@ def test_scan_destination_pack_ok(self): next_state="start_line", data=self._line_data(next_line), message={ - "message_type": "info", + "message_type": "success", "message": "{} {} put in {}".format( line.qty_done, line.product_id.display_name, self.bin1.name ), diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py index f8e8b0d5dc..91cf8379b5 100644 --- a/shopfloor/tests/test_cluster_picking_select.py +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -59,14 +59,14 @@ def test_find_batch_in_progress_current_user(self): data={ "id": self.batch3.id, "name": self.batch3.name, - # TODO - "weight": 0, "pickings": [ { "id": self.batch3.picking_ids.id, "name": self.batch3.picking_ids.name, "move_line_count": len(self.batch3.picking_ids.move_line_ids), "origin": self.batch3.picking_ids.origin, + # TODO check weight + "weight": 0, "partner": { "id": self.batch3.picking_ids.partner_id.id, "name": self.batch3.picking_ids.partner_id.name, @@ -97,14 +97,14 @@ def test_find_batch_assigned(self): data={ "id": self.batch2.id, "name": self.batch2.name, - # TODO - "weight": 0, "pickings": [ { "id": self.batch2.picking_ids.id, "name": self.batch2.picking_ids.name, "move_line_count": len(self.batch2.picking_ids.move_line_ids), "origin": self.batch2.picking_ids.origin, + # TODO check weight + "weight": 0, "partner": { "id": self.batch2.picking_ids.partner_id.id, "name": self.batch2.picking_ids.partner_id.name, @@ -134,14 +134,14 @@ def test_find_batch_unassigned_draft(self): data={ "id": self.batch2.id, "name": self.batch2.name, - # TODO - "weight": 0, "pickings": [ { "id": self.batch2.picking_ids.id, "name": self.batch2.picking_ids.name, "move_line_count": len(self.batch2.picking_ids.move_line_ids), "origin": self.batch2.picking_ids.origin, + # TODO check weight + "weight": 0, "partner": { "id": self.batch2.picking_ids.partner_id.id, "name": self.batch2.picking_ids.partner_id.name, @@ -225,7 +225,6 @@ def test_select_in_progress_assigned(self): "name": self.batch1.name, # we don't care in these tests, the 'find_batch' tests already # check this - "weight": self.ANY, "pickings": self.ANY, }, ) @@ -249,7 +248,6 @@ def test_select_draft_assigned(self): "name": self.batch1.name, # we don't care in these tests, the 'find_batch' tests already # check this - "weight": self.ANY, "pickings": self.ANY, }, ) @@ -272,7 +270,6 @@ def test_select_draft_unassigned(self): "name": self.batch1.name, # we don't care in these tests, the 'find_batch' tests already # check this - "weight": self.ANY, "pickings": self.ANY, }, ) @@ -387,6 +384,7 @@ def test_confirm_start_ok(self): "name": picking.name, "note": "", "origin": picking.origin, + "partner": {"id": self.customer.id, "name": self.customer.name}, }, "batch": {"id": batch.id, "name": batch.name}, "product": { diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index 5fc13b3137..ed522c66dc 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -169,7 +169,7 @@ def test_set_destination_all_ok(self): self.assert_response( response, next_state="start", - message={"message_type": "info", "message": "Batch Transfer complete"}, + message={"message_type": "success", "message": "Batch Transfer complete"}, ) def test_set_destination_all_remaining_lines(self): @@ -373,7 +373,7 @@ def test_set_destination_all_with_confirmation(self): self.assert_response( response, next_state="start", - message={"message_type": "info", "message": "Batch Transfer complete"}, + message={"message_type": "success", "message": "Batch Transfer complete"}, ) diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index c447aebace..9bad651030 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -282,7 +282,7 @@ def test_validate(self): response, next_state="start", message={ - "message_type": "info", + "message_type": "success", "message": "The pack has been moved, you can scan a new pack.", }, ) @@ -473,7 +473,7 @@ def test_validate_location_with_confirm(self): response, next_state="start", message={ - "message_type": "info", + "message_type": "success", "message": "The pack has been moved, you can scan a new pack.", }, ) diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index c6bada016b..c4c0a69cb0 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -97,10 +97,6 @@ def test_start(self): self.assert_response( response, next_state="scan_location", - message={ - "message_type": "info", - "message": "Scan the destination location", - }, data={ "id": self.ANY, "name": package_level.package_id.name, @@ -193,7 +189,6 @@ def test_start_pack_from_location(self): # checked in the test_start test. response, next_state="scan_location", - message=self.ANY, data=self.ANY, ) @@ -365,7 +360,7 @@ def test_validate(self): response, next_state="start", message={ - "message_type": "info", + "message_type": "success", "message": "The pack has been moved, you can scan a new pack.", }, ) @@ -420,7 +415,7 @@ def test_validate_completion_info(self): response, next_state="show_completion_info", message={ - "message_type": "info", + "message_type": "success", "message": "The pack has been moved, you can scan a new pack.", }, ) @@ -598,7 +593,7 @@ def test_validate_location_with_confirm(self): response, next_state="start", message={ - "message_type": "info", + "message_type": "success", "message": "The pack has been moved, you can scan a new pack.", }, ) From e08b374ebf8dce13b0c858ee63739a84806ead68 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 2 Mar 2020 14:27:06 +0100 Subject: [PATCH 132/940] cluster_picking: correct weight computation --- shopfloor/services/cluster_picking.py | 5 +++-- shopfloor/tests/test_cluster_picking_base.py | 2 ++ shopfloor/tests/test_cluster_picking_select.py | 18 ++++++++---------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 20e076ac8c..05d60d76b6 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -142,8 +142,9 @@ def _response_for_no_batch_found(self): def _response_for_confirm_start(self, batch): pickings = [] for picking in batch.picking_ids: - # FIXME weight must be multiplied by quantity - p_weight = sum(picking.mapped("move_line_ids.product_id.weight")) + p_weight = 0.0 + for move_line in picking.mapped("move_line_ids"): + p_weight += move_line.product_qty * move_line.product_id.weight p_values = { "id": picking.id, "name": picking.name, diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py index 0756b05fef..bf97800b41 100644 --- a/shopfloor/tests/test_cluster_picking_base.py +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -11,6 +11,7 @@ def setUpClass(cls, *args, **kwargs): "type": "product", "default_code": "A", "barcode": "A", + "weight": 2, } ) cls.product_b = cls.env["product.product"].create( @@ -19,6 +20,7 @@ def setUpClass(cls, *args, **kwargs): "type": "product", "default_code": "B", "barcode": "B", + "weight": 3, } ) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py index 91cf8379b5..63b05e04e7 100644 --- a/shopfloor/tests/test_cluster_picking_select.py +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -24,16 +24,16 @@ def setUpClass(cls, *args, **kwargs): # drop base demo data and create our own batches to work with cls.env["stock.picking.batch"].search([]).unlink() cls.batch1 = cls._create_picking_batch( - [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + [[cls.BatchProduct(product=cls.product_a, quantity=3)]] ) cls.batch2 = cls._create_picking_batch( - [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + [[cls.BatchProduct(product=cls.product_a, quantity=3)]] ) cls.batch3 = cls._create_picking_batch( - [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + [[cls.BatchProduct(product=cls.product_a, quantity=3)]] ) cls.batch4 = cls._create_picking_batch( - [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + [[cls.BatchProduct(product=cls.product_a, quantity=3)]] ) def _add_stock_and_assign_pickings_for_batches(self, batches): @@ -65,8 +65,8 @@ def test_find_batch_in_progress_current_user(self): "name": self.batch3.picking_ids.name, "move_line_count": len(self.batch3.picking_ids.move_line_ids), "origin": self.batch3.picking_ids.origin, - # TODO check weight - "weight": 0, + # quantity of products (3) * weight of product (2) + "weight": 6.0, "partner": { "id": self.batch3.picking_ids.partner_id.id, "name": self.batch3.picking_ids.partner_id.name, @@ -103,8 +103,7 @@ def test_find_batch_assigned(self): "name": self.batch2.picking_ids.name, "move_line_count": len(self.batch2.picking_ids.move_line_ids), "origin": self.batch2.picking_ids.origin, - # TODO check weight - "weight": 0, + "weight": 6.0, "partner": { "id": self.batch2.picking_ids.partner_id.id, "name": self.batch2.picking_ids.partner_id.name, @@ -140,8 +139,7 @@ def test_find_batch_unassigned_draft(self): "name": self.batch2.picking_ids.name, "move_line_count": len(self.batch2.picking_ids.move_line_ids), "origin": self.batch2.picking_ids.origin, - # TODO check weight - "weight": 0, + "weight": 6.0, "partner": { "id": self.batch2.picking_ids.partner_id.id, "name": self.batch2.picking_ids.partner_id.name, From 89f4e32fc2574c2c9b346b58904cd13d277d55ec Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 2 Mar 2020 16:07:36 +0100 Subject: [PATCH 133/940] backend: fix openapi error when demo data is not installed --- shopfloor/services/service.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 2d82ba2f1a..6c0861f9f6 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -120,9 +120,7 @@ def _response(self, base_response=None, data=None, next_state=None, message=None def _get_openapi_default_parameters(self): defaults = super()._get_openapi_default_parameters() - demo_api_key = self.env.ref( - "shopfloor.api_key_demo", raise_if_not_found=False - ).key + demo_api_key = self.env.ref("shopfloor.api_key_demo", raise_if_not_found=False) # Try to first the first menu that implements the current service. # Not all usages have a process, in that case, well set the first @@ -142,7 +140,7 @@ def _get_openapi_default_parameters(self): "required": True, "schema": {"type": "string"}, "style": "simple", - "value": demo_api_key, + "value": demo_api_key.key if demo_api_key else "", }, { "name": "SERVICE_CTX_MENU_ID", From 69a30319aa67856f9f327fdf80dc7f8643771114 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 4 Mar 2020 11:47:10 +0100 Subject: [PATCH 134/940] cluster picking: implement unload_scan_destination --- shopfloor/actions/message.py | 6 + shopfloor/services/cluster_picking.py | 152 ++++++--- .../tests/test_cluster_picking_unload.py | 317 ++++++++++++++++++ 3 files changed, 427 insertions(+), 48 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 5bee87dbef..effb2b5892 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -149,3 +149,9 @@ def no_pending_operation_for_pack(self, pack): "message_type": "error", "message": _("No pending operation for package %s." % pack.name), } + + def unrecoverable_error(self): + return { + "message_type": "error", + "message": _("Unrecoverable error, please restart."), + } diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 05d60d76b6..6f3ba361d5 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -216,15 +216,7 @@ def confirm_start(self, picking_batch_id): next_line = self._next_line_for_pick(picking_batch) if not next_line: - # TODO - return self._response( - next_state="start", - message={ - "message_type": "error", - "message": "No lines remaining. " - "Should go to 'Prepare unload' but is not implemented yet", - }, - ) + return self.prepare_unload(picking_batch) return self._response( next_state="start_line", data=self._data_move_line(next_line) @@ -586,7 +578,10 @@ def _response_for_unload_all_need_confirm(self, batch, message=None): def _filter_for_unload(self, line): return ( - line.qty_done > 0 and line.result_package_id and not line.shopfloor_unloaded + line.state == "assigned" + and line.qty_done > 0 + and line.result_package_id + and not line.shopfloor_unloaded ) def _lines_to_unload(self, batch): @@ -604,8 +599,7 @@ def _next_bin_package_for_unload_single(self, batch): def _response_for_unload_single(self, batch): next_package = self._next_bin_package_for_unload_single(batch) if not next_package: - # TODO ensure batch is 'done' and go to start? - return self._response() + return self._unload_end(batch) return self._response( next_state="unload_single", data=self._data_for_unload_single(batch, next_package), @@ -641,23 +635,19 @@ def skip_line(self, move_line_id): """ move_line = self.env["stock.move.line"].browse(move_line_id) if not move_line.exists(): - # TODO go to next line? (but then handle if it's the last one) - return self._response(next_state="start") + message = self.actions_for("message") + return self._response( + next_state="start", message=message.unrecoverable_error() + ) # flag as postponed move_line.shopfloor_postponed = True return self._response_for_skip_line(move_line) def _response_for_skip_line(self, move_line): - next_line = self._next_line_for_pick(move_line.picking_id.batch_id) + batch = move_line.picking_id.batch_id + next_line = self._next_line_for_pick(batch) if not next_line: - # TODO ensure batch is 'done' and go to start? - return self._response( - next_state="start", - message={ - "message_type": "error", - "message": "no lines remaining, not implemented", - }, - ) + return self.prepare_unload(batch.id) return self._response( next_state="start_line", data=self._data_move_line(next_line) ) @@ -684,9 +674,7 @@ def stock_issue(self, move_line_id): destination * start: all lines are done/confirmed (because all lines were unloaded and the last line has a stock issue). In this case, this method *has* - to handle the closing of the batch to create backorders. TODO find a - generic way to share actions happening on transitions such as "close - the batch" + to handle the closing of the batch to create backorders (_unload_end) """ return self._response() @@ -725,9 +713,7 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): * confirm_unload_all: the scanned location is not the expected one (but still a valid one) * start: batch is totally done. In this case, this method *has* - to handle the closing of the batch to create backorders. TODO find a - generic way to share actions happening on transitions such as "close - the batch" + to handle the closing of the batch to create backorders. """ batch = self.env["stock.picking.batch"].browse(picking_batch_id) if not batch.exists(): @@ -742,8 +728,7 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): lines = self._lines_to_unload(batch) if not lines: - # TODO a bit unexpected here but deal with it - return self._response() + return self._unload_end(batch) first_line = fields.first(lines) picking_type = fields.first(batch.picking_ids).picking_type_id @@ -763,9 +748,11 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): if not confirmation: return self._response_for_unload_all_need_confirm(batch) - lines.write( - {"shopfloor_unloaded": True, "location_dest_id": scanned_location.id} - ) + self._unload_set_destination_on_lines(lines, scanned_location) + return self._unload_end(batch) + + def _unload_set_destination_on_lines(self, lines, location): + lines.write({"shopfloor_unloaded": True, "location_dest_id": location.id}) for line in lines: # We set the picking to done only when the last line is # unloaded to avoid backorders. @@ -776,6 +763,7 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): if all(l.shopfloor_unloaded for l in picking_lines): picking.action_done() + def _unload_end(self, batch): if all(picking.state == "done" for picking in batch.picking_ids): # do not use the 'done()' method because it does many things we # don't care about @@ -826,6 +814,8 @@ def unload_split(self, picking_batch_id): return self._response_for_unload_single(batch) + # TODO we shouldn't need this endpoint if we implement the "completion + # info" screen as a kind of generic info box instead of a state def unload_router(self, picking_batch_id): """Called after the info screen, route to the next state @@ -835,9 +825,7 @@ def unload_router(self, picking_batch_id): * unload_single: if the batch still has packs to unload * start_line: if the batch still has lines to pick * start: if the batch is done. In this case, this method *has* - to handle the closing of the batch to create backorders. TODO find a - generic way to share actions happening on transitions such as "close - the batch" + to handle the closing of the batch to create backorders. """ return self._response() @@ -856,17 +844,26 @@ def unload_scan_pack(self, picking_batch_id, package_id, barcode): return self._response_batch_does_not_exist() package = self.env["stock.quant.package"].browse(package_id) if not package.exists(): - # TODO: next package? if no next package, close and go to start? - return self._response() + return self._unload_next_package(batch) if package.name != barcode: return self._response( next_state="unload_single", data=self._data_for_unload_single(batch, package), message={"message_type": "error", "message": _("Wrong bin")}, ) + return self._response_for_unload_set_destination(batch, package) + + def _response_for_unload_set_destination(self, batch, package, message=None): return self._response( next_state="unload_set_destination", data=self._data_for_unload_single(batch, package), + message=message, + ) + + def _response_for_confirm_unload_set_destination(self, batch, package): + return self._response( + next_state="confirm_unload_set_destination", + data=self._data_for_unload_single(batch, package), ) def unload_scan_destination( @@ -877,10 +874,6 @@ def unload_scan_destination( It updates all the assigned move lines with the package to the destination. - TODO not sure: We have to call action_done on the picking *only when we - have scanned all the packages* of the picking, so maybe we have to - keep track of this with a new flag on move lines? - Transitions: * unload_single: invalid scanned location or error * unload_single: line is processed and the next bin can be unloaded @@ -892,12 +885,56 @@ def unload_scan_destination( client should then call /unload_router to know the next state * start_line: if the batch still has lines to pick * start: if the batch is done. In this case, this method *has* - to handle the closing of the batch to create backorders. TODO find a - generic way to share actions happening on transitions such as "close - the batch" + to handle the closing of the batch to create backorders. """ - return self._response() + message = self.actions_for("message") + + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() + + package = self.env["stock.quant.package"].browse(package_id) + if not package.exists(): + return self._unload_next_package(batch) + + # we work only on the lines of the scanned package + lines = self._lines_to_unload(batch).filtered( + lambda l: l.result_package_id == package + ) + if not lines: + return self._unload_end(batch) + + first_line = fields.first(lines) + picking_type = fields.first(batch.picking_ids).picking_type_id + scanned_location = self.actions_for("search").location_from_scan(barcode) + if not scanned_location: + return self._response_for_unload_set_destination( + batch, package, message=message.no_location_found() + ) + if not scanned_location.is_sublocation_of( + picking_type.default_location_dest_id + ): + return self._response_for_unload_set_destination( + batch, package, message=message.dest_location_not_allowed() + ) + + if not scanned_location.is_sublocation_of(first_line.location_dest_id): + if not confirmation: + return self._response_for_confirm_unload_set_destination(batch, package) + + self._unload_set_destination_on_lines(lines, scanned_location) + + return self._unload_next_package(batch) + + def _unload_next_package(self, batch): + next_package = self._next_bin_package_for_unload_single(batch) + if next_package: + return self._response( + next_state="unload_single", + data=self._data_for_unload_single(batch, next_package), + ) + return self._unload_end(batch) class ShopfloorClusterPickingValidator(Component): @@ -1044,6 +1081,14 @@ def confirm_start(self): # unrecoverable error, maybe we should add an attribute # `_start_state = "start"` and implicitly add it in states "start", + # we reopen a batch already started where all the lines were + # already picked and have to be unloaded to the same + # destination + "unload_all", + # we reopen a batch already started where all the lines were + # already picked and have to be unloaded to the different + # destinations + "unload_single", ] ) @@ -1143,13 +1188,24 @@ def unload_router(self): def unload_scan_pack(self): return self._response_schema( - next_states=["unload_single", "unload_set_destination"] + next_states=[ + # go back to the same state if barcode issue + "unload_single", + # if the package to scan was deleted, was the last to unload + # and we still have lines to pick + "start_line", + # next "logical" state, when the scan is ok + "unload_set_destination", + # the package was deleted and was the last one of the batch + "start", + ] ) def unload_scan_destination(self): return self._response_schema( next_states=[ "unload_single", + "unload_set_destination", "confirm_unload_set_destination", "show_completion_info", "start", diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index ed522c66dc..a801367eed 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -489,3 +489,320 @@ def test_unload_scan_pack_wrong_barcode(self): }, message={"message_type": "error", "message": "Wrong bin"}, ) + + +class ClusterPickingUnloadScanDestinationCase(ClusterPickingUnloadingCommonCase): + """Tests covering the /unload_scan_destination endpoint + + Goods have been put in the package bins, they have different destinations + or /unload_split has been called, now user has to unload package per + package. For this, they'll first scanned the bin package already (endpoint + /unload_scan_pack), now they have to set the destination with + /unload_scan_destination for the scanned pack. + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.batch.cluster_picking_unload_all = False + cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids") + cls.bin1_lines = cls.move_lines[:1] + cls.bin2_lines = cls.move_lines[1:] + cls._set_dest_package_and_done(cls.bin1_lines, cls.bin1) + cls._set_dest_package_and_done(cls.bin2_lines, cls.bin2) + cls.bin1_lines.write({"location_dest_id": cls.packing_a_location.id}) + cls.bin2_lines.write({"location_dest_id": cls.packing_b_location.id}) + cls.one_line_picking = cls.batch.picking_ids[0] + cls.two_lines_picking = cls.batch.picking_ids[1] + + def test_unload_scan_destination_ok(self): + """Endpoint /unload_scan_destination is called, result ok""" + dest_location = self.bin1_lines[0].location_dest_id + + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": dest_location.barcode, + }, + ) + + # The scan of destination set 'unloaded' to True to track the fact + # that we set the destination for the line. In this case, the line + # and the stock.picking are 'done' because all the lines of the picking + # have been unloaded + self.assertRecordValues(self.one_line_picking, [{"state": "done"}]) + self.assertRecordValues(self.two_lines_picking, [{"state": "assigned"}]) + self.assertRecordValues( + self.bin1_lines, + [ + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "done", + "picking_id": self.one_line_picking.id, + "location_dest_id": self.packing_a_location.id, + } + ], + ) + self.assertRecordValues( + self.bin2_lines, + [ + { + "shopfloor_unloaded": False, + "qty_done": 10, + "state": "assigned", + "picking_id": self.two_lines_picking.id, + "location_dest_id": self.packing_b_location.id, + }, + { + "shopfloor_unloaded": False, + "qty_done": 10, + "state": "assigned", + "picking_id": self.two_lines_picking.id, + "location_dest_id": self.packing_b_location.id, + }, + ], + ) + self.assertRecordValues(self.batch, [{"state": "in_progress"}]) + + self.assert_response( + response, + next_state="unload_single", + data={ + "id": self.batch.id, + "name": self.batch.name, + # the line of bin1 is unloaded, next one will be bin2 + "package": {"id": self.bin2.id, "name": self.bin2.name}, + "location_dst": { + "id": self.bin2_lines[0].location_dest_id.id, + "name": self.bin2_lines[0].location_dest_id.name, + }, + }, + ) + + def test_unload_scan_destination_one_line_of_picking_only(self): + """Endpoint /unload_scan_destination is called, only one line of picking""" + # For this test, we assume the move in bin1 is already done. + self.one_line_picking.action_done() + # And for the second picking, we put one line bin2 and one line in bin3 + # so the user would have to go through 2 screens for each pack. + # After scanning and setting the destination for bin2, the picking will + # still be "assigned" and they'll have to scan bin3 (but this test stops + # at bin2) + bin3 = self.env["stock.quant.package"].create({}) + bin2_line = self.bin2_lines[0] + bin3_line = self.bin2_lines[1] + self._set_dest_package_and_done(bin3_line, bin3) + + dest_location = bin2_line.location_dest_id + + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin2.id, + "barcode": dest_location.barcode, + }, + ) + + # The scan of destination set 'unloaded' to True to track the fact + # that we set the destination for the line. The picking is still + # assigned because the second line still has to be unloaded. + self.assertRecordValues(self.two_lines_picking, [{"state": "assigned"}]) + self.assertRecordValues( + bin2_line, + [ + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "assigned", + "picking_id": self.two_lines_picking.id, + "location_dest_id": self.packing_b_location.id, + } + ], + ) + self.assertRecordValues( + bin3_line, + [ + { + "shopfloor_unloaded": False, + "qty_done": 10, + "state": "assigned", + "picking_id": self.two_lines_picking.id, + "location_dest_id": self.packing_b_location.id, + } + ], + ) + self.assertRecordValues(self.batch, [{"state": "in_progress"}]) + + self.assert_response( + response, + next_state="unload_single", + data={ + "id": self.batch.id, + "name": self.batch.name, + # the line of bin2 is unloaded, next one will be bin3 + "package": {"id": bin3.id, "name": bin3.name}, + "location_dst": { + "id": bin3_line.location_dest_id.id, + "name": bin3_line.location_dest_id.name, + }, + }, + ) + + def test_unload_scan_destination_last_line(self): + """Endpoint /unload_scan_destination is called on last line""" + # For this test, we assume the move in bin1 is already done. + self.one_line_picking.action_done() + # And for the second picking, bin2 was already unloaded, + # remains bin3 to unload. + bin3 = self.env["stock.quant.package"].create({}) + bin2_line = self.bin2_lines[0] + bin3_line = self.bin2_lines[1] + self._set_dest_package_and_done(bin3_line, bin3) + bin2_line.shopfloor_unloaded = True + + dest_location = bin3_line.location_dest_id + + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": bin3.id, + "barcode": dest_location.barcode, + }, + ) + + # The scan of destination set 'unloaded' to True to track the fact + # that we set the destination for the line. The picking is done + # because all the lines have been unloaded + self.assertRecordValues(self.two_lines_picking, [{"state": "done"}]) + self.assertRecordValues( + bin3_line, + [ + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "done", + "picking_id": self.two_lines_picking.id, + "location_dest_id": self.packing_b_location.id, + } + ], + ) + self.assertRecordValues(self.batch, [{"state": "done"}]) + + self.assert_response( + response, + next_state="start", + message={"message": "Batch Transfer complete", "message_type": "success"}, + ) + + def test_unload_scan_destination_error_location_not_found(self): + """Endpoint called with a barcode not existing for a location""" + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": "¤", + }, + ) + self.assert_response( + response, + next_state="unload_set_destination", + data={ + "id": self.batch.id, + "name": self.batch.name, + "package": {"id": self.bin1.id, "name": self.bin1.name}, + "location_dst": { + "id": self.bin1_lines[0].location_dest_id.id, + "name": self.bin1_lines[0].location_dest_id.name, + }, + }, + message={ + "message_type": "error", + "message": "No location found for this barcode.", + }, + ) + + def test_unload_scan_destination_error_location_invalid(self): + """Endpoint called with a barcode for an invalid location + + It is invalid when the location is not the destination location or + sublocation of the picking type. + """ + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": self.dispatch_location.barcode, + }, + ) + self.assert_response( + response, + next_state="unload_set_destination", + data={ + "id": self.batch.id, + "name": self.batch.name, + "package": {"id": self.bin1.id, "name": self.bin1.name}, + "location_dst": { + "id": self.bin1_lines[0].location_dest_id.id, + "name": self.bin1_lines[0].location_dest_id.name, + }, + }, + message={"message_type": "error", "message": "You cannot place it here"}, + ) + + def test_unload_scan_destination_need_confirmation(self): + """Endpoint called with a barcode for another (valid) location""" + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": self.packing_b_location.barcode, + }, + ) + self.assert_response( + response, + next_state="confirm_unload_set_destination", + data={ + "id": self.batch.id, + "name": self.batch.name, + "package": {"id": self.bin1.id, "name": self.bin1.name}, + "location_dst": { + "id": self.bin1_lines[0].location_dest_id.id, + "name": self.bin1_lines[0].location_dest_id.name, + }, + }, + ) + + def test_unload_scan_destination_with_confirmation(self): + """Endpoint called with a barcode for another (valid) location, confirm""" + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin2.id, + "barcode": self.packing_a_location.barcode, + "confirmation": True, + }, + ) + self.assertRecordValues( + self.bin2.quant_ids, + [ + {"location_id": self.packing_a_location.id}, + {"location_id": self.packing_a_location.id}, + ], + ) + self.assertRecordValues( + self.two_lines_picking.move_line_ids, + [ + {"location_dest_id": self.packing_a_location.id}, + {"location_dest_id": self.packing_a_location.id}, + ], + ) + self.assert_response(response, next_state="unload_single", data=self.ANY) From 35a320b25e5255cf93a5e43802c3e7bdb1df991e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 4 Mar 2020 12:51:18 +0100 Subject: [PATCH 135/940] pack putaway: fix test after change of behavior in odoo After this commit, the behavior has changed: https://github.com/odoo/odoo/commit/90ae4d6afe7 When we change the destination location of a package level, it automatically updates the move to have the same destination location. Before this commit, the dest. location of the package level was not updated, now it is updated with the move line's dest, so the move is updated in turn. --- shopfloor/tests/test_single_pack_putaway.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 9bad651030..55cbaca4d3 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -76,7 +76,7 @@ def test_start(self): move_line, [{"qty_done": 1.0, "location_dest_id": self.shelf1.id}] ) self.assertRecordValues( - move, [{"state": "assigned", "location_dest_id": self.stock_location.id}] + move, [{"state": "assigned", "location_dest_id": self.shelf1.id}] ) self.assert_response( response, From 63613b29b64a6a57b20531e588d9a1c603f1bf61 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 5 Mar 2020 10:11:30 +0100 Subject: [PATCH 136/940] cluster picking: support more edge cases in scan_destination_pack --- shopfloor/actions/message.py | 3 + shopfloor/models/stock_quant_package.py | 4 +- shopfloor/services/cluster_picking.py | 127 ++++++++----- shopfloor/tests/test_cluster_picking_base.py | 8 + shopfloor/tests/test_cluster_picking_scan.py | 175 +++++++++++++++++- .../tests/test_cluster_picking_unload.py | 8 - 6 files changed, 267 insertions(+), 58 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index effb2b5892..b6bde733ad 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -35,6 +35,9 @@ def package_not_found_for_barcode(self, barcode): "message": _("The package %s doesn't exist") % barcode, } + def bin_not_found_for_barcode(self, barcode): + return {"message_type": "error", "message": _("Bin %s doesn't exist") % barcode} + def package_not_allowed_in_src_location(self, barcode, picking_type): return { "message_type": "error", diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py index 63cbf4ab8c..d41c531f2f 100644 --- a/shopfloor/models/stock_quant_package.py +++ b/shopfloor/models/stock_quant_package.py @@ -4,9 +4,9 @@ class StockQuantPackage(models.Model): _inherit = "stock.quant.package" - dest_move_line_ids = fields.One2many( + planned_move_line_ids = fields.One2many( comodel_name="stock.move.line", inverse_name="result_package_id", readonly=True, - help="Technical field. Move lines for which destination" " is this package.", + help="Technical field. Move lines for which destination is this package.", ) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 6f3ba361d5..69d8914567 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -1,4 +1,5 @@ from odoo import _, fields +from odoo.tools.float_utils import float_compare # , float_is_zero from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component @@ -214,18 +215,20 @@ def confirm_start(self, picking_batch_id): if not picking_batch.exists(): return self._response_batch_does_not_exist() - next_line = self._next_line_for_pick(picking_batch) - if not next_line: - return self.prepare_unload(picking_batch) + return self._pick_next_line(picking_batch) + def _pick_next_line(self, batch, message=None): + next_line = self._next_line_for_pick(batch) + if not next_line: + return self.prepare_unload(batch.id) return self._response( - next_state="start_line", data=self._data_move_line(next_line) + next_state="start_line", + data=self._data_move_line(next_line), + message=message, ) def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x): lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(filter_func) - # TODO we probably don't care about the postponed order in the 'set - # destination location' step, reset it on 'scan_destination_pack'? # TODO test line sorting and all these methods to retrieve lines # Sort line by source location, @@ -248,11 +251,11 @@ def _lines_to_pick(self, picking_batch): ) def _last_picked_line(self, picking): - """Get the last line packed for this picking.""" + """Get the last line picked and put in a pack for this picking""" return fields.first( picking.move_line_ids.filtered( lambda l: l.qty_done > 0 and l.result_package_id - ) + ).sorted(key="write_date", reverse=True) ) def _next_line_for_pick(self, picking_batch): @@ -350,18 +353,18 @@ def scan_line(self, move_line_id, barcode): message = self.actions_for("message") move_line = self.env["stock.move.line"].browse(move_line_id) if not move_line.exists(): - # TODO go to next line? (but then handle if it's the last one) - return self._response(next_state="start") - # TODO check again the state of the move line, if already processed - # move to the next state or next line + return self._response( + next_state="start", message=message.unrecoverable_error() + ) if move_line.package_id.name == barcode: - return self._response_for_scan_line_ok(move_line) + return self._response_for_scan_destination(move_line) + # TODO should search for product packaging too elif move_line.product_id.barcode == barcode: if move_line.product_id.tracking in ("lot", "serial"): return self._response_for_scan_line_product_need_lot(move_line) - return self._response_for_scan_line_ok(move_line) + return self._response_for_scan_destination(move_line) elif move_line.lot_id.name == barcode: - return self._response_for_scan_line_ok(move_line) + return self._response_for_scan_destination(move_line) elif move_line.location_id.barcode == barcode: # When a user scan a location, we accept only when we knows that # they scanned the good thing, so if in the location we have @@ -394,7 +397,7 @@ def scan_line(self, move_line_id, barcode): move_line ) - return self._response_for_scan_line_ok(move_line) + return self._response_for_scan_destination(move_line) return self._response( next_state="start_line", @@ -434,7 +437,7 @@ def _response_for_scan_line_product_need_lot(self, move_line): message=message.scan_lot_on_product_tracked_by_lot(), ) - def _response_for_scan_line_ok(self, move_line): + def _response_for_scan_destination(self, move_line, message=None): data = self._data_move_line(move_line) last_picked_line = self._last_picked_line(move_line.picking_id) if last_picked_line: @@ -443,7 +446,7 @@ def _response_for_scan_line_ok(self, move_line): "id": last_picked_line.result_package_id.id, "name": last_picked_line.result_package_id.name, } - return self._response(next_state="scan_destination", data=data) + return self._response(next_state="scan_destination", data=data, message=message) def scan_destination_pack(self, move_line_id, barcode, quantity): """Scan the destination package (bin) for a move line @@ -465,35 +468,65 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): have the same destination. * start_line: to pick the next line if any. """ + message = self.actions_for("message") move_line = self.env["stock.move.line"].browse(move_line_id) if not move_line.exists(): - # TODO go to next line? (but then handle if it's the last one) - return self._response(next_state="start") - # TODO if another line of the picking has a destination package, handle - # it (note: should be added to the 'single line' schema /data as well, - # maybe use a computed field). - - # TODO handle partial pick - if quantity > move_line.product_uom_qty: - # TODO (+ use float_tools) - return self._response() - # TODO handle destination bin not empty + return self._response( + next_state="start", message=message.unrecoverable_error() + ) + rounding = move_line.product_uom_id.rounding + compare = float_compare( + quantity, move_line.product_uom_qty, precision_rounding=rounding + ) + qty_lesser = compare == -1 + qty_greater = compare == 1 + if qty_greater: + return self._response_for_scan_destination( + move_line, + message={ + "message_type": "error", + "message": _("You must not pick more than {} units.").format( + move_line.product_uom_qty + ), + }, + ) + elif qty_lesser: + # split the move line which will be processed later (maybe the user + # has to pick some goods from another place because the location + # contained less items than expected) + remaining = move_line.product_uom_qty - quantity + # TODO it must be the next line to process + move_line.copy({"product_uom_qty": remaining, "qty_done": 0}) + move_line.product_uom_qty = quantity + search = self.actions_for("search") + message = self.actions_for("message") bin_package = search.package_from_scan(barcode) if not bin_package: - # TODO - return self._response() + return self._response_for_scan_destination( + move_line, message=message.bin_not_found_for_barcode(barcode) + ) + + # the scanned package can contain only move lines of the same picking + if any( + ml.picking_id != move_line.picking_id + for ml in bin_package.planned_move_line_ids + ): + return self._response_for_scan_destination( + move_line, + message={ + "message_type": "error", + "message": _( + "The destination bin {} is not empty, please take another." + ).format(bin_package.name), + }, + ) + move_line.write({"qty_done": quantity, "result_package_id": bin_package.id}) # TODO zero check - # TODO handle next line and no next line (in a shared way with other - # endpoints) batch = move_line.picking_id.batch_id - next_line = self._next_line_for_pick(batch) - if not next_line: - return self.prepare_unload(batch.id) - return self._response( - next_state="start_line", - data=self._data_move_line(next_line), + return self._pick_next_line( + batch, message={ "message_type": "success", # TODO different message for products/packs? @@ -549,7 +582,7 @@ def _data_for_unload_all(self, batch): def _data_for_unload_single(self, batch, package): line = fields.first( - package.dest_move_line_ids.filtered(self._filter_for_unload) + package.planned_move_line_ids.filtered(self._filter_for_unload) ) return { # TODO disambiguate "id" everywhere? (id -> picking_batch_id) @@ -645,12 +678,7 @@ def skip_line(self, move_line_id): def _response_for_skip_line(self, move_line): batch = move_line.picking_id.batch_id - next_line = self._next_line_for_pick(batch) - if not next_line: - return self.prepare_unload(batch.id) - return self._response( - next_state="start_line", data=self._data_move_line(next_line) - ) + return self._pick_next_line(batch) def stock_issue(self, move_line_id): """Declare a stock issue for a line @@ -1096,11 +1124,16 @@ def unassign(self): return self._response_schema(next_states=["start"]) def scan_line(self): - return self._response_schema(next_states=["start_line", "scan_destination"]) + return self._response_schema( + next_states=["start_line", "scan_destination", "start"] + ) def scan_destination_pack(self): return self._response_schema( next_states=[ + "start", + # error during scan of pack (wrong barcode, ...) + "scan_destination", # when we still have lines to process "start_line", # when the source location is empty diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py index bf97800b41..f44f3b8a80 100644 --- a/shopfloor/tests/test_cluster_picking_base.py +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -93,6 +93,14 @@ def _line_data(self, move_line, qty=None): "pack": {"id": package.id, "name": package.name} if package else None, } + @classmethod + def _set_dest_package_and_done(cls, move_lines, dest_package): + """Simulate what would have been done in the previous steps""" + for line in move_lines: + line.write( + {"qty_done": line.product_uom_qty, "result_package_id": dest_package.id} + ) + class ClusterPickingAPICase(ClusterPickingCommonCase): """Base tests for the cluster picking API""" diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index 1b258273cc..3e6d212d00 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -198,14 +198,19 @@ def setUpClass(cls, *args, **kwargs): [cls.BatchProduct(product=cls.product_a, quantity=10)], ] ) + cls.one_line_picking = cls.batch.picking_ids[0] + cls.two_lines_picking = cls.batch.picking_ids[1] + cls.bin1 = cls.env["stock.quant.package"].create({}) + cls.bin2 = cls.env["stock.quant.package"].create({}) + + cls._simulate_batch_selected(cls.batch) def test_scan_destination_pack_ok(self): """Happy path for scan destination package It sets the line in the pack for the full qty """ - self._simulate_batch_selected(self.batch) line = self.batch.picking_ids.move_line_ids[0] next_line = self.batch.picking_ids.move_line_ids[1] qty_done = line.product_uom_qty @@ -232,5 +237,173 @@ def test_scan_destination_pack_ok(self): }, ) + def test_scan_destination_pack_ok_last_line(self): + """Happy path for scan destination package + + It sets the line in the pack for the full qty + """ + self._set_dest_package_and_done(self.one_line_picking.move_line_ids, self.bin1) + self._set_dest_package_and_done( + self.two_lines_picking.move_line_ids[0], self.bin2 + ) + # this is the only remaining line to pick + line = self.two_lines_picking.move_line_ids[1] + qty_done = line.product_uom_qty + response = self.service.dispatch( + "scan_destination_pack", + params={ + "move_line_id": line.id, + "barcode": self.bin2.name, + "quantity": qty_done, + }, + ) + self.assertRecordValues( + line, [{"qty_done": qty_done, "result_package_id": self.bin2.id}] + ) + self.assert_response( + response, + # they reach the same destination so next state unload_all + next_state="unload_all", + data={ + "id": self.batch.id, + "name": self.batch.name, + "location_dst": { + "id": self.packing_location.id, + "name": self.packing_location.name, + }, + }, + ) + + def test_scan_destination_pack_not_empty_same_picking(self): + """Scan a destination package with move lines of same picking""" + line1 = self.two_lines_picking.move_line_ids[0] + line2 = self.two_lines_picking.move_line_ids[1] + # we already scan and put the first line in bin1 + self._set_dest_package_and_done(line1, self.bin1) + response = self.service.dispatch( + "scan_destination_pack", + params={ + "move_line_id": line2.id, + # this bin is used for the same picking, should be allowed + "barcode": self.bin1.name, + "quantity": line2.product_uom_qty, + }, + ) + self.assert_response( + response, + next_state="start_line", + # we did not pick this line, so it should go there + data=self._line_data(self.one_line_picking.move_line_ids), + message=self.ANY, + ) + + def test_scan_destination_pack_not_empty_different_picking(self): + """Scan a destination package with move lines of other picking""" + # do as if the user already picked the first good (for another picking) + # and put it in bin1 + self._set_dest_package_and_done(self.one_line_picking.move_line_ids, self.bin1) + line = self.two_lines_picking.move_line_ids[0] + response = self.service.dispatch( + "scan_destination_pack", + params={ + "move_line_id": line.id, + # this bin is used for the other picking + "barcode": self.bin1.name, + "quantity": line.product_uom_qty, + }, + ) + self.assertRecordValues(line, [{"qty_done": 0, "result_package_id": False}]) + self.assert_response( + response, + next_state="scan_destination", + data=self._line_data(line), + message={ + "message_type": "error", + "message": "The destination bin {} is not empty," + " please take another.".format(self.bin1.name), + }, + ) + + def test_scan_destination_pack_bin_not_found(self): + """Scan a destination package that do not exist""" + line = self.one_line_picking.move_line_ids + response = self.service.dispatch( + "scan_destination_pack", + params={ + "move_line_id": line.id, + # this bin is used for the other picking + "barcode": "⌿", + "quantity": line.product_uom_qty, + }, + ) + self.assert_response( + response, + next_state="scan_destination", + data=self._line_data(line), + message={ + "message_type": "error", + "message": "Bin {} doesn't exist".format("⌿"), + }, + ) + + def test_scan_destination_pack_quantity_more(self): + """Pick more units than expected""" + line = self.one_line_picking.move_line_ids + response = self.service.dispatch( + "scan_destination_pack", + params={ + "move_line_id": line.id, + "barcode": self.bin1.name, + "quantity": line.product_uom_qty + 1, + }, + ) + self.assert_response( + response, + next_state="scan_destination", + data=self._line_data(line), + message={ + "message_type": "error", + "message": "You must not pick more than {} units.".format( + line.product_uom_qty + ), + }, + ) + + def test_scan_destination_pack_quantity_less(self): + """Pick less units than expected""" + line = self.one_line_picking.move_line_ids + response = self.service.dispatch( + "scan_destination_pack", + params={ + "move_line_id": line.id, + "barcode": self.bin1.name, + "quantity": line.product_uom_qty - 3, + }, + ) + + self.assertRecordValues( + line, + [{"qty_done": 7, "result_package_id": self.bin1.id, "product_uom_qty": 7}], + ) + new_line = self.one_line_picking.move_line_ids - line + self.assertRecordValues( + new_line, + [{"qty_done": 0, "result_package_id": False, "product_uom_qty": 3}], + ) + + self.assert_response( + response, + next_state="start_line", + # TODO ensure the duplicated line is the next line, it works now but + # maybe only by chance + data=self._line_data(new_line), + message={ + "message_type": "success", + "message": "{} {} put in {}".format( + line.qty_done, line.product_id.display_name, self.bin1.name + ), + }, + ) + # TODO tests for transitions to next line / no next lines, ... diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index a801367eed..5668308fe2 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -32,14 +32,6 @@ def setUpClass(cls, *args, **kwargs): } ) - @classmethod - def _set_dest_package_and_done(cls, move_lines, dest_package): - """Simulate what would have been done in the previous steps""" - for line in move_lines: - line.write( - {"qty_done": line.product_uom_qty, "result_package_id": dest_package.id} - ) - class ClusterPickingPrepareUnloadCase(ClusterPickingUnloadingCommonCase): """Tests covering the /prepare_unload endpoint From cd911d76c23c525d3d3469f82fbb0b37148492f4 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 5 Mar 2020 16:04:53 +0100 Subject: [PATCH 137/940] cluster picking: add zero_check handling --- shopfloor/actions/__init__.py | 1 + shopfloor/actions/inventory.py | 30 +++++ shopfloor/actions/message.py | 8 ++ shopfloor/models/stock_move_line.py | 2 + shopfloor/services/cluster_picking.py | 78 ++++++++++--- shopfloor/tests/common.py | 10 ++ shopfloor/tests/test_cluster_picking_scan.py | 115 ++++++++++++++++++- 7 files changed, 230 insertions(+), 14 deletions(-) create mode 100644 shopfloor/actions/inventory.py diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index a6719c5d2f..c9bc8c3b8e 100644 --- a/shopfloor/actions/__init__.py +++ b/shopfloor/actions/__init__.py @@ -20,3 +20,4 @@ from . import message from . import pack_transfer_validate from . import search +from . import inventory diff --git a/shopfloor/actions/inventory.py b/shopfloor/actions/inventory.py new file mode 100644 index 0000000000..7feeb36d44 --- /dev/null +++ b/shopfloor/actions/inventory.py @@ -0,0 +1,30 @@ +from odoo import _ + +from odoo.addons.component.core import Component + + +class InventoryAction(Component): + """Provide methods to work with inventories + + Several processes have to create inventories at some point, + for instance when there is a stock issue. + """ + + _name = "shopfloor.inventory.action" + _inherit = "shopfloor.process.action" + _usage = "inventory" + + def create_draft_check_empty(self, location, product, ref=None): + """Create a draft inventory for a product with a zero quantity""" + if ref: + name = _("Zero check issue on location {} ({})").format(location.name, ref) + else: + name = _("Zero check issue on location {}").format(location.name) + inventory = self.env["stock.inventory"].create( + { + "name": name, + "location_ids": [(6, 0, location.ids)], + "product_ids": [(6, 0, product.ids)], + } + ) + return inventory diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index b6bde733ad..f338c46658 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -158,3 +158,11 @@ def unrecoverable_error(self): "message_type": "error", "message": _("Unrecoverable error, please restart."), } + + def x_units_put_in_package(self, qty, product, package): + return { + "message_type": "success", + "message": _("{} {} put in {}").format( + qty, product.display_name, package.name + ), + } diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py index b829cb8d85..f205aeaa01 100644 --- a/shopfloor/models/stock_move_line.py +++ b/shopfloor/models/stock_move_line.py @@ -11,3 +11,5 @@ class StockMoveLine(models.Model): help="Technical field. " "Indicates if a the move has been postponed in a process.", ) + # we search lines based on their location in some workflows + location_id = fields.Many2one(index=True) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 69d8914567..88b0f8e15a 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -1,5 +1,5 @@ from odoo import _, fields -from odoo.tools.float_utils import float_compare # , float_is_zero +from odoo.tools.float_utils import float_compare from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component @@ -214,7 +214,6 @@ def confirm_start(self, picking_batch_id): picking_batch = self.env["stock.picking.batch"].browse(picking_batch_id) if not picking_batch.exists(): return self._response_batch_does_not_exist() - return self._pick_next_line(picking_batch) def _pick_next_line(self, batch, message=None): @@ -521,20 +520,52 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): ).format(bin_package.name), }, ) - move_line.write({"qty_done": quantity, "result_package_id": bin_package.id}) - # TODO zero check + batch = move_line.picking_id.batch_id + + if self._planned_qty_in_location_is_empty( + move_line.product_id, move_line.location_id + ): + return self._response_for_zero_check(move_line) + return self._pick_next_line( batch, - message={ - "message_type": "success", - # TODO different message for products/packs? - "message": _("{} {} put in {}").format( - move_line.qty_done, - move_line.product_id.display_name, - bin_package.name, - ), + message=message.x_units_put_in_package( + move_line.qty_done, move_line.product_id, move_line.result_package_id + ), + ) + + def _planned_qty_in_location_is_empty(self, product, location): + """Return if a location will be empty when move lines will be confirmed + + Used for the "zero check". We need to know if a location is empty, but since + we set the move lines to "done" only at the end of the unload workflow, we + have to look at the qty_done of the move lines from this location. + """ + remaining = product.with_context(location=location.id).qty_available + lines_in_loc = self.env["stock.move.line"].search( + # TODO do we care about lots here? + [ + ("state", "!=", "done"), + ("location_id", "=", location.id), + ("product_id", "=", product.id), + ] + ) + planned = remaining - sum(lines_in_loc.mapped("qty_done")) + rounding = product.uom_id.rounding + compare = float_compare(planned, 0, precision_rounding=rounding) + return compare <= 0 + + def _response_for_zero_check(self, move_line): + return self._response( + next_state="zero_check", + data={ + "id": move_line.id, + "location_src": { + "id": move_line.location_id.id, + "name": move_line.location_id.name, + }, }, ) @@ -652,7 +683,28 @@ def is_zero(self, move_line_id, zero): * unload_single: if all lines have a destination package and different destination """ - return self._response() + message = self.actions_for("message") + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + return self._response( + next_state="start", message=message.unrecoverable_error() + ) + + if not zero: + inventory = self.actions_for("inventory") + inventory.create_draft_check_empty( + move_line.location_id, + move_line.product_id, + ref=move_line.picking_id.name, + ) + + batch = move_line.picking_id.batch_id + return self._pick_next_line( + batch, + message=message.x_units_put_in_package( + move_line.qty_done, move_line.product_id, move_line.result_package_id + ), + ) def skip_line(self, move_line_id): """Skip a line. The line will be processed at the end. diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 8f4a5e5925..fe4dbeeba4 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -94,6 +94,11 @@ def assert_response(self, response, next_state=None, message=None, data=None): def _update_qty_in_location( cls, location, product, quantity, package=None, lot=None ): + quants = cls.env["stock.quant"]._gather( + product, location, lot_id=lot, package_id=package, strict=True + ) + # this method adds the quantity to the current quantity, so remove it + quantity -= sum(quants.mapped("quantity")) cls.env["stock.quant"]._update_available_quantity( product, location, quantity, package_id=package, lot_id=lot ) @@ -114,6 +119,11 @@ def _fill_stock_for_moves(cls, moves, in_package=False, in_lot=False): lot = cls.env["stock.production.lot"].create( {"product_id": product.id, "company_id": cls.env.company.id} ) + else: + # always add more quantity in stock to avoid to trigger the + # "zero checks" in tests, not for lots which must have a qty + # of 1 + qty *= 2 cls._update_qty_in_location( location, product, qty, package=package, lot=lot ) diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index 3e6d212d00..93682b07ff 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -405,5 +405,118 @@ def test_scan_destination_pack_quantity_less(self): }, ) + def test_scan_destination_pack_zero_check(self): + """Location will be emptied, have to go to zero check""" + line = self.one_line_picking.move_line_ids + # Update the quantity in the location to be equal to the line's + # so when scan_destination_pack sets the qty_done, the planned + # qty should be zero and trigger a zero check + self._update_qty_in_location( + line.location_id, line.product_id, line.product_uom_qty + ) + response = self.service.dispatch( + "scan_destination_pack", + params={ + "move_line_id": line.id, + "barcode": self.bin1.name, + "quantity": line.product_uom_qty, + }, + ) + + self.assert_response( + response, + next_state="zero_check", + data={ + "id": line.id, + "location_src": { + "id": line.location_id.id, + "name": line.location_id.name, + }, + }, + ) + + +class ClusterPickingIsZeroCase(ClusterPickingCommonCase): + """Tests covering the /is_zero endpoint + + After a line has been scanned, if the location is empty, the + client application is redirected to the "zero_check" state, + where the user has to confirm or not that the location is empty. + When the location is empty, there is nothing to do, but when it + in fact not empty, a draft inventory must be created for the + product so someone can verify. + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.batch = cls._create_picking_batch( + [ + [ + cls.BatchProduct(product=cls.product_a, quantity=10), + cls.BatchProduct(product=cls.product_b, quantity=10), + ] + ] + ) + cls.picking = cls.batch.picking_ids + cls._simulate_batch_selected(cls.batch) + + cls.line = cls.picking.move_line_ids[0] + cls.next_line = cls.picking.move_line_ids[1] + cls.bin1 = cls.env["stock.quant.package"].create({}) + cls._update_qty_in_location( + cls.line.location_id, cls.line.product_id, cls.line.product_uom_qty + ) + # we already scan and put the first line in bin1, at this point the + # system see the location is empty and reach "zero_check" + cls._set_dest_package_and_done(cls.line, cls.bin1) + + def test_is_zero_is_empty(self): + """call /is_zero confirming it's empty""" + response = self.service.dispatch( + "is_zero", params={"move_line_id": self.line.id, "zero": True} + ) + self.assert_response( + response, + next_state="start_line", + data=self._line_data(self.next_line), + message={ + "message_type": "success", + "message": "{} {} put in {}".format( + self.line.qty_done, + self.line.product_id.display_name, + self.bin1.name, + ), + }, + ) -# TODO tests for transitions to next line / no next lines, ... + def test_is_zero_is_not_empty(self): + """call /is_zero not confirming it's empty""" + response = self.service.dispatch( + "is_zero", params={"move_line_id": self.line.id, "zero": False} + ) + inventory = self.env["stock.inventory"].search( + [ + ("location_ids", "in", self.line.location_id.id), + ("product_ids", "in", self.line.product_id.id), + ("state", "=", "draft"), + ] + ) + self.assertTrue(inventory) + self.assertEqual( + inventory.name, + "Zero check issue on location Stock ({})".format(self.picking.name), + ) + self.assert_response( + response, + next_state="start_line", + data=self._line_data(self.next_line), + message={ + "message_type": "success", + "message": "{} {} put in {}".format( + self.line.qty_done, + self.line.product_id.display_name, + self.bin1.name, + ), + }, + ) From c6d7641f87a1ecceb2779c249550949ad476a4b5 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 5 Mar 2020 16:36:19 +0100 Subject: [PATCH 138/940] rest validators: add an implicit start state Since all the endpoints could theoretically bring back to the start state in case of unrecoverable error, we should not need to add the state everywhere. It's still fine and probably better to keep the start state in the possible next state of a state when it's the logical next state (not because of an error) for documentation. --- shopfloor/services/checkout.py | 20 ++++--- shopfloor/services/cluster_picking.py | 64 +++++++++------------- shopfloor/services/single_pack_putaway.py | 8 +-- shopfloor/services/single_pack_transfer.py | 10 ++-- shopfloor/services/validator.py | 16 ++++++ 5 files changed, 59 insertions(+), 59 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 6e2c95d8b4..77e5acea3e 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -270,6 +270,8 @@ class ShopfloorCheckoutValidatorResponse(Component): _name = "shopfloor.checkout.validator.response" _usage = "checkout.validator.response" + _start_state = "select_document" + def _states(self): """List of possible next states @@ -290,35 +292,35 @@ def _states(self): } def scan_document(self): - return self._response_schema(next_states=["select_document", "select_line"]) + return self._response_schema(next_states={"select_document", "select_line"}) def list_stock_picking(self): - return self._response_schema(next_states=["manual_selection"]) + return self._response_schema(next_states={"manual_selection"}) def select(self): return self._response_schema( - next_states=["manual_selection", "summary", "select_line"] + next_states={"manual_selection", "summary", "select_line"} ) def scan_line(self): - return self._response_schema(next_states=["select_line", "select_pack"]) + return self._response_schema(next_states={"select_line", "select_pack"}) def select_line(self): return self.scan_line() def reset_line_qty(self): - return self._response_schema(next_states=["select_pack"]) + return self._response_schema(next_states={"select_pack"}) def set_line_qty(self): - return self._response_schema(next_states=["select_pack"]) + return self._response_schema(next_states={"select_pack"}) def scan_pack_action(self): return self._response_schema( - next_states=["select_pack", "select_line", "summary"] + next_states={"select_pack", "select_line", "summary"} ) def set_custom_qty(self): - return self._response_schema(next_states=["select_pack"]) + return self._response_schema(next_states={"select_pack"}) def new_package(self): - return self._response_schema(next_states=["select_line"]) + return self._response_schema(next_states={"select_line"}) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 88b0f8e15a..e0fd21f207 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -1143,24 +1143,18 @@ def _states(self): } def find_batch(self): - return self._response_schema(next_states=["confirm_start", "start"]) + return self._response_schema(next_states={"confirm_start"}) def list_batch(self): - return self._response_schema(next_states=["manual_selection"]) + return self._response_schema(next_states={"manual_selection"}) def select(self): - return self._response_schema(next_states=["manual_selection", "confirm_start"]) + return self._response_schema(next_states={"manual_selection", "confirm_start"}) def confirm_start(self): return self._response_schema( - next_states=[ + next_states={ "start_line", - # "start" should be pretty rare, only if the batch has been - # canceled, deleted meanwhile... - # TODO every state could bring back to 'start' in case of - # unrecoverable error, maybe we should add an attribute - # `_start_state = "start"` and implicitly add it in states - "start", # we reopen a batch already started where all the lines were # already picked and have to be unloaded to the same # destination @@ -1169,21 +1163,18 @@ def confirm_start(self): # already picked and have to be unloaded to the different # destinations "unload_single", - ] + } ) def unassign(self): - return self._response_schema(next_states=["start"]) + return self._response_schema(next_states={"start"}) def scan_line(self): - return self._response_schema( - next_states=["start_line", "scan_destination", "start"] - ) + return self._response_schema(next_states={"start_line", "scan_destination"}) def scan_destination_pack(self): return self._response_schema( - next_states=[ - "start", + next_states={ # error during scan of pack (wrong barcode, ...) "scan_destination", # when we still have lines to process @@ -1196,24 +1187,24 @@ def scan_destination_pack(self): # when all lines have been processed and have different # destinations "unload_single", - ] + } ) def prepare_unload(self): return self._response_schema( - next_states=[ + next_states={ # when all lines have been processed and have same # destination "unload_all", # when all lines have been processed and have different # destinations "unload_single", - ] + } ) def is_zero(self): return self._response_schema( - next_states=[ + next_states={ # when we still have lines to process "start_line", # when all lines have been processed and have same @@ -1222,15 +1213,15 @@ def is_zero(self): # when all lines have been processed and have different # destinations "unload_single", - ] + } ) def skip_line(self): - return self._response_schema(next_states=["start_line"]) + return self._response_schema(next_states={"start_line"}) def stock_issue(self): return self._response_schema( - next_states=[ + next_states={ # when we still have lines to process "start_line", # when all lines have been processed and have same @@ -1239,16 +1230,15 @@ def stock_issue(self): # when all lines have been processed and have different # destinations "unload_single", - "start", - ] + } ) def change_pack_lot(self): - return self._response_schema(next_states=["scan_destination", "start_line"]) + return self._response_schema(next_states={"scan_destination", "start_line"}) def set_destination_all(self): return self._response_schema( - next_states=[ + next_states={ # if the batch still contain lines "start_line", # invalid destination, have to scan a valid one @@ -1260,20 +1250,18 @@ def set_destination_all(self): "confirm_unload_all", # batch finished "start", - ] + } ) def unload_split(self): - return self._response_schema(next_states=["unload_single"]) + return self._response_schema(next_states={"unload_single"}) def unload_router(self): - return self._response_schema( - next_states=["unload_single", "start_line", "start"] - ) + return self._response_schema(next_states={"unload_single", "start_line"}) def unload_scan_pack(self): return self._response_schema( - next_states=[ + next_states={ # go back to the same state if barcode issue "unload_single", # if the package to scan was deleted, was the last to unload @@ -1281,21 +1269,19 @@ def unload_scan_pack(self): "start_line", # next "logical" state, when the scan is ok "unload_set_destination", - # the package was deleted and was the last one of the batch - "start", - ] + } ) def unload_scan_destination(self): return self._response_schema( - next_states=[ + next_states={ "unload_single", "unload_set_destination", "confirm_unload_set_destination", "show_completion_info", "start", "start_line", - ] + } ) # TODO single class for sharing schemas between services diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 70cdfdc92a..9c2857d348 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -306,17 +306,15 @@ def _states(self): } def cancel(self): - return self._response_schema(next_states=["start"]) + return self._response_schema(next_states={"start"}) def validate(self): return self._response_schema( - next_states=["scan_location", "start", "confirm_location"] + next_states={"scan_location", "start", "confirm_location"} ) def start(self): - return self._response_schema( - next_states=["confirm_start", "start", "scan_location"] - ) + return self._response_schema(next_states={"confirm_start", "scan_location"}) @property def _schema_for_location(self): diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 01f5c8b5f0..edef7da3b1 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -272,21 +272,19 @@ def _states(self): } def start(self): - return self._response_schema( - next_states=["confirm_start", "start", "scan_location"] - ) + return self._response_schema(next_states={"confirm_start", "scan_location"}) def cancel(self): - return self._response_schema(next_states=["start"]) + return self._response_schema(next_states={"start"}) def validate(self): return self._response_schema( - next_states=[ + next_states={ "scan_location", "start", "confirm_location", "show_completion_info", - ] + } ) @property diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py index f4e1e3373c..b7db691361 100644 --- a/shopfloor/services/validator.py +++ b/shopfloor/services/validator.py @@ -55,6 +55,9 @@ class BaseShopfloorValidatorResponse(AbstractComponent): _collection = "shopfloor.service" _is_rest_service_component = False + # Initial state of a workflow + _start_state = "start" + def _states(self): """List of possible next states @@ -73,6 +76,10 @@ def _response_schema(self, data_schema=None, next_states=None): next_states is a list of allowed states to which the client can transition. The schema of the data needed for every state of the list must be defined in the ``_states`` method. + + The initial state does not need to be included in the list, it + is implicit as we assume that any state can go back to the initial + state in case of unrecoverable error. """ response_schema = { "message": { @@ -92,7 +99,16 @@ def _response_schema(self, data_schema=None, next_states=None): data_schema = {} if next_states: + next_states = set(next_states) + next_states.add(self._start_state) states_schemas = self._states() + if self._start_state not in states_schemas: + raise ValueError( + "the _start_state is {} but this state does not exist" + ", you may want to change the property's value".format( + self._start_state + ) + ) unknown_states = set(next_states) - states_schemas.keys() if unknown_states: raise ValueError( From 60f4d2d6a10cdf8b6a8fe0dd8577c984084622b5 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 6 Mar 2020 16:22:19 +0100 Subject: [PATCH 139/940] cluster picking: implement skipped test --- shopfloor/tests/test_cluster_picking_select.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py index 63b05e04e7..af742af033 100644 --- a/shopfloor/tests/test_cluster_picking_select.py +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -1,5 +1,3 @@ -import unittest - from .test_cluster_picking_base import ClusterPickingCommonCase @@ -414,11 +412,22 @@ def test_confirm_start_not_exists(self): next_state="start", ) - # TODO - @unittest.skip("not sure yet what we have to do, keep for later") def test_confirm_start_all_is_done(self): """User confirms start but all lines are already done""" # we want to jump to the start because there are no lines # to process anymore, but we want to set pickings and # picking batch to done if not done yet (because the process # was interrupted for instance) + self._set_dest_package_and_done( + self.batch.mapped("picking_ids.move_line_ids"), + self.env["stock.quant.package"].create({}), + ) + self.batch.done() + response = self.service.dispatch( + "confirm_start", params={"picking_batch_id": self.batch.id} + ) + self.assert_response( + response, + next_state="start", + message={"message": "Batch Transfer complete", "message_type": "success"}, + ) From 8ac223596390aa32055afe6c8e1a41df38443b23 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 11 Mar 2020 15:26:21 +0100 Subject: [PATCH 140/940] checkout: more specs, and add a common component for schemas we'll still have to refine the format of the schemas and to adapt the workflows to use shared schemas --- shopfloor/actions/__init__.py | 1 + shopfloor/actions/data.py | 13 ++ shopfloor/services/__init__.py | 1 + shopfloor/services/checkout.py | 342 ++++++++++++++++++++++++++++---- shopfloor/services/schema.py | 75 +++++++ shopfloor/services/validator.py | 3 + 6 files changed, 401 insertions(+), 34 deletions(-) create mode 100644 shopfloor/actions/data.py create mode 100644 shopfloor/services/schema.py diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index c9bc8c3b8e..b98bf56e07 100644 --- a/shopfloor/actions/__init__.py +++ b/shopfloor/actions/__init__.py @@ -17,6 +17,7 @@ """ from . import base_action +from . import data from . import message from . import pack_transfer_validate from . import search diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py new file mode 100644 index 0000000000..ee2b903950 --- /dev/null +++ b/shopfloor/actions/data.py @@ -0,0 +1,13 @@ +from odoo.addons.component.core import Component + + +class DataAction(Component): + """Provide methods to share data structures + + The methods should be used in Service Components, so we try to + have similar data structures across scenarios. + """ + + _name = "shopfloor.data.action" + _inherit = "shopfloor.process.action" + _usage = "data" diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 6a8054d8e0..34f9243f4f 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -1,6 +1,7 @@ # core classes from . import service from . import validator +from . import schema # generic services from . import app diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 77e5acea3e..bb2d2f4083 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -98,7 +98,7 @@ def scan_line(self, picking_id, barcode): Transitions: * select_line: nothing could be found for the barcode - * select_pack: lines are selected, user is redirected to this + * select_package: lines are selected, user is redirected to this screen to change the qty done and destination pack if needed """ return self._response() @@ -110,41 +110,41 @@ def select_line(self, picking_id, package_id=None, move_line_id=None): move_line_id is given by the client (user clicked on a list). It returns a list of move line ids that will be displayed by the - screen ``select_pack``. This screen will have to send this list to + screen ``select_package``. This screen will have to send this list to the endpoints it calls, so we can select/deselect lines but still show them in the list of the client application. Transitions: * select_line: nothing could be found for the barcode - * select_pack: lines are selected, user is redirected to this - screen to change the qty done and destination pack if needed + * select_package: lines are selected, user is redirected to this + screen to change the qty done and destination package if needed """ assert package_id or move_line_id return self._response() - def reset_line_qty(self, move_line_id): + def reset_line_qty(self, picking_id, move_line_id): """Reset qty_done of a move line to zero - Used to deselect a line in the "select_pack" screen. + Used to deselect a line in the "select_package" screen. Transitions: - * select_pack: goes back to the same state, the line will appear + * select_package: goes back to the same state, the line will appear as deselected """ return self._response() - def set_line_qty(self, move_line_id): + def set_line_qty(self, picking_id, move_line_id): """Set qty_done of a move line to its reserved quantity - Used to deselect a line in the "select_pack" screen. + Used to deselect a line in the "select_package" screen. Transitions: - * select_pack: goes back to the same state, the line will appear + * select_package: goes back to the same state, the line will appear as selected """ return self._response() - def scan_pack_action(self, move_line_ids, barcode): + def scan_package_action(self, picking_id, move_line_ids, barcode): """Scan a package, a lot, a product or a package to handle a line When a package is scanned, if the package is known as the destination @@ -166,7 +166,7 @@ def scan_pack_action(self, move_line_ids, barcode): ``qty_done`` > 0 and have no destination package. Transitions: - * select_pack: when a product or lot is scanned to select/deselect, + * select_package: when a product or lot is scanned to select/deselect, the client app has to show the same screen with the updated selection * select_line: when a package or packaging type is scanned, move lines have been put in package and we can return back to this state to handle @@ -176,16 +176,16 @@ def scan_pack_action(self, move_line_ids, barcode): """ return self._response() - def set_custom_qty(self, move_line_id, qty_done): + def set_custom_qty(self, picking_id, move_line_id, qty_done): """Change qty_done of a move line with a custom value Transitions: - * select_pack: goes back to this screen showing all the lines after + * select_package: goes back to this screen showing all the lines after we changed the qty """ return self._response() - def new_package(self, move_line_ids): + def new_package(self, picking_id, move_line_ids): """Add all selected lines in a new package It creates a new package and set it as the destination package of all @@ -199,7 +199,102 @@ def new_package(self, move_line_ids): """ return self._response() - # TODO add the rest of the methods + def list_dest_package(self, picking_id, move_line_ids): + """Return a list of packages the user can select for the lines + + Only valid packages must be proposed. Look at ``scan_dest_package`` + for the conditions to be valid. + + Transitions: + * select_dest_package: selection screen + """ + return self._response() + + def scan_dest_package(self, picking_id, move_line_ids, barcode): + """Scan destination package for lines + + Set the destination package on the selected lines with a `qty_done` if + the package is valid. It is valid when one of: + + * it is already the destination package of another line of the stock.picking + * it is the source package of the selected lines + + Note: by default, Odoo puts the same destination package as the source + package on lines. + + Transitions: + * select_package: error when scanning package + * select_line: lines to package remain + * summary: all lines are put in packages + """ + return self._response() + + def set_dest_package(self, picking_id, move_line_ids, package_id): + """Set destination package for lines from a package id + + Used by the list obtained from ``list_dest_package``. + + The validity is the same as ``scan_dest_package``. + + Transitions: + * select_dest_package: error when scanning package + * select_line: lines to package remain + * summary: all lines are put in packages + """ + return self._response() + + def summary(self, picking_id): + """Return information for the summary screen + + Transitions: + * summary + """ + return self._response() + + def list_package_type(self, picking_id, package_id): + """List the available package types for a package + + For a package, we can change the package type. The available + package types are the ones with no product. + + Transitions: + * change_package_type + """ + return self._response() + + def set_package_type(self, picking_id, package_id, package_type_id): + """Set a package type on a package + + Transitions: + * change_package_type: in case of error + * summary + """ + return self._response() + + def remove_package(self, picking_id, package_id): + """Remove destination package from move lines and set qty done to 0 + + All the move lines with the package as ``result_package_id`` have their + ``result_package_id`` reset to the source package (default odoo behavior) + and their ``qty_done`` set to 0. + + Transitions: + * summary + """ + return self._response() + + def done(self, picking_id, confirmation=False): + """Set the moves as done + + If some lines have not the full ``qty_done`` or no destination package set, + a confirmation is asked to the user. + + Transitions: + * summary: in case of error + * select_document: after done, goes back to start + * confirm_done: confirm a partial + """ + return self._response() class ShopfloorCheckoutValidator(Component): @@ -232,13 +327,20 @@ def select_line(self): } def reset_line_qty(self): - return {"move_line_id": {"coerce": to_int, "required": True, "type": "integer"}} + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } def set_line_qty(self): - return {"move_line_id": {"coerce": to_int, "required": True, "type": "integer"}} + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } - def scan_pack_action(self): + def scan_package_action(self): return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, "move_line_ids": { "type": "list", "required": True, @@ -249,17 +351,79 @@ def scan_pack_action(self): def set_custom_qty(self): return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, "qty_done": {"coerce": to_float, "required": True, "type": "float"}, } def new_package(self): return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, "move_line_ids": { "type": "list", "required": True, "schema": {"coerce": to_int, "required": True, "type": "integer"}, - } + }, + } + + def list_dest_package(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_ids": { + "type": "list", + "required": True, + "schema": {"coerce": to_int, "required": True, "type": "integer"}, + }, + } + + def scan_dest_package(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_ids": { + "type": "list", + "required": True, + "schema": {"coerce": to_int, "required": True, "type": "integer"}, + }, + "barcode": {"required": True, "type": "string"}, + } + + def set_dest_package(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_ids": { + "type": "list", + "required": True, + "schema": {"coerce": to_int, "required": True, "type": "integer"}, + }, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def summary(self): + return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def list_package_type(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def set_package_type(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_type_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def remove_package(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def done(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, } @@ -278,17 +442,99 @@ def _states(self): With the schema of the data send to the client to transition to the next state. """ - # TODO schemas return { "select_document": {}, "manual_selection": {}, - "select_line": {}, - "select_pack": {}, - "change_quantity": {}, - "select_dest_package": {}, - "summary": {}, - "change_package_type": {}, - "confirm_done": {}, + "select_line": self._schema_picking_details, + "select_package": self._schema_selected_lines, + "change_quantity": self._schema_selected_lines, + "select_dest_package": self._schema_select_package, + "summary": self._schema_picking_details, + "change_package_type": self._schema_select_package_type, + "confirm_done": self._schema_picking_details, + } + + @property + def _schema_picking_details(self): + return { + "picking": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "origin": {"type": "string", "nullable": True, "required": True}, + "note": {"type": "string", "nullable": True, "required": True}, + "move_lines": { + "type": "list", + "schema": { + "type": "dict", + "schema": self.schemas().move_line(), + }, + }, + }, + } + } + + @property + def _schema_select_package(self): + return { + "selected_move_lines": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas().move_line()}, + }, + "packages": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas().package()}, + }, + "picking": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "origin": {"type": "string", "nullable": True, "required": True}, + "note": {"type": "string", "nullable": True, "required": True}, + }, + }, + } + + @property + def _schema_select_package_type(self): + return { + "selected_move_lines": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas().move_line()}, + }, + "package_types": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas().package_type()}, + }, + "picking": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "origin": {"type": "string", "nullable": True, "required": True}, + "note": {"type": "string", "nullable": True, "required": True}, + }, + }, + } + + @property + def _schema_selected_lines(self): + return { + "selected_move_lines": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas().move_line()}, + }, + "picking": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "origin": {"type": "string", "nullable": True, "required": True}, + "note": {"type": "string", "nullable": True, "required": True}, + }, + }, } def scan_document(self): @@ -303,24 +549,52 @@ def select(self): ) def scan_line(self): - return self._response_schema(next_states={"select_line", "select_pack"}) + return self._response_schema(next_states={"select_line", "select_package"}) def select_line(self): return self.scan_line() def reset_line_qty(self): - return self._response_schema(next_states={"select_pack"}) + return self._response_schema(next_states={"select_package"}) def set_line_qty(self): - return self._response_schema(next_states={"select_pack"}) + return self._response_schema(next_states={"select_package"}) - def scan_pack_action(self): + def scan_package_action(self): return self._response_schema( - next_states={"select_pack", "select_line", "summary"} + next_states={"select_package", "select_line", "summary"} ) def set_custom_qty(self): - return self._response_schema(next_states={"select_pack"}) + return self._response_schema(next_states={"select_package"}) def new_package(self): return self._response_schema(next_states={"select_line"}) + + def list_dest_package(self): + return self._response_schema(next_states={"select_dest_package"}) + + def scan_dest_package(self): + return self._response_schema( + next_states={"select_package", "select_line", "summary"} + ) + + def set_dest_package(self): + return self._response_schema( + next_states={"select_dest_package", "select_line", "summary"} + ) + + def summary(self): + return self._response_schema(next_states={"summary"}) + + def list_package_type(self): + return self._response_schema(next_states={"change_package_type"}) + + def set_package_type(self): + return self._response_schema(next_states={"change_package_type", "summary"}) + + def remove_package(self): + return self._response_schema(next_states={"summary"}) + + def done(self): + return self._response_schema(next_states={"summary", "confirm_done"}) diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py new file mode 100644 index 0000000000..8d05bd4f02 --- /dev/null +++ b/shopfloor/services/schema.py @@ -0,0 +1,75 @@ +from odoo.addons.component.core import Component + + +class BaseShopfloorSchemaResponse(Component): + """Provide methods to share schema structures + + The methods should be used in Service Components, so we try to + have similar schema structures across scenarios. + """ + + _inherit = "base.rest.service" + _name = "base.shopfloor.schemas" + _collection = "shopfloor.service" + _usage = "schema" + _is_rest_service_component = False + + def move_line(self): + return { + "id": {"type": "integer", "required": True}, + "qty_done": {"type": "float", "required": True}, + "quantity": {"type": "float", "required": True}, + "product": {"type": "dict", "required": True, "schema": self.product()}, + "lot": {"type": "dict", "required": True, "schema": self.lot()}, + "package_src": {"type": "dict", "required": True, "schema": self.package()}, + "package_dest": { + "type": "dict", + "required": True, + "schema": self.package(), + }, + "location_src": { + "type": "dict", + "required": True, + "schema": self.location(), + }, + "location_dest": { + "type": "dict", + "required": True, + "schema": self.location(), + }, + } + + def product(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "display_name": {"type": "string", "nullable": False, "required": True}, + "default_code": {"type": "string", "nullable": False, "required": True}, + } + + def package(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "weight": {"required": True, "nullable": True, "type": "float"}, + "line_count": {"required": True, "nullable": True, "type": "integer"}, + "package_type": {"required": True, "nullable": True, "type": "string"}, + } + + def lot(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + } + + def location(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + } + + def package_type(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + } diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py index b7db691361..db6d3bb3df 100644 --- a/shopfloor/services/validator.py +++ b/shopfloor/services/validator.py @@ -66,6 +66,9 @@ def _states(self): """ return {} + def schemas(self): + return self.component(usage="schema") + def _response_schema(self, data_schema=None, next_states=None): """Schema for the return validator From edbd1780ef3d3fa1ee050c4ce9e88fb25cfcf1b7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 12 Mar 2020 13:06:20 +0100 Subject: [PATCH 141/940] Start checkout --- shopfloor/demo/shopfloor_menu_demo.xml | 6 ++ shopfloor/demo/shopfloor_process_demo.xml | 6 ++ shopfloor/demo/stock_picking_type_demo.xml | 16 +++++ shopfloor/models/shopfloor_process.py | 1 + shopfloor/services/checkout.py | 18 +++-- shopfloor/services/schema.py | 2 +- shopfloor/tests/test_checkout.py | 77 +++++++++++++++++++++- 7 files changed, 118 insertions(+), 8 deletions(-) diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml index f19f603dfb..84e8e4f0c4 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -18,4 +18,10 @@ + + Checkout + 30 + + + diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml index 660079c145..844ec09df6 100644 --- a/shopfloor/demo/shopfloor_process_demo.xml +++ b/shopfloor/demo/shopfloor_process_demo.xml @@ -18,4 +18,10 @@ + + Checkout + checkout + + + diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml index 653cac7759..079bf0fb83 100644 --- a/shopfloor/demo/stock_picking_type_demo.xml +++ b/shopfloor/demo/stock_picking_type_demo.xml @@ -48,4 +48,20 @@ + + Checkout + CHK + + + + + + + + + internal + + + + diff --git a/shopfloor/models/shopfloor_process.py b/shopfloor/models/shopfloor_process.py index bff78f93e3..1492df892d 100644 --- a/shopfloor/models/shopfloor_process.py +++ b/shopfloor/models/shopfloor_process.py @@ -18,4 +18,5 @@ def _selection_code(self): ("single_pack_putaway", "Single Pack Put-away"), ("single_pack_transfer", "Single Pack Transfer"), ("cluster_picking", "Cluster Picking"), + ("checkout", "Checkout"), ] diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index bb2d2f4083..0e7eba6bb7 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -3,6 +3,10 @@ from .service import to_float +# NOTE: we need to know if the destination package is set, but sometimes +# the dest. package is kept, so we should have an additional field on +# move lines to keep track of lines set + class Checkout(Component): """ @@ -48,6 +52,8 @@ def scan_document(self, barcode): Transitions: * select_document: when no stock.picking could be found * select_line: a stock.picking is selected + * summary: stock.picking is selected and all its lines have a + destination pack set """ return self._response() @@ -445,17 +451,17 @@ def _states(self): return { "select_document": {}, "manual_selection": {}, - "select_line": self._schema_picking_details, + "select_line": self._schema_stock_picking_details, "select_package": self._schema_selected_lines, "change_quantity": self._schema_selected_lines, "select_dest_package": self._schema_select_package, - "summary": self._schema_picking_details, + "summary": self._schema_stock_picking_details, "change_package_type": self._schema_select_package_type, - "confirm_done": self._schema_picking_details, + "confirm_done": self._schema_stock_picking_details, } @property - def _schema_picking_details(self): + def _schema_stock_picking_details(self): return { "picking": { "type": "dict", @@ -538,7 +544,9 @@ def _schema_selected_lines(self): } def scan_document(self): - return self._response_schema(next_states={"select_document", "select_line"}) + return self._response_schema( + next_states={"select_document", "select_line", "summary"} + ) def list_stock_picking(self): return self._response_schema(next_states={"manual_selection"}) diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 8d05bd4f02..5e58b1ffca 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -53,7 +53,7 @@ def package(self): "name": {"type": "string", "nullable": False, "required": True}, "weight": {"required": True, "nullable": True, "type": "float"}, "line_count": {"required": True, "nullable": True, "type": "integer"}, - "package_type": {"required": True, "nullable": True, "type": "string"}, + "package_type_name": {"required": True, "nullable": True, "type": "string"}, } def lot(self): diff --git a/shopfloor/tests/test_checkout.py b/shopfloor/tests/test_checkout.py index 7cd5133cdd..2c6d840bef 100644 --- a/shopfloor/tests/test_checkout.py +++ b/shopfloor/tests/test_checkout.py @@ -1,3 +1,5 @@ +from odoo.tests.common import Form + from .common import CommonCase @@ -11,8 +13,7 @@ def setUpClass(cls, *args, **kwargs): cls.product_b = cls.env["product.product"].create( {"name": "Product B", "type": "product"} ) - # TODO - cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout") cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id @@ -26,3 +27,75 @@ def setUp(self): def test_to_openapi(self): # will raise if it fails to generate the openapi specs self.service.to_openapi() + + def _create_picking(self): + picking_form = Form(self.env["stock.picking"]) + picking_form.picking_type_id = self.picking_type + # TODO we must have packages in origin + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.product_a + move.product_uom_qty = 1 + picking = picking_form.save() + picking.action_confirm() + return picking + + def _stock_picking_data(self, picking): + return { + "id": picking.id, + "name": picking.name, + "origin": picking.origin, + "note": picking.note, + "move_lines": [ + { + "id": ml.id, + "qty_done": ml.qty_done, + "quantity": ml.product_uom_qty, + "product": { + "id": ml.product_id.id, + "name": ml.product_id.name, + "display_name": ml.product_id.display_name, + "default_code": ml.product_id.default_code, + }, + "lot": {"id": ml.lot_id.id, "name": ml.lot_id.name}, + "package_src": { + "id": ml.package_id.id, + "name": ml.package_id.name, + "weight": ml.package_id.weight, + # TODO + "line_count": 0, + "package_type_name": ml.package_id.package_type_id.name, + }, + "package_dest": { + "id": ml.result_package_id.id, + "name": ml.result_package_id.name, + "weight": ml.result_package_id.weight, + # TODO + "line_count": 0, + "package_type_name": ml.result_package_id.package_type_id.name, + }, + "location_src": { + "id": ml.location_id.id, + "name": ml.location_id.name, + }, + "location_dest": { + "id": ml.location_dest_id.id, + "name": ml.location_dest_id.name, + }, + } + for ml in picking.move_line_ids + ], + } + + def test_scan_document_location_ok(self): + picking = self._create_picking() + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + + location = picking.location_id + # TODO set qty in loc for each move line + response = self.service.dispatch( + "scan_document", params={"barcode": location.barcode} + ) + self.assert_response( + response, next_state="select_line", data=self._stock_picking_data(picking) + ) From 5f318b7a432735f73e2865b37891c1a03a4e2e9e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 12 Mar 2020 16:10:53 +0100 Subject: [PATCH 142/940] tests: keep identical quantity for packages As we reserve a full pack, we can't put more qties --- shopfloor/tests/common.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index fe4dbeeba4..aa11815b48 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -119,10 +119,11 @@ def _fill_stock_for_moves(cls, moves, in_package=False, in_lot=False): lot = cls.env["stock.production.lot"].create( {"product_id": product.id, "company_id": cls.env.company.id} ) - else: + if not (in_lot or in_package): # always add more quantity in stock to avoid to trigger the # "zero checks" in tests, not for lots which must have a qty - # of 1 + # of 1 and not for packages because we need the strict number + # of units to pick a package qty *= 2 cls._update_qty_in_location( location, product, qty, package=package, lot=lot From f225845909caebc72a090b6b012bb6482c82312e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 12 Mar 2020 16:55:35 +0100 Subject: [PATCH 143/940] checkout: first passing test --- shopfloor/__manifest__.py | 10 ++++ shopfloor/actions/search.py | 3 ++ shopfloor/services/checkout.py | 87 +++++++++++++++++++++++++++++++- shopfloor/services/schema.py | 15 +++++- shopfloor/tests/test_checkout.py | 54 ++------------------ 5 files changed, 116 insertions(+), 53 deletions(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index ba49f92715..cc03632c6a 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -18,6 +18,16 @@ "auth_api_key", # https://github.com/OCA/stock-logistics-warehouse/pull/808 "stock_picking_completion_info", + # https://github.com/OCA/wms/pull/12 + "stock_storage_type", + # https://github.com/OCA/wms/pull/13 + "stock_storage_type_putaway_strategy", + # https://github.com/OCA/stock-logistics-warehouse/pull/855 + "stock_location_children", + # https://github.com/OCA/stock-logistics-workflow/pull/608 + "stock_quant_package_dimension", + # https://github.com/OCA/stock-logistics-workflow/pull/607 + "stock_quant_package_product_packaging", ], "data": [ "security/ir.model.access.csv", diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py index 706b6c057e..3878d0b8c5 100644 --- a/shopfloor/actions/search.py +++ b/shopfloor/actions/search.py @@ -17,3 +17,6 @@ def location_from_scan(self, barcode): def package_from_scan(self, barcode): return self.env["stock.quant.package"].search([("name", "=", barcode)]) + + def stock_picking_from_scan(self, barcode): + return self.env["stock.picking"].search([("name", "=", barcode)]) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 0e7eba6bb7..ed8b944a64 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -1,3 +1,5 @@ +from odoo import _ + from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -55,7 +57,90 @@ def scan_document(self, barcode): * summary: stock.picking is selected and all its lines have a destination pack set """ - return self._response() + search = self.actions_for("search") + picking = search.stock_picking_from_scan(barcode) + # TODO find from location, etc + # TODO better error message when not available + if picking and picking.state == "assigned": + return self._response_for_selected_stock_picking(picking) + else: + return self._response_for_no_stock_picking_found(barcode) + + def _response_for_selected_stock_picking(self, picking): + # TODO if all lines have a dest package set, go to summary + return self._response( + next_state="select_line", data=self._data_for_stock_picking(picking) + ) + + def _response_for_no_stock_picking_found(self, barcode): + return self._response( + next_state="select_document", + message={ + "message_type": "error", + "message": _("No transfer found for barcode {}").format(barcode), + }, + ) + + def _data_for_stock_picking(self, picking): + return { + "picking": { + "id": picking.id, + "name": picking.name, + "origin": picking.origin or "", + "note": picking.note or "", + "move_lines": [ + { + "id": ml.id, + "qty_done": ml.qty_done, + "quantity": ml.product_uom_qty, + "product": { + "id": ml.product_id.id, + "name": ml.product_id.name, + "display_name": ml.product_id.display_name, + "default_code": ml.product_id.default_code or "", + }, + "lot": {"id": ml.lot_id.id, "name": ml.lot_id.name} + if ml.lot_id + else None, + "package_src": { + "id": ml.package_id.id, + "name": ml.package_id.name, + # TODO + "weight": 0, + # TODO + "line_count": 0, + "package_type_name": ( + ml.package_id.package_storage_type_id.name or "" + ), + } + if ml.package_id + else None, + "package_dest": { + "id": ml.result_package_id.id, + "name": ml.result_package_id.name, + # TODO + "weight": 0, + # TODO + "line_count": 0, + "package_type_name": ( + ml.result_package_id.package_storage_type_id.name or "" + ), + } + if ml.result_package_id + else None, + "location_src": { + "id": ml.location_id.id, + "name": ml.location_id.name, + }, + "location_dest": { + "id": ml.location_dest_id.id, + "name": ml.location_dest_id.name, + }, + } + for ml in picking.move_line_ids + ], + } + } def list_stock_picking(self): """List stock.picking records available diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 5e58b1ffca..0bf392d65c 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -20,11 +20,22 @@ def move_line(self): "qty_done": {"type": "float", "required": True}, "quantity": {"type": "float", "required": True}, "product": {"type": "dict", "required": True, "schema": self.product()}, - "lot": {"type": "dict", "required": True, "schema": self.lot()}, - "package_src": {"type": "dict", "required": True, "schema": self.package()}, + "lot": { + "type": "dict", + "required": False, + "nullable": True, + "schema": self.lot(), + }, + "package_src": { + "type": "dict", + "required": True, + "nullable": True, + "schema": self.package(), + }, "package_dest": { "type": "dict", "required": True, + "nullable": True, "schema": self.package(), }, "location_src": { diff --git a/shopfloor/tests/test_checkout.py b/shopfloor/tests/test_checkout.py index 2c6d840bef..52cb06c529 100644 --- a/shopfloor/tests/test_checkout.py +++ b/shopfloor/tests/test_checkout.py @@ -17,6 +17,7 @@ def setUpClass(cls, *args, **kwargs): cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id + cls.wh.delivery_steps = "pick_pack_ship" cls.picking_type = cls.process.picking_type_id def setUp(self): @@ -40,61 +41,14 @@ def _create_picking(self): return picking def _stock_picking_data(self, picking): - return { - "id": picking.id, - "name": picking.name, - "origin": picking.origin, - "note": picking.note, - "move_lines": [ - { - "id": ml.id, - "qty_done": ml.qty_done, - "quantity": ml.product_uom_qty, - "product": { - "id": ml.product_id.id, - "name": ml.product_id.name, - "display_name": ml.product_id.display_name, - "default_code": ml.product_id.default_code, - }, - "lot": {"id": ml.lot_id.id, "name": ml.lot_id.name}, - "package_src": { - "id": ml.package_id.id, - "name": ml.package_id.name, - "weight": ml.package_id.weight, - # TODO - "line_count": 0, - "package_type_name": ml.package_id.package_type_id.name, - }, - "package_dest": { - "id": ml.result_package_id.id, - "name": ml.result_package_id.name, - "weight": ml.result_package_id.weight, - # TODO - "line_count": 0, - "package_type_name": ml.result_package_id.package_type_id.name, - }, - "location_src": { - "id": ml.location_id.id, - "name": ml.location_id.name, - }, - "location_dest": { - "id": ml.location_dest_id.id, - "name": ml.location_dest_id.name, - }, - } - for ml in picking.move_line_ids - ], - } + return self.service._data_for_stock_picking(picking) - def test_scan_document_location_ok(self): + def test_scan_document_stock_picking_ok(self): picking = self._create_picking() self._fill_stock_for_moves(picking.move_lines, in_package=True) picking.action_assign() - - location = picking.location_id - # TODO set qty in loc for each move line response = self.service.dispatch( - "scan_document", params={"barcode": location.barcode} + "scan_document", params={"barcode": picking.name} ) self.assert_response( response, next_state="select_line", data=self._stock_picking_data(picking) From ce16640583deea7b719e5fbc041a0910613bbeb4 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 13 Mar 2020 12:19:50 +0100 Subject: [PATCH 144/940] checkout: implement /scan_document --- shopfloor/actions/message.py | 9 ++ shopfloor/models/stock_location.py | 6 +- shopfloor/services/checkout.py | 69 ++++++++++-- shopfloor/tests/__init__.py | 3 +- shopfloor/tests/test_checkout.py | 55 ---------- shopfloor/tests/test_checkout_base.py | 30 ++++++ shopfloor/tests/test_checkout_scan.py | 146 ++++++++++++++++++++++++++ 7 files changed, 254 insertions(+), 64 deletions(-) delete mode 100644 shopfloor/tests/test_checkout.py create mode 100644 shopfloor/tests/test_checkout_base.py create mode 100644 shopfloor/tests/test_checkout_scan.py diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index f338c46658..8c5e519ee2 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -89,6 +89,9 @@ def no_location_found(self): "message": _("No location found for this barcode."), } + def location_not_allowed(self): + return {"message_type": "error", "message": _("Location not allowed here.")} + def dest_location_not_allowed(self): return {"message_type": "error", "message": _("You cannot place it here")} @@ -166,3 +169,9 @@ def x_units_put_in_package(self, qty, product, package): qty, product.display_name, package.name ), } + + def cannot_move_something_in_picking_type(self): + return { + "message_type": "error", + "message": _("You cannot move this using this menu."), + } diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py index 6ff8d3a4e8..4e391e629d 100644 --- a/shopfloor/models/stock_location.py +++ b/shopfloor/models/stock_location.py @@ -1,9 +1,13 @@ -from odoo import models +from odoo import fields, models class StockLocation(models.Model): _inherit = "stock.location" + source_move_line_ids = fields.One2many( + comodel_name="stock.move.line", inverse_name="location_id", readonly=True + ) + def is_sublocation_of(self, other): self.ensure_one() return bool( diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index ed8b944a64..48447c8639 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -59,12 +59,34 @@ def scan_document(self, barcode): """ search = self.actions_for("search") picking = search.stock_picking_from_scan(barcode) - # TODO find from location, etc - # TODO better error message when not available - if picking and picking.state == "assigned": + if not picking: + location = search.location_from_scan(barcode) + if location: + if not location.is_sublocation_of( + self.picking_type.default_location_src_id + ): + return self._response_for_scan_location_not_allowed() + lines = location.source_move_line_ids + pickings = lines.mapped("picking_id") + if len(pickings) == 1: + picking = pickings + else: + return self._response_for_several_stock_picking_found() + if not picking: + package = search.package_from_scan(barcode) + if package: + lines = package.planned_move_line_ids + pickings = lines.mapped("picking_id") + if len(pickings) == 1: + picking = pickings + if not picking: + return self._response_for_no_stock_picking_found() + if picking: + if picking.picking_type_id != self.picking_type: + return self._response_for_scan_picking_type_not_allowed() + if picking.state != "assigned": + return self._response_for_picking_not_assigned(picking) return self._response_for_selected_stock_picking(picking) - else: - return self._response_for_no_stock_picking_found(barcode) def _response_for_selected_stock_picking(self, picking): # TODO if all lines have a dest package set, go to summary @@ -72,15 +94,48 @@ def _response_for_selected_stock_picking(self, picking): next_state="select_line", data=self._data_for_stock_picking(picking) ) - def _response_for_no_stock_picking_found(self, barcode): + def _response_for_picking_not_assigned(self, picking): return self._response( next_state="select_document", message={ "message_type": "error", - "message": _("No transfer found for barcode {}").format(barcode), + "message": _("Transfer {} is not entirely available.").format( + picking.name + ), }, ) + def _response_for_several_stock_picking_found(self): + return self._response( + next_state="select_document", + message={ + "message_type": "error", + "message": _( + "Several transfers found, please scan a package" + " or select a transfer manually." + ), + }, + ) + + def _response_for_scan_picking_type_not_allowed(self): + message = self.actions_for("message") + return self._response( + next_state="select_document", + message=message.cannot_move_something_in_picking_type(), + ) + + def _response_for_scan_location_not_allowed(self): + message = self.actions_for("message") + return self._response( + next_state="select_document", message=message.location_not_allowed() + ) + + def _response_for_no_stock_picking_found(self): + message = self.actions_for("message") + return self._response( + next_state="select_document", message=message.barcode_not_found() + ) + def _data_for_stock_picking(self, picking): return { "picking": { diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 8e179399c0..598176820c 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -9,4 +9,5 @@ from . import test_cluster_picking_scan from . import test_cluster_picking_skip from . import test_cluster_picking_unload -from . import test_checkout +from . import test_checkout_base +from . import test_checkout_scan diff --git a/shopfloor/tests/test_checkout.py b/shopfloor/tests/test_checkout.py deleted file mode 100644 index 52cb06c529..0000000000 --- a/shopfloor/tests/test_checkout.py +++ /dev/null @@ -1,55 +0,0 @@ -from odoo.tests.common import Form - -from .common import CommonCase - - -class CheckoutCase(CommonCase): - @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - cls.product_a = cls.env["product.product"].create( - {"name": "Product A", "type": "product"} - ) - cls.product_b = cls.env["product.product"].create( - {"name": "Product B", "type": "product"} - ) - cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout") - cls.process = cls.menu.process_id - cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") - cls.wh = cls.profile.warehouse_id - cls.wh.delivery_steps = "pick_pack_ship" - cls.picking_type = cls.process.picking_type_id - - def setUp(self): - super().setUp() - with self.work_on_services(menu=self.menu, profile=self.profile) as work: - self.service = work.component(usage="checkout") - - def test_to_openapi(self): - # will raise if it fails to generate the openapi specs - self.service.to_openapi() - - def _create_picking(self): - picking_form = Form(self.env["stock.picking"]) - picking_form.picking_type_id = self.picking_type - # TODO we must have packages in origin - with picking_form.move_ids_without_package.new() as move: - move.product_id = self.product_a - move.product_uom_qty = 1 - picking = picking_form.save() - picking.action_confirm() - return picking - - def _stock_picking_data(self, picking): - return self.service._data_for_stock_picking(picking) - - def test_scan_document_stock_picking_ok(self): - picking = self._create_picking() - self._fill_stock_for_moves(picking.move_lines, in_package=True) - picking.action_assign() - response = self.service.dispatch( - "scan_document", params={"barcode": picking.name} - ) - self.assert_response( - response, next_state="select_line", data=self._stock_picking_data(picking) - ) diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py new file mode 100644 index 0000000000..540deed801 --- /dev/null +++ b/shopfloor/tests/test_checkout_base.py @@ -0,0 +1,30 @@ +from .common import CommonCase + + +class CheckoutCommonCase(CommonCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.product_a = cls.env["product.product"].create( + {"name": "Product A", "type": "product"} + ) + cls.product_b = cls.env["product.product"].create( + {"name": "Product B", "type": "product"} + ) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout") + cls.process = cls.menu.process_id + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + cls.wh = cls.profile.warehouse_id + cls.wh.delivery_steps = "pick_pack_ship" + cls.picking_type = cls.process.picking_type_id + + def setUp(self): + super().setUp() + with self.work_on_services(menu=self.menu, profile=self.profile) as work: + self.service = work.component(usage="checkout") + + +class CheckoutOpenAPICase(CheckoutCommonCase): + def test_to_openapi(self): + # will raise if it fails to generate the openapi specs + self.service.to_openapi() diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py new file mode 100644 index 0000000000..f87ff580f8 --- /dev/null +++ b/shopfloor/tests/test_checkout_scan.py @@ -0,0 +1,146 @@ +from odoo.tests.common import Form + +from .test_checkout_base import CheckoutCommonCase + + +class CheckoutScanCase(CheckoutCommonCase): + def _create_picking(self, picking_type=None): + picking_form = Form(self.env["stock.picking"]) + picking_form.picking_type_id = picking_type or self.picking_type + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.product_a + move.product_uom_qty = 10 + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.product_b + move.product_uom_qty = 10 + picking = picking_form.save() + picking.action_confirm() + return picking + + def _stock_picking_data(self, picking): + return self.service._data_for_stock_picking(picking) + + def _test_scan_ok(self, barcode_func): + picking = self._create_picking() + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + barcode = barcode_func(picking) + response = self.service.dispatch("scan_document", params={"barcode": barcode}) + self.assert_response( + response, next_state="select_line", data=self._stock_picking_data(picking) + ) + + def test_scan_document_stock_picking_ok(self): + self._test_scan_ok(lambda picking: picking.name) + + def test_scan_document_location_ok(self): + self._test_scan_ok(lambda picking: picking.move_line_ids.location_id.barcode) + + def test_scan_document_package_ok(self): + self._test_scan_ok(lambda picking: picking.move_line_ids.package_id.name) + + def test_scan_document_error_not_found(self): + response = self.service.dispatch("scan_document", params={"barcode": "NOPE"}) + self.assert_response( + response, + next_state="select_document", + message={"message_type": "error", "message": "Barcode not found"}, + ) + + def _test_scan_document_error_not_available(self, barcode_func): + picking = self._create_picking() + # in this test, we want the picking not to be available, but + # if we leave the shipping policy to direct, a single move assigned + # would make the picking available + picking.move_type = "one" + # the picking will have one line available, so the endpoint can find + # something from a location or package but should reject the picking as + # it is not entirely available + self._fill_stock_for_moves(picking.move_lines[0], in_package=True) + picking.action_assign() + barcode = barcode_func(picking) + response = self.service.dispatch("scan_document", params={"barcode": barcode}) + self.assert_response( + response, + next_state="select_document", + message={ + "message_type": "error", + "message": "Transfer {} is not entirely available.".format( + picking.name + ), + }, + ) + + def test_scan_document_error_not_available_picking(self): + self._test_scan_document_error_not_available(lambda picking: picking.name) + + def test_scan_document_error_not_available_location(self): + self._test_scan_document_error_not_available( + lambda picking: picking.move_line_ids.location_id.barcode + ) + + def test_scan_document_error_not_available_package(self): + self._test_scan_document_error_not_available( + lambda picking: picking.move_line_ids.package_id.name + ) + + def test_scan_document_error_location_not_child_of_type(self): + picking = self._create_picking() + picking.location_id = self.dispatch_location + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + response = self.service.dispatch( + "scan_document", params={"barcode": picking.location_id.barcode} + ) + self.assert_response( + response, + next_state="select_document", + message={"message_type": "error", "message": "Location not allowed here."}, + ) + + def _test_scan_document_error_different_picking_type(self, barcode_func): + picking = self._create_picking(picking_type=self.wh.pick_type_id) + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + barcode = barcode_func(picking) + response = self.service.dispatch("scan_document", params={"barcode": barcode}) + self.assert_response( + response, + next_state="select_document", + message={ + "message_type": "error", + "message": "You cannot move this using this menu.", + }, + ) + + def test_scan_document_error_different_picking_type_picking(self): + self._test_scan_document_error_different_picking_type( + lambda picking: picking.name + ) + + def test_scan_document_error_different_picking_type_package(self): + self._test_scan_document_error_different_picking_type( + lambda picking: picking.move_line_ids.package_id.name + ) + + def test_scan_document_error_location_several_pickings(self): + picking = self._create_picking() + # create a second picking at the same place so we don't + # know which picking to use + picking2 = self._create_picking() + pickings = picking | picking2 + self._fill_stock_for_moves(pickings.move_lines, in_package=True) + pickings.action_assign() + response = self.service.dispatch( + "scan_document", + params={"barcode": picking.move_line_ids.location_id.barcode}, + ) + self.assert_response( + response, + next_state="select_document", + message={ + "message_type": "error", + "message": "Several transfers found, please scan a package" + " or select a transfer manually.", + }, + ) From b9c079661c920510f7fc874cddddd83d0f15b196 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 13 Mar 2020 14:08:01 +0100 Subject: [PATCH 145/940] checkout: implement /list_stock_picking --- shopfloor/services/checkout.py | 68 ++++++++++++++++++++++++- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_checkout_base.py | 19 +++++++ shopfloor/tests/test_checkout_scan.py | 18 ------- shopfloor/tests/test_checkout_select.py | 37 ++++++++++++++ 5 files changed, 123 insertions(+), 20 deletions(-) create mode 100644 shopfloor/tests/test_checkout_select.py diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 48447c8639..b6f1bd5b78 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -143,6 +143,7 @@ def _data_for_stock_picking(self, picking): "name": picking.name, "origin": picking.origin or "", "note": picking.note or "", + # TODO add partner "move_lines": [ { "id": ml.id, @@ -197,6 +198,15 @@ def _data_for_stock_picking(self, picking): } } + def _domain_for_list_stock_picking(self): + return [ + ("state", "=", "assigned"), + ("picking_type_id", "=", self.picking_type.id), + ] + + def _order_for_list_stock_picking(self): + return "scheduled_date desc, id asc" + def list_stock_picking(self): """List stock.picking records available @@ -206,7 +216,26 @@ def list_stock_picking(self): Transitions: * manual_selection: to the selection screen """ - return self._response() + pickings = self.env["stock.picking"].search( + self._domain_for_list_stock_picking(), + order=self._order_for_list_stock_picking(), + ) + data = { + "pickings": [self._data_picking_for_list(picking) for picking in pickings] + } + return self._response(next_state="manual_selection", data=data) + + def _data_picking_for_list(self, picking): + return { + "id": picking.id, + "name": picking.name, + "origin": picking.origin or "", + "note": picking.note or "", + "line_count": len(picking.move_line_ids), + "partner": {"id": picking.partner_id.id, "name": picking.partner_id.name} + if picking.partner_id + else None, + } def select(self, picking_id): """Select a stock picking for the process @@ -590,7 +619,7 @@ def _states(self): """ return { "select_document": {}, - "manual_selection": {}, + "manual_selection": self._schema_selection_list, "select_line": self._schema_stock_picking_details, "select_package": self._schema_selected_lines, "change_quantity": self._schema_selected_lines, @@ -621,6 +650,41 @@ def _schema_stock_picking_details(self): } } + @property + def _schema_selection_list(self): + return { + "pickings": { + "type": "list", + "schema": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "origin": { + "type": "string", + "nullable": True, + "required": True, + }, + "note": {"type": "string", "nullable": True, "required": True}, + "line_count": {"type": "integer", "required": True}, + "partner": { + "type": "dict", + "nullable": True, + "required": True, + "schema": { + "id": {"required": True, "type": "integer"}, + "name": { + "type": "string", + "nullable": False, + "required": True, + }, + }, + }, + }, + }, + } + } + @property def _schema_select_package(self): return { diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 598176820c..693366f049 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -11,3 +11,4 @@ from . import test_cluster_picking_unload from . import test_checkout_base from . import test_checkout_scan +from . import test_checkout_select diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index 540deed801..1c5848ce4a 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -1,3 +1,5 @@ +from odoo.tests.common import Form + from .common import CommonCase @@ -23,6 +25,23 @@ def setUp(self): with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="checkout") + def _create_picking(self, picking_type=None): + picking_form = Form(self.env["stock.picking"]) + picking_form.picking_type_id = picking_type or self.picking_type + picking_form.partner_id = self.customer + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.product_a + move.product_uom_qty = 10 + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.product_b + move.product_uom_qty = 10 + picking = picking_form.save() + picking.action_confirm() + return picking + + def _stock_picking_data(self, picking): + return self.service._data_for_stock_picking(picking) + class CheckoutOpenAPICase(CheckoutCommonCase): def test_to_openapi(self): diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py index f87ff580f8..98a1e6d2f4 100644 --- a/shopfloor/tests/test_checkout_scan.py +++ b/shopfloor/tests/test_checkout_scan.py @@ -1,25 +1,7 @@ -from odoo.tests.common import Form - from .test_checkout_base import CheckoutCommonCase class CheckoutScanCase(CheckoutCommonCase): - def _create_picking(self, picking_type=None): - picking_form = Form(self.env["stock.picking"]) - picking_form.picking_type_id = picking_type or self.picking_type - with picking_form.move_ids_without_package.new() as move: - move.product_id = self.product_a - move.product_uom_qty = 10 - with picking_form.move_ids_without_package.new() as move: - move.product_id = self.product_b - move.product_uom_qty = 10 - picking = picking_form.save() - picking.action_confirm() - return picking - - def _stock_picking_data(self, picking): - return self.service._data_for_stock_picking(picking) - def _test_scan_ok(self, barcode_func): picking = self._create_picking() self._fill_stock_for_moves(picking.move_lines, in_package=True) diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py new file mode 100644 index 0000000000..21fab504a8 --- /dev/null +++ b/shopfloor/tests/test_checkout_select.py @@ -0,0 +1,37 @@ +from .test_checkout_base import CheckoutCommonCase + + +class CheckoutLisStockPickingCase(CheckoutCommonCase): + def test_list_stock_picking(self): + picking1 = self._create_picking() + picking2 = self._create_picking() + # should not be in the list because another type: + picking3 = self._create_picking(picking_type=self.wh.pick_type_id) + # should not be in list because not assigned: + self._create_picking() + to_assign = picking1 | picking2 | picking3 + self._fill_stock_for_moves(to_assign.move_lines, in_package=True) + to_assign.action_assign() + response = self.service.dispatch("list_stock_picking", params={}) + expected = { + "pickings": [ + { + "id": picking2.id, + "line_count": len(picking2.move_line_ids), + "name": picking2.name, + "note": "", + "origin": "", + "partner": {"id": self.customer.id, "name": self.customer.name}, + }, + { + "id": picking1.id, + "line_count": len(picking1.move_line_ids), + "name": picking1.name, + "note": "", + "origin": "", + "partner": {"id": self.customer.id, "name": self.customer.name}, + }, + ] + } + + self.assert_response(response, next_state="manual_selection", data=expected) From a8740bc07948bf297ab0d7c4140e6b1c89a2a160 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 13 Mar 2020 16:04:13 +0100 Subject: [PATCH 146/940] fixup! checkout: implement /list_stock_picking --- shopfloor/services/checkout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index b6f1bd5b78..e53768970e 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -205,7 +205,7 @@ def _domain_for_list_stock_picking(self): ] def _order_for_list_stock_picking(self): - return "scheduled_date desc, id asc" + return "scheduled_date asc, id asc" def list_stock_picking(self): """List stock.picking records available From fd2ae98ee92919a0b3908233bd8f0e2e06651721 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 13 Mar 2020 16:04:59 +0100 Subject: [PATCH 147/940] checkout: implement /select --- shopfloor/services/checkout.py | 33 ++++++++++++++----------- shopfloor/tests/test_checkout_select.py | 25 ++++++++++++++----- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index e53768970e..22aa97c3c3 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -79,24 +79,26 @@ def scan_document(self, barcode): pickings = lines.mapped("picking_id") if len(pickings) == 1: picking = pickings + return self._select_picking(picking, "select_document") + + def _select_picking(self, picking, state_for_error): if not picking: - return self._response_for_no_stock_picking_found() - if picking: - if picking.picking_type_id != self.picking_type: - return self._response_for_scan_picking_type_not_allowed() - if picking.state != "assigned": - return self._response_for_picking_not_assigned(picking) - return self._response_for_selected_stock_picking(picking) + return self._response_for_no_stock_picking_found(state_for_error) + if picking.picking_type_id != self.picking_type: + return self._response_for_scan_picking_type_not_allowed(state_for_error) + if picking.state != "assigned": + return self._response_for_picking_not_assigned(picking, state_for_error) + # TODO if all lines have a dest package set, go to summary + return self._response_for_selected_stock_picking(picking) def _response_for_selected_stock_picking(self, picking): - # TODO if all lines have a dest package set, go to summary return self._response( next_state="select_line", data=self._data_for_stock_picking(picking) ) - def _response_for_picking_not_assigned(self, picking): + def _response_for_picking_not_assigned(self, picking, next_state): return self._response( - next_state="select_document", + next_state=next_state, message={ "message_type": "error", "message": _("Transfer {} is not entirely available.").format( @@ -117,10 +119,10 @@ def _response_for_several_stock_picking_found(self): }, ) - def _response_for_scan_picking_type_not_allowed(self): + def _response_for_scan_picking_type_not_allowed(self, next_state): message = self.actions_for("message") return self._response( - next_state="select_document", + next_state=next_state, message=message.cannot_move_something_in_picking_type(), ) @@ -130,10 +132,10 @@ def _response_for_scan_location_not_allowed(self): next_state="select_document", message=message.location_not_allowed() ) - def _response_for_no_stock_picking_found(self): + def _response_for_no_stock_picking_found(self, next_state): message = self.actions_for("message") return self._response( - next_state="select_document", message=message.barcode_not_found() + next_state=next_state, message=message.barcode_not_found() ) def _data_for_stock_picking(self, picking): @@ -255,7 +257,8 @@ def select(self, picking_id): * select_line: the "normal" case, when the user has to put in pack/move lines """ - return self._response() + picking = self.env["stock.picking"].browse(picking_id) + return self._select_picking(picking, "manual_selection") def scan_line(self, picking_id, barcode): """Scan move lines of the stock picking diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py index 21fab504a8..d860e45c3b 100644 --- a/shopfloor/tests/test_checkout_select.py +++ b/shopfloor/tests/test_checkout_select.py @@ -16,17 +16,17 @@ def test_list_stock_picking(self): expected = { "pickings": [ { - "id": picking2.id, - "line_count": len(picking2.move_line_ids), - "name": picking2.name, + "id": picking1.id, + "line_count": len(picking1.move_line_ids), + "name": picking1.name, "note": "", "origin": "", "partner": {"id": self.customer.id, "name": self.customer.name}, }, { - "id": picking1.id, - "line_count": len(picking1.move_line_ids), - "name": picking1.name, + "id": picking2.id, + "line_count": len(picking2.move_line_ids), + "name": picking2.name, "note": "", "origin": "", "partner": {"id": self.customer.id, "name": self.customer.name}, @@ -35,3 +35,16 @@ def test_list_stock_picking(self): } self.assert_response(response, next_state="manual_selection", data=expected) + + +class CheckoutSelectCase(CheckoutCommonCase): + def test_select_ok(self): + picking = self._create_picking() + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + response = self.service.dispatch("select", params={"picking_id": picking.id}) + self.assert_response( + response, next_state="select_line", data=self._stock_picking_data(picking) + ) + + # TODO error cases and go to summary From c0fd09084502aa5b55f419cd893e8700e1fa3c3d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 16 Mar 2020 11:45:37 +0100 Subject: [PATCH 148/940] checkout: handle exceptions --- shopfloor/actions/message.py | 6 +++ shopfloor/services/checkout.py | 46 +++++++++++++-------- shopfloor/tests/test_checkout_base.py | 13 +++--- shopfloor/tests/test_checkout_scan.py | 4 +- shopfloor/tests/test_checkout_select.py | 53 ++++++++++++++++++++++--- 5 files changed, 91 insertions(+), 31 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 8c5e519ee2..1e9f416b92 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -175,3 +175,9 @@ def cannot_move_something_in_picking_type(self): "message_type": "error", "message": _("You cannot move this using this menu."), } + + def stock_picking_not_available(self, picking): + return { + "message_type": "error", + "message": _("Transfer {} is not available.").format(picking.name), + } diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 22aa97c3c3..94ba08b2a4 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -82,12 +82,25 @@ def scan_document(self, barcode): return self._select_picking(picking, "select_document") def _select_picking(self, picking, state_for_error): + message = self.actions_for("message") if not picking: - return self._response_for_no_stock_picking_found(state_for_error) + if state_for_error == "manual_selection": + return self._response_for_manual_selection( + message=message.operation_not_found() + ) + return self._response_for_barcode_no_stock_picking_found() if picking.picking_type_id != self.picking_type: - return self._response_for_scan_picking_type_not_allowed(state_for_error) + if state_for_error == "manual_selection": + return self._response_for_manual_selection( + message=message.cannot_move_something_in_picking_type() + ) + return self._response_for_scan_picking_type_not_allowed() if picking.state != "assigned": - return self._response_for_picking_not_assigned(picking, state_for_error) + if state_for_error == "manual_selection": + return self._response_for_manual_selection( + message=message.stock_picking_not_available(picking) + ) + return self._response_for_picking_not_assigned(picking) # TODO if all lines have a dest package set, go to summary return self._response_for_selected_stock_picking(picking) @@ -96,15 +109,11 @@ def _response_for_selected_stock_picking(self, picking): next_state="select_line", data=self._data_for_stock_picking(picking) ) - def _response_for_picking_not_assigned(self, picking, next_state): + def _response_for_picking_not_assigned(self, picking): + message = self.actions_for("message") return self._response( - next_state=next_state, - message={ - "message_type": "error", - "message": _("Transfer {} is not entirely available.").format( - picking.name - ), - }, + next_state="select_document", + message=message.stock_picking_not_available(picking), ) def _response_for_several_stock_picking_found(self): @@ -119,10 +128,10 @@ def _response_for_several_stock_picking_found(self): }, ) - def _response_for_scan_picking_type_not_allowed(self, next_state): + def _response_for_scan_picking_type_not_allowed(self): message = self.actions_for("message") return self._response( - next_state=next_state, + next_state="select_document", message=message.cannot_move_something_in_picking_type(), ) @@ -132,10 +141,10 @@ def _response_for_scan_location_not_allowed(self): next_state="select_document", message=message.location_not_allowed() ) - def _response_for_no_stock_picking_found(self, next_state): + def _response_for_barcode_no_stock_picking_found(self): message = self.actions_for("message") return self._response( - next_state=next_state, message=message.barcode_not_found() + next_state="select_document", message=message.barcode_not_found() ) def _data_for_stock_picking(self, picking): @@ -218,6 +227,9 @@ def list_stock_picking(self): Transitions: * manual_selection: to the selection screen """ + return self._response_for_manual_selection() + + def _response_for_manual_selection(self, message=None): pickings = self.env["stock.picking"].search( self._domain_for_list_stock_picking(), order=self._order_for_list_stock_picking(), @@ -225,7 +237,7 @@ def list_stock_picking(self): data = { "pickings": [self._data_picking_for_list(picking) for picking in pickings] } - return self._response(next_state="manual_selection", data=data) + return self._response(next_state="manual_selection", data=data, message=message) def _data_picking_for_list(self, picking): return { @@ -257,7 +269,7 @@ def select(self, picking_id): * select_line: the "normal" case, when the user has to put in pack/move lines """ - picking = self.env["stock.picking"].browse(picking_id) + picking = self.env["stock.picking"].browse(picking_id).exists() return self._select_picking(picking, "manual_selection") def scan_line(self, picking_id, barcode): diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index 1c5848ce4a..77accd1a2d 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -25,15 +25,16 @@ def setUp(self): with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="checkout") - def _create_picking(self, picking_type=None): - picking_form = Form(self.env["stock.picking"]) - picking_form.picking_type_id = picking_type or self.picking_type - picking_form.partner_id = self.customer + @classmethod + def _create_picking(cls, picking_type=None): + picking_form = Form(cls.env["stock.picking"]) + picking_form.picking_type_id = picking_type or cls.picking_type + picking_form.partner_id = cls.customer with picking_form.move_ids_without_package.new() as move: - move.product_id = self.product_a + move.product_id = cls.product_a move.product_uom_qty = 10 with picking_form.move_ids_without_package.new() as move: - move.product_id = self.product_b + move.product_id = cls.product_b move.product_uom_qty = 10 picking = picking_form.save() picking.action_confirm() diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py index 98a1e6d2f4..017ddf57e1 100644 --- a/shopfloor/tests/test_checkout_scan.py +++ b/shopfloor/tests/test_checkout_scan.py @@ -47,9 +47,7 @@ def _test_scan_document_error_not_available(self, barcode_func): next_state="select_document", message={ "message_type": "error", - "message": "Transfer {} is not entirely available.".format( - picking.name - ), + "message": "Transfer {} is not available.".format(picking.name), }, ) diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py index d860e45c3b..bc37d3fc2c 100644 --- a/shopfloor/tests/test_checkout_select.py +++ b/shopfloor/tests/test_checkout_select.py @@ -38,13 +38,56 @@ def test_list_stock_picking(self): class CheckoutSelectCase(CheckoutCommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.picking = cls._create_picking() + cls._fill_stock_for_moves(cls.picking.move_lines, in_package=True) + cls.picking.action_assign() + def test_select_ok(self): - picking = self._create_picking() - self._fill_stock_for_moves(picking.move_lines, in_package=True) - picking.action_assign() + response = self.service.dispatch( + "select", params={"picking_id": self.picking.id} + ) + self.assert_response( + response, + next_state="select_line", + data=self._stock_picking_data(self.picking), + ) + + def _test_error(self, picking, msg): response = self.service.dispatch("select", params={"picking_id": picking.id}) self.assert_response( - response, next_state="select_line", data=self._stock_picking_data(picking) + response, + next_state="manual_selection", + message={"message_type": "error", "message": msg}, + data={ + "pickings": [ + { + "id": self.picking.id, + "line_count": len(self.picking.move_line_ids), + "name": self.picking.name, + "note": "", + "origin": "", + "partner": {"id": self.customer.id, "name": self.customer.name}, + } + ] + }, ) - # TODO error cases and go to summary + def test_select_error_not_found(self): + picking = self._create_picking() + picking.unlink() + self._test_error(picking, "This operation does not exist anymore.") + + def test_select_error_not_available(self): + picking = self._create_picking() + self._test_error(picking, "Transfer {} is not available.".format(picking.name)) + + def test_select_error_not_allowed(self): + picking = self._create_picking(picking_type=self.wh.pick_type_id) + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + self._test_error( + picking, "You cannot move this using this menu.".format(picking.name) + ) From a37af227c805bb1029c0f93c2288f8116b88b398 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 16 Mar 2020 15:33:14 +0100 Subject: [PATCH 149/940] checkout: implement /scan_line happy path --- shopfloor/actions/message.py | 6 + shopfloor/actions/search.py | 12 + shopfloor/services/checkout.py | 264 ++++++++++----------- shopfloor/services/schema.py | 18 ++ shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_app.py | 41 ++-- shopfloor/tests/test_checkout_base.py | 27 ++- shopfloor/tests/test_checkout_scan.py | 4 +- shopfloor/tests/test_checkout_scan_line.py | 109 +++++++++ shopfloor/tests/test_checkout_select.py | 6 +- shopfloor/tests/test_menu.py | 25 +- 11 files changed, 327 insertions(+), 186 deletions(-) create mode 100644 shopfloor/tests/test_checkout_scan_line.py diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 1e9f416b92..63434a6b5d 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -68,6 +68,12 @@ def operation_not_found(self): "message": _("This operation does not exist anymore."), } + def stock_picking_not_found(self): + return { + "message_type": "error", + "message": _("This transfer does not exist anymore."), + } + def record_not_found(self): return { "message_type": "error", diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py index 3878d0b8c5..c170963b1d 100644 --- a/shopfloor/actions/search.py +++ b/shopfloor/actions/search.py @@ -20,3 +20,15 @@ def package_from_scan(self, barcode): def stock_picking_from_scan(self, barcode): return self.env["stock.picking"].search([("name", "=", barcode)]) + + def product_from_scan(self, barcode): + product = self.env["product.product"].search([("barcode", "=", barcode)]) + if not product: + packaging = self.env["product.packaging"].search( + [("product_id", "!=", False), ("barcode", "=", barcode)] + ) + product = packaging.product_id + return product + + def lot_from_scan(self, barcode): + return self.env["stock.production.lot"].search([("name", "=", barcode)]) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 94ba08b2a4..76957097e8 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -86,7 +86,7 @@ def _select_picking(self, picking, state_for_error): if not picking: if state_for_error == "manual_selection": return self._response_for_manual_selection( - message=message.operation_not_found() + message=message.stock_picking_not_found() ) return self._response_for_barcode_no_stock_picking_found() if picking.picking_type_id != self.picking_type: @@ -106,7 +106,8 @@ def _select_picking(self, picking, state_for_error): def _response_for_selected_stock_picking(self, picking): return self._response( - next_state="select_line", data=self._data_for_stock_picking(picking) + next_state="select_line", + data={"picking": self._data_for_stock_picking(picking)}, ) def _response_for_picking_not_assigned(self, picking): @@ -147,68 +148,73 @@ def _response_for_barcode_no_stock_picking_found(self): next_state="select_document", message=message.barcode_not_found() ) - def _data_for_stock_picking(self, picking): + def _response_for_stock_picking_has_been_deleted(self): + message = self.actions_for("message") + return self._response( + next_state="select_document", message=message.stock_picking_not_found() + ) + + def _data_for_move_line(self, move_line): return { - "picking": { - "id": picking.id, - "name": picking.name, - "origin": picking.origin or "", - "note": picking.note or "", - # TODO add partner - "move_lines": [ - { - "id": ml.id, - "qty_done": ml.qty_done, - "quantity": ml.product_uom_qty, - "product": { - "id": ml.product_id.id, - "name": ml.product_id.name, - "display_name": ml.product_id.display_name, - "default_code": ml.product_id.default_code or "", - }, - "lot": {"id": ml.lot_id.id, "name": ml.lot_id.name} - if ml.lot_id - else None, - "package_src": { - "id": ml.package_id.id, - "name": ml.package_id.name, - # TODO - "weight": 0, - # TODO - "line_count": 0, - "package_type_name": ( - ml.package_id.package_storage_type_id.name or "" - ), - } - if ml.package_id - else None, - "package_dest": { - "id": ml.result_package_id.id, - "name": ml.result_package_id.name, - # TODO - "weight": 0, - # TODO - "line_count": 0, - "package_type_name": ( - ml.result_package_id.package_storage_type_id.name or "" - ), - } - if ml.result_package_id - else None, - "location_src": { - "id": ml.location_id.id, - "name": ml.location_id.name, - }, - "location_dest": { - "id": ml.location_dest_id.id, - "name": ml.location_dest_id.name, - }, - } - for ml in picking.move_line_ids - ], + "id": move_line.id, + "qty_done": move_line.qty_done, + "quantity": move_line.product_uom_qty, + "product": { + "id": move_line.product_id.id, + "name": move_line.product_id.name, + "display_name": move_line.product_id.display_name, + "default_code": move_line.product_id.default_code or "", + }, + "lot": {"id": move_line.lot_id.id, "name": move_line.lot_id.name} + if move_line.lot_id + else None, + "package_src": { + "id": move_line.package_id.id, + "name": move_line.package_id.name, + # TODO + "weight": 0, + # TODO + "line_count": 0, + "package_type_name": ( + move_line.package_id.package_storage_type_id.name or "" + ), } + if move_line.package_id + else None, + "package_dest": { + "id": move_line.result_package_id.id, + "name": move_line.result_package_id.name, + # TODO + "weight": 0, + # TODO + "line_count": 0, + "package_type_name": ( + move_line.result_package_id.package_storage_type_id.name or "" + ), + } + if move_line.result_package_id + else None, + "location_src": { + "id": move_line.location_id.id, + "name": move_line.location_id.name, + }, + "location_dest": { + "id": move_line.location_dest_id.id, + "name": move_line.location_dest_id.name, + }, } + def _data_for_stock_picking(self, picking): + data = self._data_picking_base(picking) + data.update( + { + "move_lines": [ + self._data_for_move_line(ml) for ml in picking.move_line_ids + ] + } + ) + return data + def _domain_for_list_stock_picking(self): return [ ("state", "=", "assigned"), @@ -234,12 +240,10 @@ def _response_for_manual_selection(self, message=None): self._domain_for_list_stock_picking(), order=self._order_for_list_stock_picking(), ) - data = { - "pickings": [self._data_picking_for_list(picking) for picking in pickings] - } + data = {"pickings": [self._data_picking_base(picking) for picking in pickings]} return self._response(next_state="manual_selection", data=data, message=message) - def _data_picking_for_list(self, picking): + def _data_picking_base(self, picking): return { "id": picking.id, "name": picking.name, @@ -272,6 +276,23 @@ def select(self, picking_id): picking = self.env["stock.picking"].browse(picking_id).exists() return self._select_picking(picking, "manual_selection") + def _response_for_select_package(self, lines): + picking = lines.mapped("picking_id") + return self._response( + next_state="select_package", + data={ + "selected_move_lines": [ + self._data_for_move_line(line) for line in lines + ], + "picking": self._data_picking_base(picking), + }, + ) + + def _select_scanned_lines(self, lines): + for line in lines: + line.qty_done = line.product_uom_qty + return self._response_for_select_package(lines) + def scan_line(self, picking_id, barcode): """Scan move lines of the stock picking @@ -291,6 +312,37 @@ def scan_line(self, picking_id, barcode): * select_package: lines are selected, user is redirected to this screen to change the qty done and destination pack if needed """ + picking = self.env["stock.picking"].browse(picking_id) + if not picking.exists(): + return self._response_stock_picking_does_not_exist() + + search = self.actions_for("search") + + package = search.package_from_scan(barcode) + if package: + lines = picking.move_line_ids.filtered(lambda l: l.package_id == package) + if not lines: + # TODO error package not in picking + pass + return self._select_scanned_lines(lines) + + product = search.product_from_scan(barcode) + if product: + # TODO product tracked by lot: must scan lot + lines = picking.move_line_ids.filtered(lambda l: l.product_id == product) + # TODO: if no lines: error + if len(lines.mapped("package_id")) > 1: + # TODO must scan package + pass + return self._select_scanned_lines(lines) + + lot = search.lot_from_scan(barcode) + if lot: + lines = picking.move_line_ids.filtered(lambda l: l.lot_id == lot) + # TODO: if no lines: error + return self._select_scanned_lines(lines) + + # TODO barcode not found return self._response() def select_line(self, picking_id, package_id=None, move_line_id=None): @@ -646,57 +698,23 @@ def _states(self): @property def _schema_stock_picking_details(self): - return { - "picking": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "origin": {"type": "string", "nullable": True, "required": True}, - "note": {"type": "string", "nullable": True, "required": True}, - "move_lines": { - "type": "list", - "schema": { - "type": "dict", - "schema": self.schemas().move_line(), - }, - }, - }, + schema = self.schemas().picking() + schema.update( + { + "move_lines": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas().move_line()}, + } } - } + ) + return {"picking": {"type": "dict", "schema": schema}} @property def _schema_selection_list(self): return { "pickings": { "type": "list", - "schema": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "origin": { - "type": "string", - "nullable": True, - "required": True, - }, - "note": {"type": "string", "nullable": True, "required": True}, - "line_count": {"type": "integer", "required": True}, - "partner": { - "type": "dict", - "nullable": True, - "required": True, - "schema": { - "id": {"required": True, "type": "integer"}, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, - }, - }, - }, - }, + "schema": {"type": "dict", "schema": self.schemas().picking()}, } } @@ -711,15 +729,7 @@ def _schema_select_package(self): "type": "list", "schema": {"type": "dict", "schema": self.schemas().package()}, }, - "picking": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "origin": {"type": "string", "nullable": True, "required": True}, - "note": {"type": "string", "nullable": True, "required": True}, - }, - }, + "picking": {"type": "dict", "schema": self.schemas().picking()}, } @property @@ -733,15 +743,7 @@ def _schema_select_package_type(self): "type": "list", "schema": {"type": "dict", "schema": self.schemas().package_type()}, }, - "picking": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "origin": {"type": "string", "nullable": True, "required": True}, - "note": {"type": "string", "nullable": True, "required": True}, - }, - }, + "picking": {"type": "dict", "schema": self.schemas().picking()}, } @property @@ -751,15 +753,7 @@ def _schema_selected_lines(self): "type": "list", "schema": {"type": "dict", "schema": self.schemas().move_line()}, }, - "picking": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "origin": {"type": "string", "nullable": True, "required": True}, - "note": {"type": "string", "nullable": True, "required": True}, - }, - }, + "picking": {"type": "dict", "schema": self.schemas().picking()}, } def scan_document(self): diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 0bf392d65c..63bede3dad 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -14,6 +14,24 @@ class BaseShopfloorSchemaResponse(Component): _usage = "schema" _is_rest_service_component = False + def picking(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "origin": {"type": "string", "nullable": True, "required": True}, + "note": {"type": "string", "nullable": True, "required": True}, + "line_count": {"type": "integer", "nullable": True, "required": True}, + "partner": { + "type": "dict", + "nullable": True, + "required": True, + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + } + def move_line(self): return { "id": {"type": "integer", "required": True}, diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 693366f049..e002c1d6cb 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -12,3 +12,4 @@ from . import test_checkout_base from . import test_checkout_scan from . import test_checkout_select +from . import test_checkout_scan_line diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py index e431ba588b..ff29c176a3 100644 --- a/shopfloor/tests/test_app.py +++ b/shopfloor/tests/test_app.py @@ -15,37 +15,32 @@ def test_user_config(self): """Request /app/user_config""" # Simulate the client asking the configuration response = self.service.dispatch("user_config") + menus = self.env["shopfloor.menu"].search([]) + profiles = self.env["shopfloor.profile"].search([]) self.assert_response( response, data={ "menus": [ { - "id": self.ANY, - "name": "Put-Away Reach Truck", - "process": {"id": self.ANY, "code": "single_pack_putaway"}, - }, - { - "id": self.ANY, - "name": "Single Pallet Transfer", - "process": {"id": self.ANY, "code": "single_pack_transfer"}, - }, - { - "id": self.ANY, - "name": "Cluster Picking", - "process": {"id": self.ANY, "code": "cluster_picking"}, - }, + "id": menu.id, + "name": menu.name, + "process": { + "id": menu.process_id.id, + "code": menu.process_id.code, + }, + } + for menu in menus ], "profiles": [ { - "id": self.ANY, - "name": "Highbay Truck", - "warehouse": {"id": self.ANY, "name": "YourCompany"}, - }, - { - "id": self.ANY, - "name": "Shelf 1", - "warehouse": {"id": self.ANY, "name": "YourCompany"}, - }, + "id": profile.id, + "name": profile.name, + "warehouse": { + "id": profile.warehouse_id.id, + "name": profile.warehouse_id.name, + }, + } + for profile in profiles ], }, ) diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index 77accd1a2d..2e489405c6 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -8,10 +8,16 @@ class CheckoutCommonCase(CommonCase): def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) cls.product_a = cls.env["product.product"].create( - {"name": "Product A", "type": "product"} + {"name": "Product A", "type": "product", "barcode": "product_a"} + ) + cls.product_a_packaging = cls.env["product.packaging"].create( + {"name": "Box", "product_id": cls.product_a.id, "barcode": "ProductABox"} ) cls.product_b = cls.env["product.product"].create( - {"name": "Product B", "type": "product"} + {"name": "Product B", "type": "product", "barcode": "product_b"} + ) + cls.product_b_packaging = cls.env["product.packaging"].create( + {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductBBox"} ) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout") cls.process = cls.menu.process_id @@ -26,16 +32,16 @@ def setUp(self): self.service = work.component(usage="checkout") @classmethod - def _create_picking(cls, picking_type=None): + def _create_picking(cls, picking_type=None, lines=None): picking_form = Form(cls.env["stock.picking"]) picking_form.picking_type_id = picking_type or cls.picking_type picking_form.partner_id = cls.customer - with picking_form.move_ids_without_package.new() as move: - move.product_id = cls.product_a - move.product_uom_qty = 10 - with picking_form.move_ids_without_package.new() as move: - move.product_id = cls.product_b - move.product_uom_qty = 10 + if lines is None: + lines = [(cls.product_a, 10), (cls.product_b, 10)] + for product, qty in lines: + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = qty picking = picking_form.save() picking.action_confirm() return picking @@ -43,6 +49,9 @@ def _create_picking(cls, picking_type=None): def _stock_picking_data(self, picking): return self.service._data_for_stock_picking(picking) + def _move_line_data(self, move_line): + return self.service._data_for_move_line(move_line) + class CheckoutOpenAPICase(CheckoutCommonCase): def test_to_openapi(self): diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py index 017ddf57e1..43ba2cc6dd 100644 --- a/shopfloor/tests/test_checkout_scan.py +++ b/shopfloor/tests/test_checkout_scan.py @@ -9,7 +9,9 @@ def _test_scan_ok(self, barcode_func): barcode = barcode_func(picking) response = self.service.dispatch("scan_document", params={"barcode": barcode}) self.assert_response( - response, next_state="select_line", data=self._stock_picking_data(picking) + response, + next_state="select_line", + data={"picking": self._stock_picking_data(picking)}, ) def test_scan_document_stock_picking_ok(self): diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py new file mode 100644 index 0000000000..d0adae6322 --- /dev/null +++ b/shopfloor/tests/test_checkout_scan_line.py @@ -0,0 +1,109 @@ +from .test_checkout_base import CheckoutCommonCase + + +class CheckoutScanLineCase(CheckoutCommonCase): + def _test_scan_line_ok(self, barcode, selected_lines): + picking = selected_lines.mapped("picking_id") + response = self.service.dispatch( + "scan_line", params={"picking_id": picking.id, "barcode": barcode} + ) + for line in selected_lines: + self.assertEqual( + line.qty_done, + line.product_uom_qty, + "Scanned lines must have their qty done set to the reserved quantity", + ) + self.assert_response( + response, + next_state="select_package", + data={ + "selected_move_lines": [ + self._move_line_data(ml) for ml in selected_lines + ], + "picking": { + "id": picking.id, + "name": picking.name, + "note": "", + "origin": "", + "line_count": 2, + "partner": {"id": self.customer.id, "name": self.customer.name}, + }, + }, + ) + + def test_scan_line_package_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10)] + ) + move1 = picking.move_lines[0] + move2 = picking.move_lines[1] + # put the lines in 2 separate packages (only the first line should be selected + # by the package barcode) + self._fill_stock_for_moves(move1, in_package=True) + self._fill_stock_for_moves(move2, in_package=True) + picking.action_assign() + move_line = move1.move_line_ids + self._test_scan_line_ok(move_line.package_id.name, move_line) + + def test_scan_line_package_several_lines_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10)] + ) + # put all the lines in the same source package + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + package = picking.move_line_ids.mapped("package_id") + self._test_scan_line_ok(package.name, picking.move_line_ids) + + def test_scan_line_product_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10)] + ) + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + line_a = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # we have 2 different products in the picking, we scan the first + # one and expect to select the line + self._test_scan_line_ok(self.product_a.barcode, line_a) + + def test_scan_line_product_several_lines_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_a, 10), (self.product_b, 10)] + ) + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + lines_a = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # expect to select all the lines with the scanned product, as long + # as they are in the same package + self._test_scan_line_ok(self.product_a.barcode, lines_a) + + def test_scan_line_product_packaging_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_a, 10), (self.product_b, 10)] + ) + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + lines_a = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # when we scan the packaging of the product, we should select the + # lines as if the product was scanned + self._test_scan_line_ok(self.product_a_packaging.barcode, lines_a) + + def test_scan_line_product_lot_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 1), (self.product_a, 1), (self.product_b, 1)] + ) + for move in picking.move_lines: + self._fill_stock_for_moves(move, in_lot=True) + picking.action_assign() + first_line = picking.move_line_ids[0] + lot = first_line.lot_id + self._test_scan_line_ok(lot.name, first_line) + + # TODO test 2 lines with product in different packages + # TODO test 2 lines with lots in different packages diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py index bc37d3fc2c..8393a9763c 100644 --- a/shopfloor/tests/test_checkout_select.py +++ b/shopfloor/tests/test_checkout_select.py @@ -1,7 +1,7 @@ from .test_checkout_base import CheckoutCommonCase -class CheckoutLisStockPickingCase(CheckoutCommonCase): +class CheckoutListStockPickingCase(CheckoutCommonCase): def test_list_stock_picking(self): picking1 = self._create_picking() picking2 = self._create_picking() @@ -52,7 +52,7 @@ def test_select_ok(self): self.assert_response( response, next_state="select_line", - data=self._stock_picking_data(self.picking), + data={"picking": self._stock_picking_data(self.picking)}, ) def _test_error(self, picking, msg): @@ -78,7 +78,7 @@ def _test_error(self, picking, msg): def test_select_error_not_found(self): picking = self._create_picking() picking.unlink() - self._test_error(picking, "This operation does not exist anymore.") + self._test_error(picking, "This transfer does not exist anymore.") def test_select_error_not_available(self): picking = self._create_picking() diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py index 98952c5c2e..685850002b 100644 --- a/shopfloor/tests/test_menu.py +++ b/shopfloor/tests/test_menu.py @@ -15,26 +15,21 @@ def test_menu_search(self): """Request /menu/search""" # Simulate the client searching menus response = self.service.dispatch("search") + menus = self.env["shopfloor.menu"].search([]) self.assert_response( response, data={ - "size": 3, + "size": len(menus), "records": [ { - "id": self.ANY, - "name": "Put-Away Reach Truck", - "process": {"id": self.ANY, "code": "single_pack_putaway"}, - }, - { - "id": self.ANY, - "name": "Single Pallet Transfer", - "process": {"id": self.ANY, "code": "single_pack_transfer"}, - }, - { - "id": self.ANY, - "name": "Cluster Picking", - "process": {"id": self.ANY, "code": "cluster_picking"}, - }, + "id": menu.id, + "name": menu.name, + "process": { + "id": menu.process_id.id, + "code": menu.process_id.code, + }, + } + for menu in menus ], }, ) From 30c3570d090708b4af9ae86802d26a259cc35ed4 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 17 Mar 2020 14:55:30 +0100 Subject: [PATCH 150/940] checkout: /scan_line handle errors --- shopfloor/actions/message.py | 16 ++ shopfloor/models/stock_move_line.py | 3 + shopfloor/services/checkout.py | 124 ++++++++++--- shopfloor/tests/common.py | 10 +- shopfloor/tests/test_checkout_base.py | 5 +- shopfloor/tests/test_checkout_scan_line.py | 197 ++++++++++++++++++++- 6 files changed, 324 insertions(+), 31 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 63434a6b5d..2891e25c0d 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -187,3 +187,19 @@ def stock_picking_not_available(self, picking): "message_type": "error", "message": _("Transfer {} is not available.").format(picking.name), } + + def product_multiple_packages_scan_package(self): + return { + "message_type": "warning", + "message": _( + "This product is part of multiple packages, please scan a package." + ), + } + + def lot_multiple_packages_scan_package(self): + return { + "message_type": "warning", + "message": _( + "This lot is part of multiple packages, please scan a package." + ), + } diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py index f205aeaa01..0d772accc9 100644 --- a/shopfloor/models/stock_move_line.py +++ b/shopfloor/models/stock_move_line.py @@ -4,6 +4,7 @@ class StockMoveLine(models.Model): _inherit = "stock.move.line" + # TODO use a serialized field shopfloor_unloaded = fields.Boolean(default=False) shopfloor_postponed = fields.Boolean( default=False, @@ -11,5 +12,7 @@ class StockMoveLine(models.Model): help="Technical field. " "Indicates if a the move has been postponed in a process.", ) + shopfloor_checkout_packed = fields.Boolean(default=False) + # we search lines based on their location in some workflows location_id = fields.Many2one(index=True) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 76957097e8..b19fb4ab1e 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -6,8 +6,8 @@ from .service import to_float # NOTE: we need to know if the destination package is set, but sometimes -# the dest. package is kept, so we should have an additional field on -# move lines to keep track of lines set +# the dest. package is kept, so we will use field shopfloor_checkout_packed +# on the move line class Checkout(Component): @@ -104,10 +104,11 @@ def _select_picking(self, picking, state_for_error): # TODO if all lines have a dest package set, go to summary return self._response_for_selected_stock_picking(picking) - def _response_for_selected_stock_picking(self, picking): + def _response_for_selected_stock_picking(self, picking, message=None): return self._response( next_state="select_line", data={"picking": self._data_for_stock_picking(picking)}, + message=message, ) def _response_for_picking_not_assigned(self, picking): @@ -209,12 +210,18 @@ def _data_for_stock_picking(self, picking): data.update( { "move_lines": [ - self._data_for_move_line(ml) for ml in picking.move_line_ids + self._data_for_move_line(ml) + for ml in self._lines_for_selection(picking) ] } ) return data + def _lines_for_selection(self, picking): + return picking.move_line_ids.filtered( + lambda l: l.qty_done == 0 and not l.shopfloor_checkout_packed + ) + def _domain_for_list_stock_picking(self): return [ ("state", "=", "assigned"), @@ -317,33 +324,108 @@ def scan_line(self, picking_id, barcode): return self._response_stock_picking_does_not_exist() search = self.actions_for("search") + message = self.actions_for("message") + + selection_lines = self._lines_for_selection(picking) + # TODO handle no lines in selection go to summary package = search.package_from_scan(barcode) if package: - lines = picking.move_line_ids.filtered(lambda l: l.package_id == package) - if not lines: - # TODO error package not in picking - pass - return self._select_scanned_lines(lines) + return self._select_lines_from_package(picking, selection_lines, package) product = search.product_from_scan(barcode) if product: - # TODO product tracked by lot: must scan lot - lines = picking.move_line_ids.filtered(lambda l: l.product_id == product) - # TODO: if no lines: error - if len(lines.mapped("package_id")) > 1: - # TODO must scan package - pass - return self._select_scanned_lines(lines) + return self._select_lines_from_product(picking, selection_lines, product) lot = search.lot_from_scan(barcode) if lot: - lines = picking.move_line_ids.filtered(lambda l: l.lot_id == lot) - # TODO: if no lines: error - return self._select_scanned_lines(lines) + return self._select_lines_from_lot(picking, selection_lines, lot) - # TODO barcode not found - return self._response() + return self._response_for_selected_stock_picking( + picking, message=message.barcode_not_found() + ) + + def _select_lines_from_package(self, picking, selection_lines, package): + lines = selection_lines.filtered(lambda l: l.package_id == package) + if not lines: + return self._response_for_selected_stock_picking( + picking, + message={ + "message_type": "error", + "message": _("Package {} is not in the current transfer.").format( + package.name + ), + }, + ) + return self._select_scanned_lines(lines) + + def _select_lines_from_product(self, picking, selection_lines, product): + message = self.actions_for("message") + if product.tracking in ("lot", "serial"): + return self._response_for_selected_stock_picking( + picking, message=message.scan_lot_on_product_tracked_by_lot() + ) + + lines = selection_lines.filtered(lambda l: l.product_id == product) + if not lines: + return self._response_for_selected_stock_picking( + picking, + message={ + "message_type": "error", + "message": _("Product is not in the current transfer."), + }, + ) + + # When products are as units outside of packages, we can select them for + # packing, but if they are in a package, we want the user to scan the packages. + # If the product is only in one package though, scanning the product selects + # the package. + packages = lines.mapped("package_id") + # Do not use mapped here: we want to see if we have more than one package, + # but also if we have one product as a package and the same product as + # a unit in another line. In both cases, we want the user to scan the + # package. + if packages and len({l.package_id for l in lines}) > 1: + return self._response_for_selected_stock_picking( + picking, message=message.product_multiple_packages_scan_package() + ) + elif packages: + # Select all the lines of the package when we scan a product in a + # package and we have only one. + return self._select_lines_from_package(picking, selection_lines, packages) + + return self._select_scanned_lines(lines) + + def _select_lines_from_lot(self, picking, selection_lines, lot): + lines = selection_lines.filtered(lambda l: l.lot_id == lot) + if not lines: + return self._response_for_selected_stock_picking( + picking, + message={ + "message_type": "error", + "message": _("Lot is not in the current transfer."), + }, + ) + + message = self.actions_for("message") + # When lots are as units outside of packages, we can select them for + # packing, but if they are in a package, we want the user to scan the packages. + # If the product is only in one package though, scanning the lot selects + # the package. + packages = lines.mapped("package_id") + # Do not use mapped here: we want to see if we have more than one + # package, but also if we have one lot as a package and the same lot as + # a unit in another line. In both cases, we want the user to scan the + # package. + if packages and len({l.package_id for l in lines}) > 1: + return self._response_for_selected_stock_picking( + picking, message=message.lot_multiple_packages_scan_package() + ) + elif packages: + # Select all the lines of the package when we scan a lot in a + # package and we have only one. + return self._select_lines_from_package(picking, selection_lines, packages) + return self._select_scanned_lines(lines) def select_line(self, picking_id, package_id=None, move_line_id=None): """Select move lines of the stock picking diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index aa11815b48..2ae7f007ee 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -2,6 +2,7 @@ from contextlib import contextmanager from pprint import pformat +from odoo import models from odoo.tests.common import Form, SavepointCase from odoo.addons.base_rest.controllers.main import _PseudoCollection @@ -116,9 +117,12 @@ def _fill_stock_for_moves(cls, moves, in_package=False, in_lot=False): for (product, location), qty in product_locations.items(): lot = None if in_lot: - lot = cls.env["stock.production.lot"].create( - {"product_id": product.id, "company_id": cls.env.company.id} - ) + if isinstance(in_lot, models.BaseModel): + lot = in_lot + else: + lot = cls.env["stock.production.lot"].create( + {"product_id": product.id, "company_id": cls.env.company.id} + ) if not (in_lot or in_package): # always add more quantity in stock to avoid to trigger the # "zero checks" in tests, not for lots which must have a qty diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index 2e489405c6..e3bc09a5a0 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -32,7 +32,7 @@ def setUp(self): self.service = work.component(usage="checkout") @classmethod - def _create_picking(cls, picking_type=None, lines=None): + def _create_picking(cls, picking_type=None, lines=None, confirm=True): picking_form = Form(cls.env["stock.picking"]) picking_form.picking_type_id = picking_type or cls.picking_type picking_form.partner_id = cls.customer @@ -43,7 +43,8 @@ def _create_picking(cls, picking_type=None, lines=None): move.product_id = product move.product_uom_qty = qty picking = picking_form.save() - picking.action_confirm() + if confirm: + picking.action_confirm() return picking def _stock_picking_data(self, picking): diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py index d0adae6322..ea1ad70cae 100644 --- a/shopfloor/tests/test_checkout_scan_line.py +++ b/shopfloor/tests/test_checkout_scan_line.py @@ -3,6 +3,11 @@ class CheckoutScanLineCase(CheckoutCommonCase): def _test_scan_line_ok(self, barcode, selected_lines): + """Test /scan_line with a valid return + + :param barcode: the barcode we scan + :selected_lines: expected move lines returned by the endpoint + """ picking = selected_lines.mapped("picking_id") response = self.service.dispatch( "scan_line", params={"picking_id": picking.id, "barcode": barcode} @@ -59,7 +64,8 @@ def test_scan_line_product_ok(self): picking = self._create_picking( lines=[(self.product_a, 10), (self.product_b, 10)] ) - self._fill_stock_for_moves(picking.move_lines, in_package=True) + # do not put them in a package, we'll pack units here + self._fill_stock_for_moves(picking.move_lines) picking.action_assign() line_a = picking.move_line_ids.filtered( lambda l: l.product_id == self.product_a @@ -72,7 +78,7 @@ def test_scan_line_product_several_lines_ok(self): picking = self._create_picking( lines=[(self.product_a, 10), (self.product_a, 10), (self.product_b, 10)] ) - self._fill_stock_for_moves(picking.move_lines, in_package=True) + self._fill_stock_for_moves(picking.move_lines) picking.action_assign() lines_a = picking.move_line_ids.filtered( lambda l: l.product_id == self.product_a @@ -85,7 +91,7 @@ def test_scan_line_product_packaging_ok(self): picking = self._create_picking( lines=[(self.product_a, 10), (self.product_a, 10), (self.product_b, 10)] ) - self._fill_stock_for_moves(picking.move_lines, in_package=True) + self._fill_stock_for_moves(picking.move_lines) picking.action_assign() lines_a = picking.move_line_ids.filtered( lambda l: l.product_id == self.product_a @@ -105,5 +111,186 @@ def test_scan_line_product_lot_ok(self): lot = first_line.lot_id self._test_scan_line_ok(lot.name, first_line) - # TODO test 2 lines with product in different packages - # TODO test 2 lines with lots in different packages + def test_scan_line_product_in_one_package_all_package_lines_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10)] + ) + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + # Product_a and product_b are in the same package, when we scan product_a, + # we expect to work on all the lines of the package. If product_a was in + # more than one package, it would be an error. + self._test_scan_line_ok(self.product_a.barcode, picking.move_line_ids) + + def _test_scan_line_error(self, picking, barcode, message): + """Test errors for /scan_line + + :param picking: the picking we are currently working with (selected) + :param barcode: the barcode we scan + """ + response = self.service.dispatch( + "scan_line", params={"picking_id": picking.id, "barcode": barcode} + ) + self.assert_response( + response, + next_state="select_line", + data={"picking": self._stock_picking_data(picking)}, + message=message, + ) + + def test_scan_line_error_barcode_not_found(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + self._test_scan_line_error( + picking, + "NOT A BARCODE", + {"message_type": "error", "message": "Barcode not found"}, + ) + + def test_scan_line_error_package_not_in_picking(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking2 = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking2.move_lines, in_package=True) + (picking | picking2).action_assign() + package = picking2.move_line_ids.package_id + # we work with picking, but we scan the package of picking2 + self._test_scan_line_error( + picking, + package.name, + { + "message_type": "error", + "message": "Package {} is not in the current transfer.".format( + package.name + ), + }, + ) + + def test_scan_line_error_product_tracked_by_lot(self): + self.product_a.tracking = "lot" + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + # product tracked by lot, but we scan the product barcode, user + # has to scan the lot + self._test_scan_line_error( + picking, + self.product_a.barcode, + { + "message_type": "warning", + "message": "Product tracked by lot, please scan one.", + }, + ) + + def test_scan_line_error_product_in_two_packages(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_a, 10)], + # when action_confirm is called, it would merge the moves + confirm=False, + ) + self._fill_stock_for_moves(picking.move_lines[0], in_package=True) + self._fill_stock_for_moves(picking.move_lines[1], in_package=True) + picking.action_assign() + self._test_scan_line_error( + picking, + self.product_a.barcode, + { + "message_type": "warning", + "message": "This product is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_error_product_in_one_package_and_unit(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_a, 10)], + # when action_confirm is called, it would merge the moves + # we want to keep them separated to put a part in a package + confirm=False, + ) + # put the product in one package and the other as unit + self._fill_stock_for_moves(picking.move_lines[0], in_package=True) + self._fill_stock_for_moves(picking.move_lines[1]) + picking.action_assign() + self._test_scan_line_error( + picking, + self.product_a.barcode, + { + "message_type": "warning", + "message": "This product is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_error_product_not_in_picking(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + self._test_scan_line_error( + picking, + self.product_b.barcode, + { + "message_type": "error", + "message": "Product is not in the current transfer.", + }, + ) + + def test_scan_line_error_lot_not_in_picking(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_lines, in_lot=True) + picking.action_assign() + lot = self.env["stock.production.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + self._test_scan_line_error( + picking, + lot.name, + {"message_type": "error", "message": "Lot is not in the current transfer."}, + ) + + def test_scan_line_error_lot_in_two_packages(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_a, 10)], + # when action_confirm is called, it would merge the moves + confirm=False, + ) + # we want the same lot to be used in 2 lines with different packages + lot = self.env["stock.production.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + self._fill_stock_for_moves(picking.move_lines[0], in_package=True, in_lot=lot) + self._fill_stock_for_moves(picking.move_lines[1], in_package=True, in_lot=lot) + picking.action_assign() + self._test_scan_line_error( + picking, + lot.name, + { + "message_type": "warning", + "message": "This lot is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_error_lot_in_one_package_and_unit(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_a, 10)], + # when action_confirm is called, it would merge the moves + confirm=False, + ) + # we want the same lot to be used in 2 lines with different packages + lot = self.env["stock.production.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + self._fill_stock_for_moves(picking.move_lines[0], in_package=True, in_lot=lot) + self._fill_stock_for_moves(picking.move_lines[1], in_lot=lot) + picking.action_assign() + self._test_scan_line_error( + picking, + lot.name, + { + "message_type": "warning", + "message": "This lot is part of multiple" + " packages, please scan a package.", + }, + ) From e302f8d9a67da8f4906d18caaa8f70a6649878ae Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 18 Mar 2020 11:13:37 +0100 Subject: [PATCH 151/940] checkout: implement / select_line --- shopfloor/actions/message.py | 2 +- shopfloor/services/checkout.py | 30 ++++++ shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_checkout_base.py | 6 ++ shopfloor/tests/test_checkout_scan_line.py | 32 ++++--- shopfloor/tests/test_checkout_select_line.py | 91 +++++++++++++++++++ .../tests/test_cluster_picking_select.py | 2 +- 7 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 shopfloor/tests/test_checkout_select_line.py diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 2891e25c0d..b2b6cc8433 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -77,7 +77,7 @@ def stock_picking_not_found(self): def record_not_found(self): return { "message_type": "error", - "message": _("This record you were working on does not exist anymore."), + "message": _("The record you were working on does not exist anymore."), } def barcode_not_found(self): diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index b19fb4ab1e..ce3ff645f6 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -444,6 +444,36 @@ def select_line(self, picking_id, package_id=None, move_line_id=None): screen to change the qty done and destination package if needed """ assert package_id or move_line_id + + picking = self.env["stock.picking"].browse(picking_id) + if not picking.exists(): + return self._response_stock_picking_does_not_exist() + + message = self.actions_for("message") + selection_lines = self._lines_for_selection(picking) + # TODO if no remaining lines, go to summary + + if package_id: + package = self.env["stock.quant.package"].browse(package_id).exists() + if not package: + return self._response_for_selected_stock_picking( + picking, message=message.record_not_found() + ) + return self._select_lines_from_package(picking, selection_lines, package) + if move_line_id: + move_line = self.env["stock.move.line"].browse(move_line_id).exists() + if not move_line: + return self._response_for_selected_stock_picking( + picking, message=message.record_not_found() + ) + # normally, the client should sent only move lines out of packages, but + # in case there is a package, handle it as a package + if move_line.package_id: + return self._select_lines_from_package( + picking, selection_lines, move_line.package_id + ) + return self._select_scanned_lines(move_line) + return self._response() def reset_line_qty(self, picking_id, move_line_id): diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index e002c1d6cb..cf1bf3cd8e 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -13,3 +13,4 @@ from . import test_checkout_scan from . import test_checkout_select from . import test_checkout_scan_line +from . import test_checkout_select_line diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index e3bc09a5a0..a3709d9329 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -19,6 +19,12 @@ def setUpClass(cls, *args, **kwargs): cls.product_b_packaging = cls.env["product.packaging"].create( {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductBBox"} ) + cls.product_c = cls.env["product.product"].create( + {"name": "Product C", "type": "product", "barcode": "product_c"} + ) + cls.product_c_packaging = cls.env["product.packaging"].create( + {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductCBox"} + ) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout") cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py index ea1ad70cae..b645e97d1a 100644 --- a/shopfloor/tests/test_checkout_scan_line.py +++ b/shopfloor/tests/test_checkout_scan_line.py @@ -1,23 +1,18 @@ from .test_checkout_base import CheckoutCommonCase -class CheckoutScanLineCase(CheckoutCommonCase): - def _test_scan_line_ok(self, barcode, selected_lines): - """Test /scan_line with a valid return - - :param barcode: the barcode we scan - :selected_lines: expected move lines returned by the endpoint - """ +class CheckoutSelectLineCommonCase(CheckoutCommonCase): + def _assert_selected(self, response, selected_lines): picking = selected_lines.mapped("picking_id") - response = self.service.dispatch( - "scan_line", params={"picking_id": picking.id, "barcode": barcode} - ) + unselected_lines = picking.move_line_ids - selected_lines for line in selected_lines: self.assertEqual( line.qty_done, line.product_uom_qty, "Scanned lines must have their qty done set to the reserved quantity", ) + for line in unselected_lines: + self.assertEqual(line.qty_done, 0) self.assert_response( response, next_state="select_package", @@ -30,12 +25,26 @@ def _test_scan_line_ok(self, barcode, selected_lines): "name": picking.name, "note": "", "origin": "", - "line_count": 2, + "line_count": len(picking.move_line_ids), "partner": {"id": self.customer.id, "name": self.customer.name}, }, }, ) + +class CheckoutScanLineCase(CheckoutSelectLineCommonCase): + def _test_scan_line_ok(self, barcode, selected_lines): + """Test /scan_line with a valid return + + :param barcode: the barcode we scan + :selected_lines: expected move lines returned by the endpoint + """ + picking = selected_lines.mapped("picking_id") + response = self.service.dispatch( + "scan_line", params={"picking_id": picking.id, "barcode": barcode} + ) + self._assert_selected(response, selected_lines) + def test_scan_line_package_ok(self): picking = self._create_picking( lines=[(self.product_a, 10), (self.product_b, 10)] @@ -127,6 +136,7 @@ def _test_scan_line_error(self, picking, barcode, message): :param picking: the picking we are currently working with (selected) :param barcode: the barcode we scan + :param message: the dict of expected error message """ response = self.service.dispatch( "scan_line", params={"picking_id": picking.id, "barcode": barcode} diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py new file mode 100644 index 0000000000..22066b1384 --- /dev/null +++ b/shopfloor/tests/test_checkout_select_line.py @@ -0,0 +1,91 @@ +from .test_checkout_scan_line import CheckoutSelectLineCommonCase + + +class CheckoutSelectLineCase(CheckoutSelectLineCommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + picking = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10), (cls.product_c, 10)] + ) + cls.moves_pack = picking.move_lines[:2] + cls.move_single = picking.move_lines[2:] + cls._fill_stock_for_moves(cls.moves_pack, in_package=True) + cls._fill_stock_for_moves(cls.move_single) + picking.action_assign() + cls.picking = picking + + def test_select_line_package_ok(self): + selected_lines = self.moves_pack.move_line_ids + # we want to check that when we give the package id, we get + # all its move lines + response = self.service.dispatch( + "select_line", + params={ + "picking_id": self.picking.id, + "package_id": selected_lines.package_id.id, + }, + ) + self._assert_selected(response, selected_lines) + + def test_select_line_move_line_package_ok(self): + selected_lines = self.moves_pack.move_line_ids + # When we select a single line but the line is part of a package, + # we select all the lines. Note: not really supposed to happen as + # the client application should use send a package id when there is + # a package and use the move line id only for lines without package + response = self.service.dispatch( + "select_line", + params={ + "picking_id": self.picking.id, + "move_line_id": selected_lines[0].id, + }, + ) + self._assert_selected(response, selected_lines) + + def test_select_line_move_line_ok(self): + selected_lines = self.move_single.move_line_ids + response = self.service.dispatch( + "select_line", + params={ + "picking_id": self.picking.id, + "move_line_id": selected_lines[0].id, + }, + ) + self._assert_selected(response, selected_lines) + + def _test_select_line_error(self, params, message): + """Test errors for /select_line + + :param params: params sent to /select_line + :param message: the dict of expected error message + """ + response = self.service.dispatch("select_line", params=params) + self.assert_response( + response, + next_state="select_line", + data={"picking": self._stock_picking_data(self.picking)}, + message=message, + ) + + def test_select_line_package_error_not_found(self): + selected_lines = self.move_single.move_line_ids + self.picking.do_unreserve() + self._test_select_line_error( + {"picking_id": self.picking.id, "package_id": selected_lines[0].id}, + { + "message_type": "error", + "message": "The record you were working on does not exist anymore.", + }, + ) + + def test_select_line_move_line_error_not_found(self): + selected_lines = self.move_single.move_line_ids + self.picking.do_unreserve() + self._test_select_line_error( + {"picking_id": self.picking.id, "move_line_id": selected_lines[0].id}, + { + "message_type": "error", + "message": "The record you were working on does not exist anymore.", + }, + ) diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py index af742af033..5bc64cd816 100644 --- a/shopfloor/tests/test_cluster_picking_select.py +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -407,7 +407,7 @@ def test_confirm_start_not_exists(self): response, message={ "message_type": "error", - "message": "This record you were working on does not exist anymore.", + "message": "The record you were working on does not exist anymore.", }, next_state="start", ) From 3bbe4dfb7e5359259c06cbc3a757831f89b85d3e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 18 Mar 2020 14:28:59 +0100 Subject: [PATCH 152/940] checkout: implement methods to change line quantities --- shopfloor/services/checkout.py | 156 ++++++++++---- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_checkout_set_qty.py | 248 +++++++++++++++++++++++ 3 files changed, 366 insertions(+), 39 deletions(-) create mode 100644 shopfloor/tests/test_checkout_set_qty.py diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index ce3ff645f6..bf75100b58 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -210,14 +210,13 @@ def _data_for_stock_picking(self, picking): data.update( { "move_lines": [ - self._data_for_move_line(ml) - for ml in self._lines_for_selection(picking) + self._data_for_move_line(ml) for ml in self._lines_to_pack(picking) ] } ) return data - def _lines_for_selection(self, picking): + def _lines_to_pack(self, picking): return picking.move_line_ids.filtered( lambda l: l.qty_done == 0 and not l.shopfloor_checkout_packed ) @@ -283,7 +282,7 @@ def select(self, picking_id): picking = self.env["stock.picking"].browse(picking_id).exists() return self._select_picking(picking, "manual_selection") - def _response_for_select_package(self, lines): + def _response_for_select_package(self, lines, message=None): picking = lines.mapped("picking_id") return self._response( next_state="select_package", @@ -293,12 +292,21 @@ def _response_for_select_package(self, lines): ], "picking": self._data_picking_base(picking), }, + message=message, ) - def _select_scanned_lines(self, lines): + def _select_lines(self, lines): for line in lines: + if line.shopfloor_checkout_packed: + continue line.qty_done = line.product_uom_qty - return self._response_for_select_package(lines) + + picking = lines.mapped("picking_id") + other_lines = picking.move_line_ids - lines + self._deselect_lines(other_lines) + + def _deselect_lines(self, lines): + lines.filtered(lambda l: not l.shopfloor_checkout_packed).qty_done = 0 def scan_line(self, picking_id, barcode): """Scan move lines of the stock picking @@ -326,7 +334,7 @@ def scan_line(self, picking_id, barcode): search = self.actions_for("search") message = self.actions_for("message") - selection_lines = self._lines_for_selection(picking) + selection_lines = self._lines_to_pack(picking) # TODO handle no lines in selection go to summary package = search.package_from_scan(barcode) @@ -357,7 +365,8 @@ def _select_lines_from_package(self, picking, selection_lines, package): ), }, ) - return self._select_scanned_lines(lines) + self._select_lines(lines) + return self._response_for_select_package(lines) def _select_lines_from_product(self, picking, selection_lines, product): message = self.actions_for("message") @@ -394,7 +403,8 @@ def _select_lines_from_product(self, picking, selection_lines, product): # package and we have only one. return self._select_lines_from_package(picking, selection_lines, packages) - return self._select_scanned_lines(lines) + self._select_lines(lines) + return self._response_for_select_package(lines) def _select_lines_from_lot(self, picking, selection_lines, lot): lines = selection_lines.filtered(lambda l: l.lot_id == lot) @@ -425,7 +435,9 @@ def _select_lines_from_lot(self, picking, selection_lines, lot): # Select all the lines of the package when we scan a lot in a # package and we have only one. return self._select_lines_from_package(picking, selection_lines, packages) - return self._select_scanned_lines(lines) + + self._select_lines(lines) + return self._response_for_select_package(lines) def select_line(self, picking_id, package_id=None, move_line_id=None): """Select move lines of the stock picking @@ -450,7 +462,7 @@ def select_line(self, picking_id, package_id=None, move_line_id=None): return self._response_stock_picking_does_not_exist() message = self.actions_for("message") - selection_lines = self._lines_for_selection(picking) + selection_lines = self._lines_to_pack(picking) # TODO if no remaining lines, go to summary if package_id: @@ -472,31 +484,91 @@ def select_line(self, picking_id, package_id=None, move_line_id=None): return self._select_lines_from_package( picking, selection_lines, move_line.package_id ) - return self._select_scanned_lines(move_line) + self._select_lines(move_line) + return self._response_for_select_package(move_line) return self._response() - def reset_line_qty(self, picking_id, move_line_id): + def _change_line_qty( + self, picking_id, selected_line_ids, move_line_id, quantity_func + ): + picking = self.env["stock.picking"].browse(picking_id) + if not picking.exists(): + return self._response_stock_picking_does_not_exist() + + message_directory = self.actions_for("message") + + move_line = self.env["stock.move.line"].browse(move_line_id).exists() + + message = None + if not move_line: + message = message_directory.record_not_found() + else: + qty_done = quantity_func(move_line) + if qty_done > move_line.product_uom_qty: + qty_done = move_line.product_uom_qty + message = { + "message": _( + "Not allowed to pack more than the quantity, " + "the value has been changed to the maximum." + ), + "message_type": "warning", + } + if qty_done < 0: + message = { + "message": _("Negative quantity not allowed."), + "message_type": "error", + } + else: + move_line.qty_done = qty_done + return self._response_for_select_package( + self.env["stock.move.line"].browse(selected_line_ids).exists(), + message=message, + ) + + def reset_line_qty(self, picking_id, selected_line_ids, move_line_id): """Reset qty_done of a move line to zero Used to deselect a line in the "select_package" screen. + The selected_line_ids parameter is used to keep the selection of lines + stateless. Transitions: * select_package: goes back to the same state, the line will appear as deselected """ - return self._response() + return self._change_line_qty( + picking_id, selected_line_ids, move_line_id, lambda __: 0 + ) - def set_line_qty(self, picking_id, move_line_id): + def set_line_qty(self, picking_id, selected_line_ids, move_line_id): """Set qty_done of a move line to its reserved quantity - Used to deselect a line in the "select_package" screen. + Used to select a line in the "select_package" screen. + The selected_line_ids parameter is used to keep the selection of lines + stateless. Transitions: * select_package: goes back to the same state, the line will appear as selected """ - return self._response() + return self._change_line_qty( + picking_id, selected_line_ids, move_line_id, lambda l: l.product_uom_qty + ) + + def set_custom_qty(self, picking_id, selected_line_ids, move_line_id, qty_done): + """Change qty_done of a move line with a custom value + + The selected_line_ids parameter is used to keep the selection of lines + stateless. + + Transitions: + * select_package: goes back to this screen showing all the lines after + we changed the qty + """ + return self._change_line_qty( + picking_id, selected_line_ids, move_line_id, lambda __: qty_done + ) def scan_package_action(self, picking_id, move_line_ids, barcode): """Scan a package, a lot, a product or a package to handle a line @@ -530,15 +602,6 @@ def scan_package_action(self, picking_id, move_line_ids, barcode): """ return self._response() - def set_custom_qty(self, picking_id, move_line_id, qty_done): - """Change qty_done of a move line with a custom value - - Transitions: - * select_package: goes back to this screen showing all the lines after - we changed the qty - """ - return self._response() - def new_package(self, picking_id, move_line_ids): """Add all selected lines in a new package @@ -683,37 +746,52 @@ def select_line(self): def reset_line_qty(self): return { "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_ids": { + "type": "list", + "required": True, + "schema": {"coerce": to_int, "required": True, "type": "integer"}, + }, "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, } def set_line_qty(self): return { "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_ids": { + "type": "list", + "required": True, + "schema": {"coerce": to_int, "required": True, "type": "integer"}, + }, "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, } - def scan_package_action(self): + def set_custom_qty(self): return { "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, - "move_line_ids": { + "selected_line_ids": { "type": "list", "required": True, "schema": {"coerce": to_int, "required": True, "type": "integer"}, }, - "barcode": {"required": True, "type": "string"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "qty_done": {"coerce": to_float, "required": True, "type": "float"}, } - def set_custom_qty(self): + def scan_package_action(self): return { "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, - "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, - "qty_done": {"coerce": to_float, "required": True, "type": "float"}, + "selected_line_ids": { + "type": "list", + "required": True, + "schema": {"coerce": to_int, "required": True, "type": "integer"}, + }, + "barcode": {"required": True, "type": "string"}, } def new_package(self): return { "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, - "move_line_ids": { + "selected_line_ids": { "type": "list", "required": True, "schema": {"coerce": to_int, "required": True, "type": "integer"}, @@ -723,7 +801,7 @@ def new_package(self): def list_dest_package(self): return { "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, - "move_line_ids": { + "selected_line_ids": { "type": "list", "required": True, "schema": {"coerce": to_int, "required": True, "type": "integer"}, @@ -733,7 +811,7 @@ def list_dest_package(self): def scan_dest_package(self): return { "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, - "move_line_ids": { + "selected_line_ids": { "type": "list", "required": True, "schema": {"coerce": to_int, "required": True, "type": "integer"}, @@ -744,7 +822,7 @@ def scan_dest_package(self): def set_dest_package(self): return { "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, - "move_line_ids": { + "selected_line_ids": { "type": "list", "required": True, "schema": {"coerce": to_int, "required": True, "type": "integer"}, @@ -893,14 +971,14 @@ def reset_line_qty(self): def set_line_qty(self): return self._response_schema(next_states={"select_package"}) + def set_custom_qty(self): + return self._response_schema(next_states={"select_package"}) + def scan_package_action(self): return self._response_schema( next_states={"select_package", "select_line", "summary"} ) - def set_custom_qty(self): - return self._response_schema(next_states={"select_package"}) - def new_package(self): return self._response_schema(next_states={"select_line"}) diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index cf1bf3cd8e..77713a88a2 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -14,3 +14,4 @@ from . import test_checkout_select from . import test_checkout_scan_line from . import test_checkout_select_line +from . import test_checkout_set_qty diff --git a/shopfloor/tests/test_checkout_set_qty.py b/shopfloor/tests/test_checkout_set_qty.py new file mode 100644 index 0000000000..35bc8b7819 --- /dev/null +++ b/shopfloor/tests/test_checkout_set_qty.py @@ -0,0 +1,248 @@ +from .test_checkout_base import CheckoutCommonCase + + +class CheckoutSetQtyCommonCase(CheckoutCommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + picking = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10), (cls.product_c, 10)] + ) + cls.moves_pack1 = picking.move_lines[:2] + cls.moves_pack2 = picking.move_lines[2:] + cls._fill_stock_for_moves(cls.moves_pack1, in_package=True) + cls._fill_stock_for_moves(cls.moves_pack2, in_package=True) + picking.action_assign() + cls.picking = picking + + def setUp(self): + super().setUp() + # we assume we have called /select_line on pack one, so by default, we + # expect the lines for product a and b to have their qty_done set to + # their product_uom_qty at the start of the tests + self.selected_lines = self.moves_pack1.move_line_ids + self.deselected_lines = self.moves_pack2.move_line_ids + self.service._select_lines(self.selected_lines) + self.assertTrue( + all(l.qty_done == l.product_uom_qty for l in self.selected_lines) + ) + self.assertTrue(all(l.qty_done == 0 for l in self.deselected_lines)) + + def _assert_selected_qties( + self, response, selected_lines, lines_quantities, message=None + ): + picking = selected_lines.mapped("picking_id") + deselected_lines = picking.move_line_ids - selected_lines + self.assertEqual(selected_lines.ids, [l.id for l in lines_quantities]) + for line, quantity in lines_quantities.items(): + self.assertEqual(line.qty_done, quantity) + for line in deselected_lines: + self.assertEqual(line.qty_done, 0, "Lines deselected must have no qty done") + self.assert_response( + response, + next_state="select_package", + data={ + "selected_move_lines": [ + self._move_line_data(ml) for ml in selected_lines + ], + "picking": { + "id": picking.id, + "name": picking.name, + "note": "", + "origin": "", + "line_count": len(picking.move_line_ids), + "partner": {"id": self.customer.id, "name": self.customer.name}, + }, + }, + message=message, + ) + + +class CheckoutResetLineQtyCase(CheckoutSetQtyCommonCase): + def test_reset_line_qty_ok(self): + selected_lines = self.moves_pack1.move_line_ids + line_to_reset = selected_lines[0] + line_with_qty = selected_lines[1] + # we want to check that when we give the package id, we get + # all its move lines + response = self.service.dispatch( + "reset_line_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": line_to_reset.id, + }, + ) + self._assert_selected_qties( + response, + selected_lines, + {line_to_reset: 0, line_with_qty: line_with_qty.product_uom_qty}, + ) + + def test_reset_line_qty_not_found(self): + selected_lines = self.moves_pack1.move_line_ids + response = self.service.dispatch( + "reset_line_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": 0, + }, + ) + # if the move line is not found, ignore and return to the + # screen + self._assert_selected_qties( + response, + selected_lines, + {line: line.product_uom_qty for line in selected_lines}, + message={ + "message": "The record you were working on does not exist anymore.", + "message_type": "error", + }, + ) + + +class CheckoutSetLineQtyCase(CheckoutSetQtyCommonCase): + def test_set_line_qty_ok(self): + selected_lines = self.moves_pack1.move_line_ids + # do as if the user removed the qties of the 2 selected lines + selected_lines.qty_done = 0 + line_to_set = selected_lines[0] + line_no_qty = selected_lines[1] + # we want to check that when we give the package id, we get + # all its move lines + response = self.service.dispatch( + "set_line_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": line_to_set.id, + }, + ) + self.assertEqual(line_to_set.qty_done, line_to_set.product_uom_qty) + self.assertEqual(line_no_qty.qty_done, 0) + self._assert_selected_qties( + response, + selected_lines, + {line_to_set: line_to_set.product_uom_qty, line_no_qty: 0}, + ) + + def test_set_line_qty_not_found(self): + selected_lines = self.moves_pack1.move_line_ids + response = self.service.dispatch( + "set_line_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": 0, + }, + ) + # if the move line is not found, ignore and return to the + # screen + self._assert_selected_qties( + response, + selected_lines, + {line: line.product_uom_qty for line in selected_lines}, + message={ + "message": "The record you were working on does not exist anymore.", + "message_type": "error", + }, + ) + + +class CheckoutSetCustomQtyCase(CheckoutSetQtyCommonCase): + def test_set_custom_qty_ok(self): + selected_lines = self.moves_pack1.move_line_ids + line_to_change = selected_lines[0] + line_keep_qty = selected_lines[1] + new_qty = 5 + # we want to check that when we give the package id, we get + # all its move lines + response = self.service.dispatch( + "set_custom_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": line_to_change.id, + "qty_done": 5, + }, + ) + self.assertEqual(line_to_change.qty_done, new_qty) + self.assertEqual(line_keep_qty.qty_done, line_keep_qty.product_uom_qty) + self._assert_selected_qties( + response, + selected_lines, + {line_to_change: new_qty, line_keep_qty: line_keep_qty.product_uom_qty}, + ) + + def test_set_custem_qty_not_found(self): + selected_lines = self.moves_pack1.move_line_ids + response = self.service.dispatch( + "set_custom_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": 0, + "qty_done": 3, + }, + ) + # if the move line is not found, ignore and return to the + # screen + self._assert_selected_qties( + response, + selected_lines, + {line: line.product_uom_qty for line in selected_lines}, + message={ + "message": "The record you were working on does not exist anymore.", + "message_type": "error", + }, + ) + + def test_set_custom_qty_above(self): + selected_lines = self.moves_pack1.move_line_ids + line1 = selected_lines[0] + # modify so we can check that a too high quantity set the max + line1.qty_done = 1 + line2 = selected_lines[1] + response = self.service.dispatch( + "set_custom_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": line1.id, + "qty_done": line1.product_uom_qty + 1, + }, + ) + self._assert_selected_qties( + response, + selected_lines, + {line1: line1.product_uom_qty, line2: line2.product_uom_qty}, + message={ + "message": "Not allowed to pack more than the quantity, " + "the value has been changed to the maximum.", + "message_type": "warning", + }, + ) + + def test_set_custom_qty_negative(self): + selected_lines = self.moves_pack1.move_line_ids + line1 = selected_lines[0] + line2 = selected_lines[1] + response = self.service.dispatch( + "set_custom_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": line1.id, + "qty_done": -1, + }, + ) + self._assert_selected_qties( + response, + selected_lines, + {line1: line1.product_uom_qty, line2: line2.product_uom_qty}, + message={ + "message": "Negative quantity not allowed.", + "message_type": "error", + }, + ) From 602c9322cb52f64c423c0edf087ae3f60721f2bb Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 20 Mar 2020 11:14:50 +0100 Subject: [PATCH 153/940] checkout: implement /scan_package_action and /summary --- shopfloor/actions/search.py | 5 + shopfloor/services/checkout.py | 198 +++++++-- shopfloor/tests/__init__.py | 2 + shopfloor/tests/test_checkout_base.py | 6 + shopfloor/tests/test_checkout_scan_line.py | 34 +- .../test_checkout_scan_package_action.py | 378 ++++++++++++++++++ shopfloor/tests/test_checkout_select_line.py | 9 +- .../test_checkout_select_package_base.py | 46 +++ shopfloor/tests/test_checkout_set_qty.py | 32 +- shopfloor/tests/test_checkout_summary.py | 20 + 10 files changed, 628 insertions(+), 102 deletions(-) create mode 100644 shopfloor/tests/test_checkout_scan_package_action.py create mode 100644 shopfloor/tests/test_checkout_select_package_base.py create mode 100644 shopfloor/tests/test_checkout_summary.py diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py index c170963b1d..a547abbbc6 100644 --- a/shopfloor/actions/search.py +++ b/shopfloor/actions/search.py @@ -32,3 +32,8 @@ def product_from_scan(self, barcode): def lot_from_scan(self, barcode): return self.env["stock.production.lot"].search([("name", "=", barcode)]) + + def generic_packaging_from_scan(self, barcode): + return self.env["product.packaging"].search( + [("barcode", "=", barcode), ("product_id", "=", False)] + ) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index bf75100b58..b7fc8c3e75 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -101,16 +101,24 @@ def _select_picking(self, picking, state_for_error): message=message.stock_picking_not_available(picking) ) return self._response_for_picking_not_assigned(picking) - # TODO if all lines have a dest package set, go to summary return self._response_for_selected_stock_picking(picking) def _response_for_selected_stock_picking(self, picking, message=None): + if all(line.shopfloor_checkout_packed for line in picking.move_line_ids): + return self._response_for_all_lines_packed(picking, message=message) return self._response( next_state="select_line", data={"picking": self._data_for_stock_picking(picking)}, message=message, ) + def _response_for_all_lines_packed(self, picking, message=None): + return self._response( + next_state="summary", + data={"picking": self._data_for_stock_picking(picking)}, + message=message, + ) + def _response_for_picking_not_assigned(self, picking): message = self.actions_for("message") return self._response( @@ -439,6 +447,29 @@ def _select_lines_from_lot(self, picking, selection_lines, lot): self._select_lines(lines) return self._response_for_select_package(lines) + def _select_line_package(self, picking, selection_lines, package): + if not package: + message = self.actions_for("message") + return self._response_for_selected_stock_picking( + picking, message=message.record_not_found() + ) + return self._select_lines_from_package(picking, selection_lines, package) + + def _select_line_move_line(self, picking, selection_lines, move_line): + if not move_line: + message = self.actions_for("message") + return self._response_for_selected_stock_picking( + picking, message=message.record_not_found() + ) + # normally, the client should sent only move lines out of packages, but + # in case there is a package, handle it as a package + if move_line.package_id: + return self._select_lines_from_package( + picking, selection_lines, move_line.package_id + ) + self._select_lines(move_line) + return self._response_for_select_package(move_line) + def select_line(self, picking_id, package_id=None, move_line_id=None): """Select move lines of the stock picking @@ -461,36 +492,18 @@ def select_line(self, picking_id, package_id=None, move_line_id=None): if not picking.exists(): return self._response_stock_picking_does_not_exist() - message = self.actions_for("message") selection_lines = self._lines_to_pack(picking) # TODO if no remaining lines, go to summary if package_id: package = self.env["stock.quant.package"].browse(package_id).exists() - if not package: - return self._response_for_selected_stock_picking( - picking, message=message.record_not_found() - ) - return self._select_lines_from_package(picking, selection_lines, package) + return self._select_line_package(picking, selection_lines, package) if move_line_id: move_line = self.env["stock.move.line"].browse(move_line_id).exists() - if not move_line: - return self._response_for_selected_stock_picking( - picking, message=message.record_not_found() - ) - # normally, the client should sent only move lines out of packages, but - # in case there is a package, handle it as a package - if move_line.package_id: - return self._select_lines_from_package( - picking, selection_lines, move_line.package_id - ) - self._select_lines(move_line) - return self._response_for_select_package(move_line) - - return self._response() + return self._select_line_move_line(picking, selection_lines, move_line) def _change_line_qty( - self, picking_id, selected_line_ids, move_line_id, quantity_func + self, picking_id, selected_line_ids, move_line_ids, quantity_func ): picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): @@ -498,12 +511,12 @@ def _change_line_qty( message_directory = self.actions_for("message") - move_line = self.env["stock.move.line"].browse(move_line_id).exists() + move_lines = self.env["stock.move.line"].browse(move_line_ids).exists() message = None - if not move_line: + if not move_lines: message = message_directory.record_not_found() - else: + for move_line in move_lines: qty_done = quantity_func(move_line) if qty_done > move_line.product_uom_qty: qty_done = move_line.product_uom_qty @@ -538,7 +551,7 @@ def reset_line_qty(self, picking_id, selected_line_ids, move_line_id): as deselected """ return self._change_line_qty( - picking_id, selected_line_ids, move_line_id, lambda __: 0 + picking_id, selected_line_ids, [move_line_id], lambda __: 0 ) def set_line_qty(self, picking_id, selected_line_ids, move_line_id): @@ -553,7 +566,7 @@ def set_line_qty(self, picking_id, selected_line_ids, move_line_id): as selected """ return self._change_line_qty( - picking_id, selected_line_ids, move_line_id, lambda l: l.product_uom_qty + picking_id, selected_line_ids, [move_line_id], lambda l: l.product_uom_qty ) def set_custom_qty(self, picking_id, selected_line_ids, move_line_id, qty_done): @@ -567,16 +580,91 @@ def set_custom_qty(self, picking_id, selected_line_ids, move_line_id, qty_done): we changed the qty """ return self._change_line_qty( - picking_id, selected_line_ids, move_line_id, lambda __: qty_done + picking_id, selected_line_ids, [move_line_id], lambda __: qty_done ) - def scan_package_action(self, picking_id, move_line_ids, barcode): + def _switch_line_qty_done(self, picking, selected_lines, switch_lines): + """Switch qty_done on lines and return to the 'select_package' state + + If at least one of the lines to switch has a qty_done, set them all + to zero. If all the lines to switch have a zero qty_done, switch them + to their quantity to deliver. + """ + if any(line.qty_done for line in switch_lines): + return self._change_line_qty( + picking.id, selected_lines.ids, switch_lines.ids, lambda __: 0 + ) + else: + return self._change_line_qty( + picking.id, + selected_lines.ids, + switch_lines.ids, + lambda l: l.product_uom_qty, + ) + + @staticmethod + def _filter_lines_to_pack(move_line): + return move_line.qty_done > 0 and not move_line.shopfloor_checkout_packed + + def _put_lines_in_package(self, picking, selected_lines, package): + """Put the current selected lines with a qty_done in a package + + Note: only packages which are already a destination package for another + line of the stock picking can be selected. Packages which are the + source packages are allowed too (we keep the current package), but + since Odoo set the value of the result package to the source package by + default, it works by default. + """ + existing_packages = picking.mapped("move_line_ids.result_package_id") + if package not in existing_packages: + return self._response_for_select_package( + selected_lines, + message={ + "message_type": "error", + "message": _("Not a valid destination package").format( + package.name + ), + }, + ) + return self._put_lines_in_allowed_package(picking, selected_lines, package) + + def _put_lines_in_allowed_package(self, picking, selected_lines, package): + lines_to_pack = selected_lines.filtered(self._filter_lines_to_pack) + lines_to_pack.write( + {"result_package_id": package.id, "shopfloor_checkout_packed": True} + ) + # go back to the screen to select the next lines to pack + return self._response_for_selected_stock_picking( + picking, + message={ + "message_type": "info", + "message": _("Product(s) packed in {}").format(package.name), + }, + ) + + def _prepare_vals_package_from_packaging(self, packaging): + package_type = packaging.package_storage_type_id + return { + "package_storage_type_id": package_type.id, + "packaging_id": packaging.id, + "lngth": packaging.lngth, + "width": packaging.width, + "height": packaging.height, + } + + def _create_and_assign_new_packaging(self, picking, selected_lines, packaging): + package = self.env["stock.quant.package"].create( + self._prepare_vals_package_from_packaging(packaging) + ) + return self._put_lines_in_allowed_package(picking, selected_lines, package) + + def scan_package_action(self, picking_id, selected_line_ids, barcode): """Scan a package, a lot, a product or a package to handle a line When a package is scanned, if the package is known as the destination package of one of the lines or is the source package of a selected line, the package is set to be the destination package of all then - selected lines. + lines to pack. When a product is scanned, it selects (set qty_done = reserved qty) or deselects (set qty_done = 0) the move lines for this product. Only @@ -586,10 +674,11 @@ def scan_package_action(self, picking_id, move_line_ids, barcode): on the lot. When a packaging type (one without related product) is scanned, a new - package is created and set as destination of the selected lines. + package is created and set as destination of the lines to pack. - Selected lines are move lines in the list of ``move_line_ids`` where - ``qty_done`` > 0 and have no destination package. + Lines to pack are move lines in the list of ``selected_line_ids`` + where ``qty_done`` > 0 and have not been packed yet + (``shopfloor_checkout_packed is False``). Transitions: * select_package: when a product or lot is scanned to select/deselect, @@ -600,9 +689,43 @@ def scan_package_action(self, picking_id, move_line_ids, barcode): * summary: if there is no other lines, go to the summary screen to be able to close the stock picking """ - return self._response() + picking = self.env["stock.picking"].browse(picking_id) + if not picking.exists(): + return self._response_stock_picking_does_not_exist() + search = self.actions_for("search") + message = self.actions_for("message") + + selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() - def new_package(self, picking_id, move_line_ids): + product = search.product_from_scan(barcode) + if product: + if product.tracking in ("lot", "serial"): + return self._response_for_select_package( + selected_lines, message=message.scan_lot_on_product_tracked_by_lot() + ) + product_lines = selected_lines.filtered(lambda l: l.product_id == product) + return self._switch_line_qty_done(picking, selected_lines, product_lines) + + lot = search.lot_from_scan(barcode) + if lot: + lot_lines = selected_lines.filtered(lambda l: l.lot_id == lot) + return self._switch_line_qty_done(picking, selected_lines, lot_lines) + + package = search.package_from_scan(barcode) + if package: + return self._put_lines_in_package(picking, selected_lines, package) + + packaging = search.generic_packaging_from_scan(barcode) + if packaging: + return self._create_and_assign_new_packaging( + picking, selected_lines, packaging + ) + + return self._response_for_select_package( + selected_lines, message=message.barcode_not_found() + ) + + def new_package(self, picking_id, selected_line_ids): """Add all selected lines in a new package It creates a new package and set it as the destination package of all @@ -666,7 +789,10 @@ def summary(self, picking_id): Transitions: * summary """ - return self._response() + picking = self.env["stock.picking"].browse(picking_id) + if not picking.exists(): + return self._response_stock_picking_does_not_exist() + return self._response_for_all_lines_packed(picking) def list_package_type(self, picking_id, package_id): """List the available package types for a package diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 77713a88a2..08f255a73e 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -15,3 +15,5 @@ from . import test_checkout_scan_line from . import test_checkout_select_line from . import test_checkout_set_qty +from . import test_checkout_scan_package_action +from . import test_checkout_summary diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index a3709d9329..0ad543068e 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -25,6 +25,12 @@ def setUpClass(cls, *args, **kwargs): cls.product_c_packaging = cls.env["product.packaging"].create( {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductCBox"} ) + cls.product_d = cls.env["product.product"].create( + {"name": "Product D", "type": "product", "barcode": "product_d"} + ) + cls.product_d_packaging = cls.env["product.packaging"].create( + {"name": "Box", "product_id": cls.product_d.id, "barcode": "ProductDBox"} + ) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout") cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py index b645e97d1a..4344b8f8a9 100644 --- a/shopfloor/tests/test_checkout_scan_line.py +++ b/shopfloor/tests/test_checkout_scan_line.py @@ -1,38 +1,8 @@ from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin -class CheckoutSelectLineCommonCase(CheckoutCommonCase): - def _assert_selected(self, response, selected_lines): - picking = selected_lines.mapped("picking_id") - unselected_lines = picking.move_line_ids - selected_lines - for line in selected_lines: - self.assertEqual( - line.qty_done, - line.product_uom_qty, - "Scanned lines must have their qty done set to the reserved quantity", - ) - for line in unselected_lines: - self.assertEqual(line.qty_done, 0) - self.assert_response( - response, - next_state="select_package", - data={ - "selected_move_lines": [ - self._move_line_data(ml) for ml in selected_lines - ], - "picking": { - "id": picking.id, - "name": picking.name, - "note": "", - "origin": "", - "line_count": len(picking.move_line_ids), - "partner": {"id": self.customer.id, "name": self.customer.name}, - }, - }, - ) - - -class CheckoutScanLineCase(CheckoutSelectLineCommonCase): +class CheckoutScanLineCase(CheckoutCommonCase, CheckoutSelectPackageMixin): def _test_scan_line_ok(self, barcode, selected_lines): """Test /scan_line with a valid return diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py new file mode 100644 index 0000000000..9aa13fd6e3 --- /dev/null +++ b/shopfloor/tests/test_checkout_scan_package_action.py @@ -0,0 +1,378 @@ +from itertools import product + +from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin + + +class CheckoutScanPackageActionCase(CheckoutCommonCase, CheckoutSelectPackageMixin): + def _test_select_product( + self, barcode_func, origin_qty_func, expected_qty_func, in_lot=False + ): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10), (self.product_c, 10)] + ) + for move_line in picking.move_lines: + # put in 3 different packages + self._fill_stock_for_moves(move_line, in_package=True, in_lot=in_lot) + picking.action_assign() + + # we have selected the pack that contains product a + line_a = picking.move_line_ids[0] + line_a.qty_done = origin_qty_func(line_a) + + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": line_a.ids, + "barcode": barcode_func(line_a), + }, + ) + + # since we scanned the barcode of the product and we had a + # qty_done, the qty_done should flip to 0 + self._assert_selected_qties( + response, line_a, {line_a: expected_qty_func(line_a)} + ) + + def test_scan_package_action_select_product(self): + self._test_select_product( + lambda l: l.product_id.barcode, lambda l: l.product_uom_qty, lambda __: 0 + ) + + def test_scan_package_action_deselect_product(self): + self._test_select_product( + lambda l: l.product_id.barcode, lambda __: 0, lambda l: l.product_uom_qty + ) + + def test_scan_package_action_select_product_packaging(self): + self._test_select_product( + lambda l: l.product_id.packaging_ids.barcode, + lambda l: l.product_uom_qty, + lambda __: 0, + ) + + def test_scan_package_action_deselect_product_packaging(self): + self._test_select_product( + lambda l: l.product_id.packaging_ids.barcode, + lambda __: 0, + lambda l: l.product_uom_qty, + ) + + def test_scan_package_action_select_product_lot(self): + self._test_select_product( + lambda l: l.lot_id.name, + lambda __: 0, + lambda l: l.product_uom_qty, + in_lot=True, + ) + + def test_scan_package_action_deselect_product_lot(self): + self._test_select_product( + lambda l: l.lot_id.name, + lambda l: l.product_uom_qty, + lambda __: 0, + in_lot=True, + ) + + def _test_scan_package_action_scan_product_error_tracked_by( + self, tracked_by, barcode + ): + self.product_a.tracking = tracked_by + picking = self._create_picking(lines=[(self.product_a, 1)]) + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + move_line = picking.move_line_ids + origin_qty_done = move_line.qty_done + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": move_line.ids, + "barcode": barcode, + }, + ) + self._assert_selected_qties( + response, + move_line, + # no change as the scan was not valid + {move_line: origin_qty_done}, + message={ + "message_type": "warning", + "message": "Product tracked by lot, please scan one.", + }, + ) + + def test_scan_package_action_scan_product_error_tracking(self): + trackings = ("lot", "serial") + barcodes = (self.product_a.barcode, self.product_a.packaging_ids.barcode) + for tracking, barcode in product(trackings, barcodes): + self._test_scan_package_action_scan_product_error_tracked_by( + tracking, barcode + ) + + def test_scan_package_action_scan_package_keep_source_package_ok(self): + picking = self._create_picking( + lines=[ + (self.product_a, 10), + (self.product_b, 10), + (self.product_c, 10), + (self.product_d, 10), + ] + ) + pack1_moves = picking.move_lines[:3] + pack2_moves = picking.move_lines[3:] + # put in 2 packs, for this test, we'll work on pack1 + self._fill_stock_for_moves(pack1_moves, in_package=True) + self._fill_stock_for_moves(pack2_moves, in_package=True) + picking.action_assign() + + selected_lines = pack1_moves.move_line_ids + pack1 = pack1_moves.move_line_ids.package_id + + move_line1, move_line2, move_line3 = selected_lines + # We'll put only product A and B in the package + move_line1.qty_done = move_line1.product_uom_qty + move_line2.qty_done = move_line2.product_uom_qty + move_line3.qty_done = 0 + + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": selected_lines.ids, + # we keep the goods in the same package, so we scan the source package + "barcode": pack1.name, + }, + ) + + self.assertRecordValues( + move_line1, + [{"result_package_id": pack1.id, "shopfloor_checkout_packed": True}], + ) + self.assertRecordValues( + move_line2, + [{"result_package_id": pack1.id, "shopfloor_checkout_packed": True}], + ) + self.assertRecordValues( + move_line3, + # qty_done was zero so we don't set it as packed + [{"result_package_id": pack1.id, "shopfloor_checkout_packed": False}], + ) + self.assert_response( + response, + # go pack to the screen to select lines to put in packages + next_state="select_line", + data={"picking": self._stock_picking_data(picking)}, + message={ + "message_type": "info", + "message": "Product(s) packed in {}".format(pack1.name), + }, + ) + + def test_scan_package_action_scan_package_error_invalid(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + move = picking.move_lines + self._fill_stock_for_moves(move, in_package=True) + picking.action_assign() + + selected_line = move.move_line_ids + other_package = self.env["stock.quant.package"].create({}) + + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": selected_line.ids, + "barcode": other_package.name, + }, + ) + + self.assertRecordValues( + selected_line, + [ + { + # the result package must remain identical, so equal to the + # source package + "result_package_id": selected_line.package_id.id, + "shopfloor_checkout_packed": False, + } + ], + ) + self._assert_selected_response( + response, + selected_line, + message={ + "message_type": "error", + "message": "Not a valid destination package", + }, + ) + + def test_scan_package_action_scan_package_use_existing_package_ok(self): + picking = self._create_picking( + lines=[ + (self.product_a, 10), + (self.product_b, 10), + (self.product_c, 10), + (self.product_d, 10), + ] + ) + pack1_moves = picking.move_lines[:3] + pack2_moves = picking.move_lines[3:] + # put in 2 packs, for this test, we'll work on pack1 + self._fill_stock_for_moves(pack1_moves, in_package=True) + self._fill_stock_for_moves(pack2_moves, in_package=True) + picking.action_assign() + + package = self.env["stock.quant.package"].create({}) + + # assume that product d was already put in a package, + # we must be able to put the lines of pack1 inside the same + pack2_moves.move_line_ids.write( + {"result_package_id": package.id, "shopfloor_checkout_packed": True} + ) + + selected_lines = pack1_moves.move_line_ids + # they are all selected + selected_lines.write({"qty_done": 10.0}) + + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": selected_lines.ids, + # use the package that was used for product D + "barcode": package.name, + }, + ) + + self.assertRecordValues( + selected_lines, + [ + {"result_package_id": package.id, "shopfloor_checkout_packed": True}, + {"result_package_id": package.id, "shopfloor_checkout_packed": True}, + {"result_package_id": package.id, "shopfloor_checkout_packed": True}, + ], + ) + + self.assert_response( + response, + # all the lines are packed, so we expect to go the summary screen + next_state="summary", + data={"picking": self._stock_picking_data(picking)}, + message={ + "message_type": "info", + "message": "Product(s) packed in {}".format(package.name), + }, + ) + + def test_scan_package_action_scan_packaging_ok(self): + picking = self._create_picking( + lines=[ + (self.product_a, 10), + (self.product_b, 10), + (self.product_c, 10), + (self.product_d, 10), + ] + ) + pack1_moves = picking.move_lines[:3] + pack2_moves = picking.move_lines[3:] + # put in 2 packs, for this test, we'll work on pack1 + self._fill_stock_for_moves(pack1_moves, in_package=True) + self._fill_stock_for_moves(pack2_moves, in_package=True) + picking.action_assign() + + selected_lines = pack1_moves.move_line_ids + pack1 = pack1_moves.move_line_ids.package_id + + move_line1, move_line2, move_line3 = selected_lines + # we'll put only the first 2 lines (product A and B) in the new package + move_line1.qty_done = move_line1.product_uom_qty + move_line2.qty_done = move_line2.product_uom_qty + move_line3.qty_done = 0 + + packaging_type = self.env["stock.package.storage.type"].create( + {"name": "Pallet"} + ) + packaging = self.env["product.packaging"].create( + { + "name": "Pallet", + "barcode": "PPP", + "height": 12, + "width": 13, + "lngth": 14, + "package_storage_type_id": packaging_type.id, + } + ) + + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": selected_lines.ids, + # create a new package using this packaging + "barcode": packaging.barcode, + }, + ) + + new_package = move_line1.result_package_id + self.assertNotEqual(pack1, new_package) + + self.assertRecordValues( + new_package, + [ + { + "package_storage_type_id": packaging_type.id, + "packaging_id": packaging.id, + "lngth": packaging.lngth, + "width": packaging.width, + "height": packaging.height, + } + ], + ) + + self.assertRecordValues( + move_line1, + [{"result_package_id": new_package.id, "shopfloor_checkout_packed": True}], + ) + self.assertRecordValues( + move_line2, + [{"result_package_id": new_package.id, "shopfloor_checkout_packed": True}], + ) + self.assertRecordValues( + move_line3, + # qty_done was zero so we don't set it as packed and it remains in + # the same package + [{"result_package_id": pack1.id, "shopfloor_checkout_packed": False}], + ) + self.assert_response( + response, + # go pack to the screen to select lines to put in packages + next_state="select_line", + data={"picking": self._stock_picking_data(picking)}, + message={ + "message_type": "info", + "message": "Product(s) packed in {}".format(new_package.name), + }, + ) + + def test_scan_package_action_scan_not_found(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + move = picking.move_lines + self._fill_stock_for_moves(move, in_package=True) + picking.action_assign() + selected_line = move.move_line_ids + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": selected_line.ids, + # create a new package using this packaging + "barcode": "BARCODE NOT FOUND", + }, + ) + self._assert_selected_response( + response, + selected_line, + message={"message_type": "error", "message": "Barcode not found"}, + ) diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py index 22066b1384..5db7c188a1 100644 --- a/shopfloor/tests/test_checkout_select_line.py +++ b/shopfloor/tests/test_checkout_select_line.py @@ -1,7 +1,8 @@ -from .test_checkout_scan_line import CheckoutSelectLineCommonCase +from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin -class CheckoutSelectLineCase(CheckoutSelectLineCommonCase): +class CheckoutSelectLineCase(CheckoutCommonCase, CheckoutSelectPackageMixin): @classmethod def setUpClass(cls): super().setUpClass() @@ -70,7 +71,7 @@ def _test_select_line_error(self, params, message): def test_select_line_package_error_not_found(self): selected_lines = self.move_single.move_line_ids - self.picking.do_unreserve() + selected_lines.unlink() self._test_select_line_error( {"picking_id": self.picking.id, "package_id": selected_lines[0].id}, { @@ -81,7 +82,7 @@ def test_select_line_package_error_not_found(self): def test_select_line_move_line_error_not_found(self): selected_lines = self.move_single.move_line_ids - self.picking.do_unreserve() + selected_lines.unlink() self._test_select_line_error( {"picking_id": self.picking.id, "move_line_id": selected_lines[0].id}, { diff --git a/shopfloor/tests/test_checkout_select_package_base.py b/shopfloor/tests/test_checkout_select_package_base.py new file mode 100644 index 0000000000..4a66c075e4 --- /dev/null +++ b/shopfloor/tests/test_checkout_select_package_base.py @@ -0,0 +1,46 @@ +class CheckoutSelectPackageMixin: + def _assert_selected_response(self, response, selected_lines, message=None): + picking = selected_lines.mapped("picking_id") + self.assert_response( + response, + next_state="select_package", + data={ + "selected_move_lines": [ + self._move_line_data(ml) for ml in selected_lines + ], + "picking": { + "id": picking.id, + "name": picking.name, + "note": "", + "origin": "", + "line_count": len(picking.move_line_ids), + "partner": {"id": self.customer.id, "name": self.customer.name}, + }, + }, + message=message, + ) + + def _assert_selected_qties( + self, response, selected_lines, lines_quantities, message=None + ): + picking = selected_lines.mapped("picking_id") + deselected_lines = picking.move_line_ids - selected_lines + self.assertEqual(selected_lines.ids, [l.id for l in lines_quantities]) + for line, quantity in lines_quantities.items(): + self.assertEqual(line.qty_done, quantity) + for line in deselected_lines: + self.assertEqual(line.qty_done, 0, "Lines deselected must have no qty done") + self._assert_selected_response(response, selected_lines, message=message) + + def _assert_selected(self, response, selected_lines, message=None): + picking = selected_lines.mapped("picking_id") + unselected_lines = picking.move_line_ids - selected_lines + for line in selected_lines: + self.assertEqual( + line.qty_done, + line.product_uom_qty, + "Scanned lines must have their qty done set to the reserved quantity", + ) + for line in unselected_lines: + self.assertEqual(line.qty_done, 0) + self._assert_selected_response(response, selected_lines, message=message) diff --git a/shopfloor/tests/test_checkout_set_qty.py b/shopfloor/tests/test_checkout_set_qty.py index 35bc8b7819..e2df7952a3 100644 --- a/shopfloor/tests/test_checkout_set_qty.py +++ b/shopfloor/tests/test_checkout_set_qty.py @@ -1,7 +1,8 @@ from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin -class CheckoutSetQtyCommonCase(CheckoutCommonCase): +class CheckoutSetQtyCommonCase(CheckoutCommonCase, CheckoutSelectPackageMixin): @classmethod def setUpClass(cls): super().setUpClass() @@ -28,35 +29,6 @@ def setUp(self): ) self.assertTrue(all(l.qty_done == 0 for l in self.deselected_lines)) - def _assert_selected_qties( - self, response, selected_lines, lines_quantities, message=None - ): - picking = selected_lines.mapped("picking_id") - deselected_lines = picking.move_line_ids - selected_lines - self.assertEqual(selected_lines.ids, [l.id for l in lines_quantities]) - for line, quantity in lines_quantities.items(): - self.assertEqual(line.qty_done, quantity) - for line in deselected_lines: - self.assertEqual(line.qty_done, 0, "Lines deselected must have no qty done") - self.assert_response( - response, - next_state="select_package", - data={ - "selected_move_lines": [ - self._move_line_data(ml) for ml in selected_lines - ], - "picking": { - "id": picking.id, - "name": picking.name, - "note": "", - "origin": "", - "line_count": len(picking.move_line_ids), - "partner": {"id": self.customer.id, "name": self.customer.name}, - }, - }, - message=message, - ) - class CheckoutResetLineQtyCase(CheckoutSetQtyCommonCase): def test_reset_line_qty_ok(self): diff --git a/shopfloor/tests/test_checkout_summary.py b/shopfloor/tests/test_checkout_summary.py new file mode 100644 index 0000000000..c58803c11c --- /dev/null +++ b/shopfloor/tests/test_checkout_summary.py @@ -0,0 +1,20 @@ +from .test_checkout_base import CheckoutCommonCase + + +class CheckoutSummaryCase(CheckoutCommonCase): + def test_summary_ok(self): + picking = self._create_picking( + lines=[ + (self.product_a, 10), + (self.product_b, 10), + (self.product_c, 10), + (self.product_d, 10), + ] + ) + response = self.service.dispatch("summary", params={"picking_id": picking.id}) + + self.assert_response( + response, + next_state="summary", + data={"picking": self._stock_picking_data(picking)}, + ) From 3fb0ae5b36055f690f1c39ecb37cb6198fc756c7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 20 Mar 2020 11:30:48 +0100 Subject: [PATCH 154/940] checkout: implement /new_package --- shopfloor/services/checkout.py | 22 +++++-- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_checkout_new_package.py | 62 ++++++++++++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 shopfloor/tests/test_checkout_new_package.py diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index b7fc8c3e75..916c779d08 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -652,10 +652,15 @@ def _prepare_vals_package_from_packaging(self, packaging): "height": packaging.height, } - def _create_and_assign_new_packaging(self, picking, selected_lines, packaging): - package = self.env["stock.quant.package"].create( - self._prepare_vals_package_from_packaging(packaging) - ) + def _prepare_vals_package_without_packaging(self): + return {} + + def _create_and_assign_new_packaging(self, picking, selected_lines, packaging=None): + if packaging: + vals = self._prepare_vals_package_from_packaging(packaging) + else: + vals = self._prepare_vals_package_without_packaging() + package = self.env["stock.quant.package"].create(vals) return self._put_lines_in_allowed_package(picking, selected_lines, package) def scan_package_action(self, picking_id, selected_line_ids, barcode): @@ -732,12 +737,17 @@ def new_package(self, picking_id, selected_line_ids): the selected lines. Selected lines are move lines in the list of ``move_line_ids`` where - ``qty_done`` > 0 and have no destination package. + ``qty_done`` > 0 and have no destination package + (shopfloor_checkout_packed is False). Transitions: * select_line: goes back to selection of lines to work on next lines """ - return self._response() + picking = self.env["stock.picking"].browse(picking_id) + if not picking.exists(): + return self._response_stock_picking_does_not_exist() + selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() + return self._create_and_assign_new_packaging(picking, selected_lines) def list_dest_package(self, picking_id, move_line_ids): """Return a list of packages the user can select for the lines diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 08f255a73e..920e2190e5 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -16,4 +16,5 @@ from . import test_checkout_select_line from . import test_checkout_set_qty from . import test_checkout_scan_package_action +from . import test_checkout_new_package from . import test_checkout_summary diff --git a/shopfloor/tests/test_checkout_new_package.py b/shopfloor/tests/test_checkout_new_package.py new file mode 100644 index 0000000000..bd62d52c49 --- /dev/null +++ b/shopfloor/tests/test_checkout_new_package.py @@ -0,0 +1,62 @@ +from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin + + +class CheckoutNewPackageCase(CheckoutCommonCase, CheckoutSelectPackageMixin): + def test_new_package_ok(self): + picking = self._create_picking( + lines=[ + (self.product_a, 10), + (self.product_b, 10), + (self.product_c, 10), + (self.product_d, 10), + ] + ) + pack1_moves = picking.move_lines[:3] + pack2_moves = picking.move_lines[3:] + # put in 2 packs, for this test, we'll work on pack1 + self._fill_stock_for_moves(pack1_moves, in_package=True) + self._fill_stock_for_moves(pack2_moves, in_package=True) + picking.action_assign() + + selected_lines = pack1_moves.move_line_ids + pack1 = pack1_moves.move_line_ids.package_id + + move_line1, move_line2, move_line3 = selected_lines + # we'll put only the first 2 lines (product A and B) in the new package + move_line1.qty_done = move_line1.product_uom_qty + move_line2.qty_done = move_line2.product_uom_qty + move_line3.qty_done = 0 + + response = self.service.dispatch( + "new_package", + params={"picking_id": picking.id, "selected_line_ids": selected_lines.ids}, + ) + + new_package = move_line1.result_package_id + self.assertNotEqual(pack1, new_package) + + self.assertRecordValues( + move_line1, + [{"result_package_id": new_package.id, "shopfloor_checkout_packed": True}], + ) + self.assertRecordValues( + move_line2, + [{"result_package_id": new_package.id, "shopfloor_checkout_packed": True}], + ) + self.assertRecordValues( + move_line3, + # qty_done was zero so we don't set it as packed and it remains in + # the same package + [{"result_package_id": pack1.id, "shopfloor_checkout_packed": False}], + ) + self.assert_response( + response, + # go pack to the screen to select lines to put in packages + next_state="select_line", + data={"picking": self._stock_picking_data(picking)}, + message={ + "message_type": "info", + "message": "Product(s) packed in {}".format(new_package.name), + }, + ) From 5f7966364367e5bdc26e0cc624751d02f23241bd Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 20 Mar 2020 11:40:16 +0100 Subject: [PATCH 155/940] checkout: add TODO comments --- shopfloor/services/checkout.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 916c779d08..2a72de2dd4 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -758,6 +758,8 @@ def list_dest_package(self, picking_id, move_line_ids): Transitions: * select_dest_package: selection screen """ + # TODO get list of all result_package_id | package_id of picking's move + # lines, return to 'select_dest_package' with the list return self._response() def scan_dest_package(self, picking_id, move_line_ids, barcode): @@ -777,6 +779,8 @@ def scan_dest_package(self, picking_id, move_line_ids, barcode): * select_line: lines to package remain * summary: all lines are put in packages """ + # TODO search for stock.quant.package with barcode, if found, call + # _put_lines_in_package return self._response() def set_dest_package(self, picking_id, move_line_ids, package_id): @@ -791,6 +795,7 @@ def set_dest_package(self, picking_id, move_line_ids, package_id): * select_line: lines to package remain * summary: all lines are put in packages """ + # TODO check if package still exists, call _put_lines_in_package return self._response() def summary(self, picking_id): @@ -813,6 +818,7 @@ def list_package_type(self, picking_id, package_id): Transitions: * change_package_type """ + # TODO list product.packaging where product_id is False return self._response() def set_package_type(self, picking_id, package_id, package_type_id): From 8b56d1017445b4b3c0ede8aec2b9a1b69d95d9fc Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 20 Mar 2020 15:07:36 +0100 Subject: [PATCH 156/940] shopfloor: prettify! --- shopfloor/demo/auth_api_key_demo.xml | 4 +- shopfloor/demo/shopfloor_menu_demo.xml | 13 +-- .../demo/shopfloor_operation_group_demo.xml | 7 +- shopfloor/demo/shopfloor_process_demo.xml | 13 +-- shopfloor/demo/shopfloor_profile_demo.xml | 7 +- shopfloor/demo/stock_picking_type_demo.xml | 85 +++++++++---------- shopfloor/views/menus.xml | 37 ++++++-- shopfloor/views/shopfloor_menu.xml | 15 ++-- shopfloor/views/shopfloor_operation_group.xml | 18 ++-- shopfloor/views/shopfloor_process.xml | 24 +++--- shopfloor/views/shopfloor_profile_views.xml | 28 +++--- shopfloor/views/stock_location.xml | 26 +++--- shopfloor/views/stock_picking_type.xml | 4 +- 13 files changed, 136 insertions(+), 145 deletions(-) diff --git a/shopfloor/demo/auth_api_key_demo.xml b/shopfloor/demo/auth_api_key_demo.xml index 800aec5f0b..7858d1d044 100644 --- a/shopfloor/demo/auth_api_key_demo.xml +++ b/shopfloor/demo/auth_api_key_demo.xml @@ -1,9 +1,7 @@ - Demo - + 72B044F7AC780DAC - diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml index 84e8e4f0c4..8d324b8762 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -1,27 +1,22 @@ - 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 4f8104efb5..8d549c1648 100644 --- a/shopfloor/demo/shopfloor_operation_group_demo.xml +++ b/shopfloor/demo/shopfloor_operation_group_demo.xml @@ -1,8 +1,9 @@ - - + HighBay - diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml index 844ec09df6..e16c9ff92e 100644 --- a/shopfloor/demo/shopfloor_process_demo.xml +++ b/shopfloor/demo/shopfloor_process_demo.xml @@ -1,27 +1,22 @@ - Put-Away Reach Truck single_pack_putaway - + - Single Pallet Transfer single_pack_transfer - + - Cluster Picking cluster_picking - + - Checkout checkout - + - diff --git a/shopfloor/demo/shopfloor_profile_demo.xml b/shopfloor/demo/shopfloor_profile_demo.xml index 5ef9df4179..743890335c 100644 --- a/shopfloor/demo/shopfloor_profile_demo.xml +++ b/shopfloor/demo/shopfloor_profile_demo.xml @@ -1,15 +1,12 @@ - Highbay Truck - + - Shelf 1 - + - diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml index 079bf0fb83..1fdf6ae096 100644 --- a/shopfloor/demo/stock_picking_type_demo.xml +++ b/shopfloor/demo/stock_picking_type_demo.xml @@ -1,67 +1,62 @@ - Put-Away Reach Truck PART - - - - - - - - + + + + + + + + internal - - + + - Single Pallet Transfer SPT - - - - - - - - + + + + + + + + internal - - + + - Cluster Picking CPI - - - - - - - - + + + + + + + + internal - - + + - Checkout CHK - - - - - - - - + + + + + + + + internal - - + + - diff --git a/shopfloor/views/menus.xml b/shopfloor/views/menus.xml index 71f19f9f78..6ece0c498f 100644 --- a/shopfloor/views/menus.xml +++ b/shopfloor/views/menus.xml @@ -1,8 +1,33 @@ - + - - - - - + + + + + diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index 016b872f25..d34c291c34 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -1,29 +1,26 @@ - + - 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 28fdc3ecc3..d4286df223 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 25821c60fc..5ec7e2fb58 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 26720fae2b..5f0fd5245b 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 3bb76f9884..b3afe523ee 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 c5ba2996ff..7d38e3074f 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 5311a1d39a756ddc161c38f7e3271b0f7c71fc2a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 24 Mar 2020 10:02:43 +0100 Subject: [PATCH 157/940] 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 8d324b8762..eaf4bb73e0 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 e16c9ff92e..0db0a727e4 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 1fdf6ae096..ded6d31b77 100644 --- a/shopfloor/demo/stock_picking_type_demo.xml +++ b/shopfloor/demo/stock_picking_type_demo.xml @@ -50,6 +50,21 @@ + + + + + + internal + + + + + Delivery + DEL + + + diff --git a/shopfloor/models/shopfloor_process.py b/shopfloor/models/shopfloor_process.py index 1492df892d..2eafef947c 100644 --- a/shopfloor/models/shopfloor_process.py +++ b/shopfloor/models/shopfloor_process.py @@ -19,4 +19,5 @@ def _selection_code(self): ("single_pack_transfer", "Single Pack Transfer"), ("cluster_picking", "Cluster Picking"), ("checkout", "Checkout"), + ("delivery", "Delivery"), ] diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 34f9243f4f..8908b1cb7c 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -13,6 +13,7 @@ # process services from . import checkout from . import cluster_picking +from . import delivery from . import picking_batch from . import single_pack_putaway from . import single_pack_transfer diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py new file mode 100644 index 0000000000..48ad6ff668 --- /dev/null +++ b/shopfloor/services/delivery.py @@ -0,0 +1,44 @@ +from odoo.addons.component.core import Component + + +class Delivery(Component): + """ + Methods for the Delivery Process + + TODO + + Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP + """ + + _inherit = "base.shopfloor.service" + _name = "shopfloor.delivery" + _usage = "delivery" + _description = __doc__ + + +class ShopfloorDeliveryValidator(Component): + """Validators for the Delivery endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.delivery.validator" + _usage = "delivery.validator" + + +class ShopfloorDeliveryValidatorResponse(Component): + """Validators for the Delivery endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.delivery.validator.response" + _usage = "delivery.validator.response" + + _start_state = "start" + + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + return { + "start": {}, + } diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 920e2190e5..9136abf5bb 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -18,3 +18,4 @@ from . import test_checkout_scan_package_action from . import test_checkout_new_package from . import test_checkout_summary +from . import test_delivery_base diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py new file mode 100644 index 0000000000..80c8bc0a91 --- /dev/null +++ b/shopfloor/tests/test_delivery_base.py @@ -0,0 +1,25 @@ +from .common import CommonCase + + +class DeliveryCommonCase(CommonCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_delivery") + cls.process = cls.menu.process_id + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + cls.wh = cls.profile.warehouse_id + cls.wh.delivery_steps = "pick_pack_ship" + cls.picking_type = cls.process.picking_type_id + + def setUp(self): + super().setUp() + with self.work_on_services(menu=self.menu, profile=self.profile) as work: + self.service = work.component(usage="delivery") + + +class DeliveryOpenAPICase(DeliveryCommonCase): + def test_to_openapi(self): + # will raise if it fails to generate the openapi specs + self.service.to_openapi() From 822d8289b4812f0efd4c85380133aca2ed6caa85 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 24 Mar 2020 10:17:19 +0100 Subject: [PATCH 158/940] Replace openapi tests by an auto-discovery test --- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_app.py | 4 ---- shopfloor/tests/test_checkout_base.py | 6 ----- shopfloor/tests/test_cluster_picking_base.py | 8 ------- shopfloor/tests/test_delivery_base.py | 6 ----- shopfloor/tests/test_menu.py | 4 ---- shopfloor/tests/test_openapi.py | 25 ++++++++++++++++++++ shopfloor/tests/test_picking_batch.py | 4 ---- shopfloor/tests/test_profile.py | 4 ---- shopfloor/tests/test_single_pack_putaway.py | 4 ---- shopfloor/tests/test_single_pack_transfer.py | 4 ---- 11 files changed, 26 insertions(+), 44 deletions(-) create mode 100644 shopfloor/tests/test_openapi.py diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 9136abf5bb..b01b5ef354 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -1,5 +1,6 @@ from . import test_app from . import test_menu +from . import test_openapi from . import test_profile from . import test_picking_batch from . import test_single_pack_putaway diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py index ff29c176a3..f64bb0f91e 100644 --- a/shopfloor/tests/test_app.py +++ b/shopfloor/tests/test_app.py @@ -7,10 +7,6 @@ def setUp(self): with self.work_on_services() as work: self.service = work.component(usage="app") - def test_to_openapi(self): - # will raise if it fails to generate the openapi specs - self.service.to_openapi() - def test_user_config(self): """Request /app/user_config""" # Simulate the client asking the configuration diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index 0ad543068e..3e55e9ae85 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -64,9 +64,3 @@ def _stock_picking_data(self, picking): def _move_line_data(self, move_line): return self.service._data_for_move_line(move_line) - - -class CheckoutOpenAPICase(CheckoutCommonCase): - def test_to_openapi(self): - # will raise if it fails to generate the openapi specs - self.service.to_openapi() diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py index f44f3b8a80..3d52804aff 100644 --- a/shopfloor/tests/test_cluster_picking_base.py +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -102,14 +102,6 @@ def _set_dest_package_and_done(cls, move_lines, dest_package): ) -class ClusterPickingAPICase(ClusterPickingCommonCase): - """Base tests for the cluster picking API""" - - def test_to_openapi(self): - # will raise if it fails to generate the openapi specs - self.service.to_openapi() - - class ClusterPickingLineCommonCase(ClusterPickingCommonCase): @classmethod def setUpClass(cls, *args, **kwargs): diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py index 80c8bc0a91..27d645a703 100644 --- a/shopfloor/tests/test_delivery_base.py +++ b/shopfloor/tests/test_delivery_base.py @@ -17,9 +17,3 @@ def setUp(self): super().setUp() with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="delivery") - - -class DeliveryOpenAPICase(DeliveryCommonCase): - def test_to_openapi(self): - # will raise if it fails to generate the openapi specs - self.service.to_openapi() diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py index 685850002b..a1a86ca74d 100644 --- a/shopfloor/tests/test_menu.py +++ b/shopfloor/tests/test_menu.py @@ -7,10 +7,6 @@ def setUp(self): with self.work_on_services() as work: self.service = work.component(usage="menu") - def test_to_openapi(self): - # will raise if it fails to generate the openapi specs - self.service.to_openapi() - def test_menu_search(self): """Request /menu/search""" # Simulate the client searching menus diff --git a/shopfloor/tests/test_openapi.py b/shopfloor/tests/test_openapi.py new file mode 100644 index 0000000000..5eff2b327c --- /dev/null +++ b/shopfloor/tests/test_openapi.py @@ -0,0 +1,25 @@ +from .common import CommonCase + + +class TestOpenAPICommonCase(CommonCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + + # we don't really care about which menu and profile we use + # to read the OpenAPI specs + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_delivery") + cls.process = cls.menu.process_id + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + + def setUp(self): + super().setUp() + + def test_openapi(self): + with self.work_on_services(menu=self.menu, profile=self.profile) as work: + services = work.many_components() + for service in services: + if not service._is_rest_service_component: + continue + # will raise if it fails to generate the openapi specs + service.to_openapi() diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py index 2be2ab16c8..bf76635a82 100644 --- a/shopfloor/tests/test_picking_batch.py +++ b/shopfloor/tests/test_picking_batch.py @@ -44,10 +44,6 @@ def setUp(self): with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="picking_batch") - def test_to_openapi(self): - # will raise if it fails to generate the openapi specs - self.service.to_openapi() - def test_search_empty(self): """No batch is available""" # Simulate the client asking the list of picking batch diff --git a/shopfloor/tests/test_profile.py b/shopfloor/tests/test_profile.py index 7a2fc1f4e0..a3eb4489a5 100644 --- a/shopfloor/tests/test_profile.py +++ b/shopfloor/tests/test_profile.py @@ -7,10 +7,6 @@ def setUp(self): with self.work_on_services() as work: self.service = work.component(usage="profile") - def test_to_openapi(self): - # will raise if it fails to generate the openapi specs - self.service.to_openapi() - def test_profile_search(self): """Request /profile/search""" # Simulate the client searching profiles diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 55cbaca4d3..cada111056 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -38,10 +38,6 @@ def setUp(self): with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="single_pack_putaway") - def test_to_openapi(self): - # will raise if it fails to generate the openapi specs - self.service.to_openapi() - def test_start(self): """Test the happy path for single pack putaway /start endpoint diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index c4c0a69cb0..c5c49c4261 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -61,10 +61,6 @@ def _simulate_started(self): package_level.is_done = True return package_level - def test_to_openapi(self): - # will raise if it fails to generate the openapi specs - self.service.to_openapi() - def test_start(self): """Test the happy path for single pack transfer /start endpoint From 431f84dabab52fdab62ff15c9b54fd49c731711e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 24 Mar 2020 14:41:22 +0100 Subject: [PATCH 159/940] delivery: add specifications --- shopfloor/services/delivery.py | 282 ++++++++++++++++++++++++++++++++- 1 file changed, 281 insertions(+), 1 deletion(-) diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 48ad6ff668..6db8933d8d 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -1,3 +1,4 @@ +from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -5,7 +6,16 @@ class Delivery(Component): """ Methods for the Delivery Process - TODO + Deliver the goods by processing the PACK and raw products by delivery order. + Last step in the pick/pack/ship steps. (Cluster Picking → Checkout → Delivery) + + Multiple operators could be processing a same delivery order. + + Expected: + + * Existing packages are moved to customer location + * Products are moved to customer location as raw products + * Bin packed products are placed in new shipping package and shipped to customer Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP """ @@ -15,6 +25,151 @@ class Delivery(Component): _usage = "delivery" _description = __doc__ + # TODO we don't know yet if we have to select a destination package or a + # destination location + def xxx(self, barcode): + return self._result() + + # TODO we'll probably to add the selected destination package or location + # id in every endpoint parameters and returns + def scan_stock_picking(self, barcode): + """Scan a stock picking or a package + + When a package is scanned, and has an available move line part of the + expected picking type, the package level is directly set to "done" and + the stock picking of the line is returned to work on its other lines. + + When a stock picking is scanned and is partially or fully available, it + is returned to work on its lines. + + When all the available move lines and package levels of the stock picking + are done, the user is directed to the summary screen. + + Transitions: + * select_source: error when scanning (stock picking not available, ...) + * move_set_done: when a valid package or stock picking has been scanned + and the stock picking still have lines / package levels not done + * summary: when a valid package or stock picking has been scanned + and all the lines of the stock picking are done + """ + return self._response() + + def list_stock_picking(self): + """Return the list of stock pickings for the picking type + + It returns only stock picking available or partially available. + + Transitions: + * manual_selection: next state to show the list of stock pickings + """ + return self._response() + + def select(self, picking_id): + """Select a stock picking from its ID (found using /list_stock_picking) + + It returns only stock picking available or partially available. + + Transitions: + * manual_selection: the selected stock picking is no longer valid + * move_set_done: the selected stock picking has lines not done + * summary: the selected stock picking has all lines done + """ + return self._response() + + def set_qty_done_pack(self, picking_id, package_id): + """Set a package to "Done" + + Transitions: + * move_set_done: error when setting done, or success but the stock + picking has other lines to set done + * summary: all the lines of the stock picking are now done + """ + return self._response() + + def set_qty_done_line(self, picking_id, line_id): + """Set a move line to "Done" + + Should be called only for lines of raw products, /set_qty_done_pack + must be used for lines that move a package. + + Transitions: + * move_set_done: error when setting done, or success but the stock + picking has other lines to set done + * summary: all the lines of the stock picking are now done + """ + return self._response() + + def scan_line(self, picking_id, barcode): + """Set a move line or package to "Done" from a barcode + + If the barcode is a package in the picking and is available, it + sets it to done. + + If the barcode is a product or a product's packaging, the move lines + for this product are set to done. However, if the product is in more + than one package, a package barcode is requested, and if the product is + tracked by lot/serial, a lot is asked. + + If the barcode is a lot, the mbarcode ove lines for this lot are set to + done. However, if the lot is in more than one package, a package + barcode is requested. + + NOTE: see scan_line in the Checkout service. + + Transitions: + * move_set_done: error when setting done, or success but the stock + picking has other lines to set done + * summary: all the lines of the stock picking are now done + """ + return self._response() + + def summary(self, picking_id): + """Return data for the summary screen + + Transitions: + * summary + """ + return self._response() + + def reset_qty_done_pack(self, picking_id, package_id): + """Remove "Done" on a package + + Transitions: + * summary: return back to this state + """ + return self._response() + + def reset_qty_done_line(self, picking_id, line_id): + """Remove "Done" on a move line + + Should be called only for lines of raw products, /set_qty_done_pack + must be used for lines that move a package. + + Transitions: + * summary: return back to this state + """ + return self._response() + + def done(self, picking_id): + """Set the stock picking to done + + Transitions: + * summary: error during action + * select_source: stock picking was set to done, user can work on the + next stock picking + """ + return self._response() + + def back_to_move_set_done(self, picking_id): + """Allow to return to the "move_set_done" state with refreshed data + + Transitions: + * move_set_done: return to this state when not all lines of the + stock picking are done + * summary: return back to this state when all lines are done + """ + return self._response() + class ShopfloorDeliveryValidator(Component): """Validators for the Delivery endpoints""" @@ -23,6 +178,58 @@ class ShopfloorDeliveryValidator(Component): _name = "shopfloor.delivery.validator" _usage = "delivery.validator" + # TODO + def xxx(self): + return {} + + def scan_stock_picking(self): + return {"barcode": {"required": True, "type": "string"}} + + def list_stock_picking(self): + return {} + + def select(self): + return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def set_qty_done_pack(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def set_qty_done_line(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def scan_line(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def summary(self): + return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def reset_qty_done_pack(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def reset_qty_done_line(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def done(self): + return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def back_to_move_set_done(self): + return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}} + class ShopfloorDeliveryValidatorResponse(Component): """Validators for the Delivery endpoints responses""" @@ -41,4 +248,77 @@ def _states(self): """ return { "start": {}, + "select_source": self._schema_select_source, + "manual_selection": self._schema_selection_list, + "move_set_done": self._schema_stock_picking_details, + "summary": self._schema_stock_picking_details, + "confirm_summary": self._schema_stock_picking_details, } + + # TODO add the selected dest. package or location in returns (to keep it stateless) + @property + def _schema_stock_picking_details(self): + schema = self.schemas().picking() + schema.update( + { + "move_lines": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas().move_line()}, + } + } + ) + return {"picking": {"type": "dict", "schema": schema}} + + @property + def _schema_selection_list(self): + return { + "pickings": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas().picking()}, + } + } + + @property + def _schema_select_source(self): + # TODO we don't know yet if we want to show the dest. location or package + return {} + + def xxx(self): + return self._response_schema(next_states={"start", "select_source"}) + + def scan_stock_picking(self): + return self._response_schema( + next_states={"select_source", "move_set_done", "summary"} + ) + + def list_stock_picking(self): + return self._response_schema(next_states={"manual_selection"}) + + def select(self): + return self._response_schema( + next_states={"manual_selection", "move_set_done", "summary"} + ) + + def set_qty_done_pack(self): + return self._response_schema(next_states={"move_set_done", "summary"}) + + def set_qty_done_line(self): + return self._response_schema(next_states={"move_set_done", "summary"}) + + def scan_line(self): + return self._response_schema(next_states={"move_set_done", "summary"}) + + def summary(self): + return self._response_schema(next_states={"summary"}) + + def reset_qty_done_pack(self): + return self._response_schema(next_states={"summary"}) + + def reset_qty_done_line(self): + return self._response_schema(next_states={"summary"}) + + def done(self): + return self._response_schema(next_states={"confirm_summary", "start"}) + + def back_to_move_set_done(self): + return self._response_schema(next_states={"move_set_done", "summary"}) From 4cd2493e00ab23224a7c91cdd65a681ef7aca299 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 25 Mar 2020 12:42:46 +0100 Subject: [PATCH 160/940] cluster picking: refactor /scan_line to prepare for new requirements --- shopfloor/services/cluster_picking.py | 175 ++++++++++++-------------- 1 file changed, 82 insertions(+), 93 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index e0fd21f207..1931512486 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -113,8 +113,7 @@ def list_batch(self): def _select_a_picking_batch(self, batches): # look for in progress + assigned to self first candidates = batches.filtered( - lambda batch: batch.state == "in_progress" - and batch.user_id == self.env.user + lambda batch: batch.state == "in_progress" and batch.user_id == self.env.user ) if candidates: return candidates[0] @@ -220,11 +219,7 @@ def _pick_next_line(self, batch, message=None): next_line = self._next_line_for_pick(batch) if not next_line: return self.prepare_unload(batch.id) - return self._response( - next_state="start_line", - data=self._data_move_line(next_line), - message=message, - ) + return self._response_for_start_line(next_line, message=message) def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x): lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(filter_func) @@ -355,85 +350,92 @@ def scan_line(self, move_line_id, barcode): return self._response( next_state="start", message=message.unrecoverable_error() ) - if move_line.package_id.name == barcode: - return self._response_for_scan_destination(move_line) - # TODO should search for product packaging too - elif move_line.product_id.barcode == barcode: - if move_line.product_id.tracking in ("lot", "serial"): - return self._response_for_scan_line_product_need_lot(move_line) - return self._response_for_scan_destination(move_line) - elif move_line.lot_id.name == barcode: - return self._response_for_scan_destination(move_line) - elif move_line.location_id.barcode == barcode: - # When a user scan a location, we accept only when we knows that - # they scanned the good thing, so if in the location we have - # several lots (on a package or a product), several packages, - # several products or a mix of several products and packages, we - # ask to scan a more precise barcode. - location = move_line.location_id - packages = set() - products = set() - lots = set() - for quant in location.quant_ids: - if quant.quantity <= 0: - continue - if quant.package_id: - packages.add(quant.package_id) - else: - products.add(quant.product_id) - if quant.lot_id: - lots.add(quant.lot_id) - - if len(lots) > 1: - return self._response_for_scan_line_several_lots_in_loc(move_line) - if len(packages | products) > 1: - if move_line.package_id: - return self._response_for_scan_line_several_packages_in_loc( - move_line - ) - else: - return self._response_for_scan_line_several_products_in_loc( - move_line - ) - - return self._response_for_scan_destination(move_line) - return self._response( - next_state="start_line", - data=self._data_move_line(move_line), - message=message.barcode_not_found(), - ) + search = self.actions_for("search") - def _response_for_scan_line_several_lots_in_loc(self, move_line): - message = self.actions_for("message") - return self._response( - next_state="start_line", - data=self._data_move_line(move_line), - message=message.several_lots_in_location(move_line.location_id), - ) + picking = move_line.picking_id - def _response_for_scan_line_several_products_in_loc(self, move_line): - message = self.actions_for("message") - return self._response( - next_state="start_line", - data=self._data_move_line(move_line), - message=message.several_products_in_location(move_line.location_id), + package = search.package_from_scan(barcode) + if package and move_line.package_id == package: + return self._scan_line_by_package(picking, move_line, package) + + # use the common search method so we search by packaging too + product = search.product_from_scan(barcode) + if product and move_line.product_id == product: + return self._scan_line_by_product(picking, move_line, product) + + lot = search.lot_from_scan(barcode) + if lot and move_line.lot_id == lot: + return self._scan_line_by_lot(picking, move_line, lot) + + location = search.location_from_scan(barcode) + if location and move_line.location_id == location: + return self._scan_line_by_location(picking, move_line, location) + + return self._response_for_start_line( + move_line, message=message.barcode_not_found() ) - def _response_for_scan_line_several_packages_in_loc(self, move_line): + def _scan_line_by_package(self, picking, move_line, package): + return self._response_for_scan_destination(move_line) + + def _scan_line_by_product(self, picking, move_line, product): message = self.actions_for("message") - return self._response( - next_state="start_line", - data=self._data_move_line(move_line), - message=message.several_packs_in_location(move_line.location_id), - ) + if move_line.product_id.tracking in ("lot", "serial"): + return self._response_for_start_line( + move_line, message=message.scan_lot_on_product_tracked_by_lot() + ) + return self._response_for_scan_destination(move_line) - def _response_for_scan_line_product_need_lot(self, move_line): + def _scan_line_by_lot(self, picking, move_line, lot): + return self._response_for_scan_destination(move_line) + + def _scan_line_by_location(self, picking, move_line, location): message = self.actions_for("message") + # When a user scan a location, we accept only when we knows that + # they scanned the good thing, so if in the location we have + # several lots (on a package or a product), several packages, + # several products or a mix of several products and packages, we + # ask to scan a more precise barcode. + location = move_line.location_id + packages = set() + products = set() + lots = set() + for quant in location.quant_ids: + if quant.quantity <= 0: + continue + if quant.package_id: + packages.add(quant.package_id) + else: + products.add(quant.product_id) + if quant.lot_id: + lots.add(quant.lot_id) + + if len(lots) > 1: + return self._response_for_start_line( + move_line, + message=message.several_lots_in_location(move_line.location_id), + ) + + if len(packages | products) > 1: + if move_line.package_id: + return self._response_for_start_line( + move_line, + message=message.several_packs_in_location(move_line.location_id), + ) + else: + return self._response_for_start_line( + move_line, + message=message.several_products_in_location(move_line.location_id), + ) + + return self._response_for_scan_destination(move_line) + + def _response_for_start_line(self, move_line, message=None): return self._response( next_state="start_line", data=self._data_move_line(move_line), - message=message.scan_lot_on_product_tracked_by_lot(), + message=message, ) def _response_for_scan_destination(self, move_line, message=None): @@ -817,9 +819,7 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): return self._response_for_unload_all( batch, message=message.no_location_found() ) - if not scanned_location.is_sublocation_of( - picking_type.default_location_dest_id - ): + if not scanned_location.is_sublocation_of(picking_type.default_location_dest_id): return self._response_for_unload_all( batch, message=message.dest_location_not_allowed() ) @@ -852,9 +852,7 @@ def _unload_end(self, batch): next_line = self._next_line_for_pick(batch) if next_line: - return self._response( - next_state="start_line", data=self._data_move_line(next_line) - ) + return self._response_for_start_line(next_line) else: # TODO add tests for this (for instance a picking is not 'done' # because a move was unassigned, we want to validate the batch to @@ -866,10 +864,7 @@ def _unload_end(self, batch): def _response_batch_complete(self): return self._response( next_state="start", - message={ - "message_type": "success", - "message": _("Batch Transfer complete"), - }, + message={"message_type": "success", "message": _("Batch Transfer complete")}, ) def unload_split(self, picking_batch_id): @@ -992,9 +987,7 @@ def unload_scan_destination( return self._response_for_unload_set_destination( batch, package, message=message.no_location_found() ) - if not scanned_location.is_sublocation_of( - picking_type.default_location_dest_id - ): + if not scanned_location.is_sublocation_of(picking_type.default_location_dest_id): return self._response_for_unload_set_destination( batch, package, message=message.dest_location_not_allowed() ) @@ -1301,11 +1294,7 @@ def _schema_for_batch_details(self): "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, "move_line_count": {"required": True, "type": "integer"}, - "weight": { - "type": "float", - "nullable": False, - "required": True, - }, + "weight": {"type": "float", "nullable": False, "required": True}, "origin": { "type": "string", "nullable": False, From 49d73c51e5d8642a13823842c0064d679d75dfdd Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 25 Mar 2020 14:00:48 +0100 Subject: [PATCH 161/940] cluster picking: prevent scanning product/lot with packages When a product or a lot is scanned and the stock picking may have more than one package for the product or the lot, ask to scan the package. --- shopfloor/services/cluster_picking.py | 50 ++++++++++-- shopfloor/tests/test_cluster_picking_scan.py | 83 ++++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 1931512486..a81634f37d 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -113,7 +113,8 @@ def list_batch(self): def _select_a_picking_batch(self, batches): # look for in progress + assigned to self first candidates = batches.filtered( - lambda batch: batch.state == "in_progress" and batch.user_id == self.env.user + lambda batch: batch.state == "in_progress" + and batch.user_id == self.env.user ) if candidates: return candidates[0] @@ -385,9 +386,37 @@ def _scan_line_by_product(self, picking, move_line, product): return self._response_for_start_line( move_line, message=message.scan_lot_on_product_tracked_by_lot() ) + + # if we scanned a product and it's part of several packages, we can't be + # sure the user scanned the correct one, in such case, ask to scan a package + other_product_lines = picking.move_line_ids.filtered( + lambda l: l.product_id == product + ) + packages = other_product_lines.mapped("package_id") + # Do not use mapped here: we want to see if we have more than one package, + # but also if we have one product as a package and the same product as + # a unit in another line. In both cases, we want the user to scan the + # package. + if packages and len({l.package_id for l in other_product_lines}) > 1: + return self._response_for_start_line( + move_line, message=message.product_multiple_packages_scan_package() + ) return self._response_for_scan_destination(move_line) def _scan_line_by_lot(self, picking, move_line, lot): + message = self.actions_for("message") + # if we scanned a lot and it's part of several packages, we can't be + # sure the user scanned the correct one, in such case, ask to scan a package + other_lot_lines = picking.move_line_ids.filtered(lambda l: l.lot_id == lot) + packages = other_lot_lines.mapped("package_id") + # Do not use mapped here: we want to see if we have more than one + # package, but also if we have one lot as a package and the same lot as + # a unit in another line. In both cases, we want the user to scan the + # package. + if packages and len({l.package_id for l in other_lot_lines}) > 1: + return self._response_for_start_line( + move_line, message=message.lot_multiple_packages_scan_package() + ) return self._response_for_scan_destination(move_line) def _scan_line_by_location(self, picking, move_line, location): @@ -819,7 +848,9 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): return self._response_for_unload_all( batch, message=message.no_location_found() ) - if not scanned_location.is_sublocation_of(picking_type.default_location_dest_id): + if not scanned_location.is_sublocation_of( + picking_type.default_location_dest_id + ): return self._response_for_unload_all( batch, message=message.dest_location_not_allowed() ) @@ -864,7 +895,10 @@ def _unload_end(self, batch): def _response_batch_complete(self): return self._response( next_state="start", - message={"message_type": "success", "message": _("Batch Transfer complete")}, + message={ + "message_type": "success", + "message": _("Batch Transfer complete"), + }, ) def unload_split(self, picking_batch_id): @@ -987,7 +1021,9 @@ def unload_scan_destination( return self._response_for_unload_set_destination( batch, package, message=message.no_location_found() ) - if not scanned_location.is_sublocation_of(picking_type.default_location_dest_id): + if not scanned_location.is_sublocation_of( + picking_type.default_location_dest_id + ): return self._response_for_unload_set_destination( batch, package, message=message.dest_location_not_allowed() ) @@ -1294,7 +1330,11 @@ def _schema_for_batch_details(self): "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, "move_line_count": {"required": True, "type": "integer"}, - "weight": {"type": "float", "nullable": False, "required": True}, + "weight": { + "type": "float", + "nullable": False, + "required": True, + }, "origin": { "type": "string", "nullable": False, diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index 93682b07ff..5192134661 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -73,6 +73,89 @@ def test_scan_line_error_product_tracked(self): }, ) + def test_scan_line_product_error_several_packages(self): + """When we scan a product which is in more than one package, error""" + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + # create a second move line for the same product in a different + # package + move = line.move_id.copy() + self._fill_stock_for_moves(move, in_package=True) + move._action_confirm(merge=False) + move._action_assign() + + self._scan_line_error( + line, + move.product_id.barcode, + { + "message_type": "warning", + "message": "This product is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_product_error_in_one_package_and_unit(self): + """When we scan a product which is in a package and as raw, error""" + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + # create a second move line for the same product in a different + # package + move = line.move_id.copy() + self._fill_stock_for_moves(move) + move._action_confirm(merge=False) + move._action_assign() + + self._scan_line_error( + line, + move.product_id.barcode, + { + "message_type": "warning", + "message": "This product is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_lot_error_several_packages(self): + """When we scan a lot which is in more than one package, error""" + self._simulate_batch_selected(self.batch, in_package=True, in_lot=True) + line = self.batch.picking_ids.move_line_ids + # create a second move line for the same product in a different + # package + move = line.move_id.copy() + self._fill_stock_for_moves(move, in_lot=line.lot_id) + move._action_confirm(merge=False) + move._action_assign() + + self._scan_line_error( + line, + line.lot_id.name, + { + "message_type": "warning", + "message": "This lot is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_lot_error_in_one_package_and_unit(self): + """When we scan a lot which is in a package and as raw, error""" + self._simulate_batch_selected(self.batch, in_package=True, in_lot=True) + line = self.batch.picking_ids.move_line_ids + # create a second move line for the same product in a different + # package + move = line.move_id.copy() + self._fill_stock_for_moves(move, in_lot=line.lot_id) + move._action_confirm(merge=False) + move._action_assign() + self._scan_line_error( + line, + line.lot_id.name, + { + "message_type": "warning", + "message": "This lot is part of multiple" + " packages, please scan a package.", + }, + ) + def test_scan_line_location_ok_single_package(self): """Scan to check if user scans a correct location for current line From 4e6b5c1af87f3fda4f769bc070953eaeee1946d3 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 25 Mar 2020 14:35:09 +0100 Subject: [PATCH 162/940] checkout: fix flaky test --- shopfloor/services/checkout.py | 2 +- shopfloor/tests/test_checkout_set_qty.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 2a72de2dd4..ab14dd3294 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -296,7 +296,7 @@ def _response_for_select_package(self, lines, message=None): next_state="select_package", data={ "selected_move_lines": [ - self._data_for_move_line(line) for line in lines + self._data_for_move_line(line) for line in lines.sorted() ], "picking": self._data_picking_base(picking), }, diff --git a/shopfloor/tests/test_checkout_set_qty.py b/shopfloor/tests/test_checkout_set_qty.py index e2df7952a3..d6a91c9028 100644 --- a/shopfloor/tests/test_checkout_set_qty.py +++ b/shopfloor/tests/test_checkout_set_qty.py @@ -147,7 +147,7 @@ def test_set_custom_qty_ok(self): {line_to_change: new_qty, line_keep_qty: line_keep_qty.product_uom_qty}, ) - def test_set_custem_qty_not_found(self): + def test_set_custom_qty_not_found(self): selected_lines = self.moves_pack1.move_line_ids response = self.service.dispatch( "set_custom_qty", From bcb0395f0c5e9f671e4a26de379dc3c0e53ab60c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 26 Mar 2020 08:07:28 +0100 Subject: [PATCH 163/940] checkout: implement /list_dest_package --- shopfloor/services/checkout.py | 62 +++++++++++++-- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_checkout_base.py | 3 + shopfloor/tests/test_checkout_list_package.py | 78 +++++++++++++++++++ 4 files changed, 137 insertions(+), 7 deletions(-) create mode 100644 shopfloor/tests/test_checkout_list_package.py diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index ab14dd3294..3f5cd577d5 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -749,7 +749,21 @@ def new_package(self, picking_id, selected_line_ids): selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() return self._create_and_assign_new_packaging(picking, selected_lines) - def list_dest_package(self, picking_id, move_line_ids): + def _data_package(self, picking, package): + line_count = len( + picking.move_line_ids.filtered(lambda l: l.package_id == package) + ) + return { + "id": package.id, + "name": package.name, + # TODO + "weight": 0, + "line_count": line_count, + # TODO + "package_type_name": "", + } + + def list_dest_package(self, picking_id, selected_line_ids): """Return a list of packages the user can select for the lines Only valid packages must be proposed. Look at ``scan_dest_package`` @@ -757,12 +771,44 @@ def list_dest_package(self, picking_id, move_line_ids): Transitions: * select_dest_package: selection screen + * select_package: when no package is available """ - # TODO get list of all result_package_id | package_id of picking's move - # lines, return to 'select_dest_package' with the list - return self._response() + picking = self.env["stock.picking"].browse(picking_id) + if not picking.exists(): + return self._response_stock_picking_does_not_exist() + + lines = self.env["stock.move.line"].browse(selected_line_ids).exists() + + packages = picking.mapped("move_line_ids.package_id") | picking.mapped( + "move_line_ids.result_package_id" + ) + if not packages: + return self._response_for_select_package( + lines, + message={ + "message_type": "warning", + "message": _("No valid package to select."), + }, + ) + picking_data = self._data_picking_base(picking) + packages_data = [ + self._data_package(picking, package) for package in packages.sorted() + ] - def scan_dest_package(self, picking_id, move_line_ids, barcode): + return self._response( + next_state="select_dest_package", + data={ + "picking": picking_data, + "packages": packages_data, + "selected_move_lines": [ + # TODO factorize + self._data_for_move_line(line) + for line in lines.sorted() + ], + }, + ) + + def scan_dest_package(self, picking_id, selected_line_ids, barcode): """Scan destination package for lines Set the destination package on the selected lines with a `qty_done` if @@ -783,7 +829,7 @@ def scan_dest_package(self, picking_id, move_line_ids, barcode): # _put_lines_in_package return self._response() - def set_dest_package(self, picking_id, move_line_ids, package_id): + def set_dest_package(self, picking_id, selected_line_ids, package_id): """Set destination package for lines from a package id Used by the list obtained from ``list_dest_package``. @@ -1125,7 +1171,9 @@ def new_package(self): return self._response_schema(next_states={"select_line"}) def list_dest_package(self): - return self._response_schema(next_states={"select_dest_package"}) + return self._response_schema( + next_states={"select_dest_package", "select_package"} + ) def scan_dest_package(self): return self._response_schema( diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index b01b5ef354..709fa5cd76 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -18,5 +18,6 @@ from . import test_checkout_set_qty from . import test_checkout_scan_package_action from . import test_checkout_new_package +from . import test_checkout_list_package from . import test_checkout_summary from . import test_delivery_base diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index 3e55e9ae85..8053ee6ace 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -64,3 +64,6 @@ def _stock_picking_data(self, picking): def _move_line_data(self, move_line): return self.service._data_for_move_line(move_line) + + def _package_data(self, picking, package): + return self.service._data_package(picking, package) diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py new file mode 100644 index 0000000000..2b70308bce --- /dev/null +++ b/shopfloor/tests/test_checkout_list_package.py @@ -0,0 +1,78 @@ +from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin + + +class CheckoutListDestPackageCase(CheckoutCommonCase, CheckoutSelectPackageMixin): + def test_list_dest_package_ok(self): + picking = self._create_picking( + lines=[ + (self.product_a, 10), + (self.product_b, 10), + (self.product_c, 10), + (self.product_d, 10), + ] + ) + self._fill_stock_for_moves(picking.move_lines[:2], in_package=True) + self._fill_stock_for_moves(picking.move_lines[2], in_package=True) + self._fill_stock_for_moves(picking.move_lines[3], in_package=True) + picking.action_assign() + new_package = self.env["stock.quant.package"].create({}) + picking.move_lines[1].move_line_ids.result_package_id = new_package + + packages = picking.mapped("move_line_ids.package_id") | new_package + + response = self.service.dispatch( + "list_dest_package", + params={ + "picking_id": picking.id, + "selected_line_ids": picking.move_line_ids.ids, + }, + ) + self.assert_response( + response, + next_state="select_dest_package", + data={ + "picking": { + "id": picking.id, + "name": picking.name, + "note": "", + "origin": "", + "line_count": len(picking.move_line_ids), + "partner": {"id": self.customer.id, "name": self.customer.name}, + }, + "packages": [ + self._package_data(picking, package) for package in packages + ], + "selected_move_lines": [ + self._move_line_data(ml) for ml in picking.move_line_ids.sorted() + ], + }, + ) + + def test_list_dest_package_error_no_package(self): + picking = self._create_picking( + lines=[ + (self.product_a, 10), + (self.product_b, 10), + (self.product_c, 10), + (self.product_d, 10), + ] + ) + self._fill_stock_for_moves(picking.move_lines) + picking.action_assign() + self.assertEqual(picking.state, "assigned") + response = self.service.dispatch( + "list_dest_package", + params={ + "picking_id": picking.id, + "selected_line_ids": picking.move_line_ids.ids, + }, + ) + self._assert_selected_response( + response, + picking.move_line_ids, + message={ + "message_type": "warning", + "message": "No valid package to select.", + }, + ) From 1c76f696d875e08c31caf2600204f0f7ce5630d7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 26 Mar 2020 15:22:14 +0100 Subject: [PATCH 164/940] checkout: implement /set_dest_package and /scan_dest_package --- shopfloor/services/checkout.py | 57 +++-- shopfloor/tests/test_checkout_list_package.py | 208 ++++++++++++++++-- 2 files changed, 230 insertions(+), 35 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 3f5cd577d5..108e96df7c 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -606,6 +606,10 @@ def _switch_line_qty_done(self, picking, selected_lines, switch_lines): def _filter_lines_to_pack(move_line): return move_line.qty_done > 0 and not move_line.shopfloor_checkout_packed + def _is_package_allowed(self, picking, package): + existing_packages = picking.mapped("move_line_ids.result_package_id") + return package in existing_packages + def _put_lines_in_package(self, picking, selected_lines, package): """Put the current selected lines with a qty_done in a package @@ -615,8 +619,7 @@ def _put_lines_in_package(self, picking, selected_lines, package): since Odoo set the value of the result package to the source package by default, it works by default. """ - existing_packages = picking.mapped("move_line_ids.result_package_id") - if package not in existing_packages: + if not self._is_package_allowed(picking, package): return self._response_for_select_package( selected_lines, message={ @@ -778,13 +781,15 @@ def list_dest_package(self, picking_id, selected_line_ids): return self._response_stock_picking_does_not_exist() lines = self.env["stock.move.line"].browse(selected_line_ids).exists() + return self._response_for_select_dest_package(picking, lines) + def _response_for_select_dest_package(self, picking, move_lines, message=None): packages = picking.mapped("move_line_ids.package_id") | picking.mapped( "move_line_ids.result_package_id" ) if not packages: return self._response_for_select_package( - lines, + move_lines, message={ "message_type": "warning", "message": _("No valid package to select."), @@ -794,20 +799,34 @@ def list_dest_package(self, picking_id, selected_line_ids): packages_data = [ self._data_package(picking, package) for package in packages.sorted() ] - return self._response( next_state="select_dest_package", data={ "picking": picking_data, "packages": packages_data, "selected_move_lines": [ - # TODO factorize - self._data_for_move_line(line) - for line in lines.sorted() + self._data_for_move_line(line) for line in move_lines.sorted() ], }, + message=message, ) + def _set_dest_package_from_selection(self, picking, selected_lines, package): + if not package: + return self._response_for_select_dest_package(picking, selected_lines) + if not self._is_package_allowed(picking, package): + return self._response_for_select_dest_package( + picking, + selected_lines, + message={ + "message_type": "error", + "message": _("Not a valid destination package").format( + package.name + ), + }, + ) + return self._put_lines_in_allowed_package(picking, selected_lines, package) + def scan_dest_package(self, picking_id, selected_line_ids, barcode): """Scan destination package for lines @@ -821,13 +840,17 @@ def scan_dest_package(self, picking_id, selected_line_ids, barcode): package on lines. Transitions: - * select_package: error when scanning package + * select_dest_package: error when scanning package * select_line: lines to package remain * summary: all lines are put in packages """ - # TODO search for stock.quant.package with barcode, if found, call - # _put_lines_in_package - return self._response() + picking = self.env["stock.picking"].browse(picking_id) + if not picking.exists(): + return self._response_stock_picking_does_not_exist() + lines = self.env["stock.move.line"].browse(selected_line_ids).exists() + search = self.actions_for("search") + package = search.package_from_scan(barcode) + return self._set_dest_package_from_selection(picking, lines, package) def set_dest_package(self, picking_id, selected_line_ids, package_id): """Set destination package for lines from a package id @@ -837,12 +860,16 @@ def set_dest_package(self, picking_id, selected_line_ids, package_id): The validity is the same as ``scan_dest_package``. Transitions: - * select_dest_package: error when scanning package + * select_dest_package: error when selecting package * select_line: lines to package remain * summary: all lines are put in packages """ - # TODO check if package still exists, call _put_lines_in_package - return self._response() + picking = self.env["stock.picking"].browse(picking_id) + if not picking.exists(): + return self._response_stock_picking_does_not_exist() + lines = self.env["stock.move.line"].browse(selected_line_ids).exists() + package = self.env["stock.quant.package"].browse(package_id).exists() + return self._set_dest_package_from_selection(picking, lines, package) def summary(self, picking_id): """Return information for the summary screen @@ -1177,7 +1204,7 @@ def list_dest_package(self): def scan_dest_package(self): return self._response_schema( - next_states={"select_package", "select_line", "summary"} + next_states={"select_dest_package", "select_line", "summary"} ) def set_dest_package(self): diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py index 2b70308bce..dcbd7a59c9 100644 --- a/shopfloor/tests/test_checkout_list_package.py +++ b/shopfloor/tests/test_checkout_list_package.py @@ -2,7 +2,36 @@ from .test_checkout_select_package_base import CheckoutSelectPackageMixin -class CheckoutListDestPackageCase(CheckoutCommonCase, CheckoutSelectPackageMixin): +class SelectDestPackageMixin: + def _assert_response_select_dest_package( + self, response, picking, selected_lines, packages, message=None + ): + self.assert_response( + response, + next_state="select_dest_package", + data={ + "picking": { + "id": picking.id, + "name": picking.name, + "note": "", + "origin": "", + "line_count": len(picking.move_line_ids), + "partner": {"id": self.customer.id, "name": self.customer.name}, + }, + "packages": [ + self._package_data(picking, package) for package in packages + ], + "selected_move_lines": [ + self._move_line_data(ml) for ml in selected_lines.sorted() + ], + }, + message=message, + ) + + +class CheckoutListDestPackageCase( + CheckoutCommonCase, CheckoutSelectPackageMixin, SelectDestPackageMixin +): def test_list_dest_package_ok(self): picking = self._create_picking( lines=[ @@ -28,25 +57,8 @@ def test_list_dest_package_ok(self): "selected_line_ids": picking.move_line_ids.ids, }, ) - self.assert_response( - response, - next_state="select_dest_package", - data={ - "picking": { - "id": picking.id, - "name": picking.name, - "note": "", - "origin": "", - "line_count": len(picking.move_line_ids), - "partner": {"id": self.customer.id, "name": self.customer.name}, - }, - "packages": [ - self._package_data(picking, package) for package in packages - ], - "selected_move_lines": [ - self._move_line_data(ml) for ml in picking.move_line_ids.sorted() - ], - }, + self._assert_response_select_dest_package( + response, picking, picking.move_line_ids, packages ) def test_list_dest_package_error_no_package(self): @@ -76,3 +88,159 @@ def test_list_dest_package_error_no_package(self): "message": "No valid package to select.", }, ) + + +class CheckoutScanSetDestPackageCase(CheckoutCommonCase, SelectDestPackageMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + picking = cls._create_picking( + lines=[ + (cls.product_a, 10), + (cls.product_b, 10), + (cls.product_c, 10), + (cls.product_d, 10), + ] + ) + pack1_moves = picking.move_lines[:3] + pack2_moves = picking.move_lines[3:] + # put in 2 packs, for this test, we'll work on pack1 + cls._fill_stock_for_moves(pack1_moves, in_package=True) + cls._fill_stock_for_moves(pack2_moves, in_package=True) + picking.action_assign() + + cls.selected_lines = pack1_moves.move_line_ids + cls.pack1 = pack1_moves.move_line_ids.package_id + cls.allowed_packages = picking.mapped( + "move_line_ids.package_id" + ) | picking.mapped("move_line_ids.result_package_id") + + cls.move_line1, cls.move_line2, cls.move_line3 = cls.selected_lines + # We'll put only product A and B in the destination package + cls.move_line1.qty_done = cls.move_line1.product_uom_qty + cls.move_line2.qty_done = cls.move_line2.product_uom_qty + cls.move_line3.qty_done = 0 + + cls.picking = picking + cls.package = cls.move_line1.result_package_id + + def _assert_package_set(self, response): + self.assertRecordValues( + self.move_line1 + self.move_line2 + self.move_line3, + [ + { + "result_package_id": self.package.id, + "shopfloor_checkout_packed": True, + }, + { + "result_package_id": self.package.id, + "shopfloor_checkout_packed": True, + }, + # qty_done was zero so we don't set it as packed + { + "result_package_id": self.pack1.id, + "shopfloor_checkout_packed": False, + }, + ], + ) + self.assert_response( + response, + # go pack to the screen to select lines to put in packages + next_state="select_line", + data={"picking": self._stock_picking_data(self.picking)}, + message={ + "message_type": "info", + "message": "Product(s) packed in {}".format(self.pack1.name), + }, + ) + + def test_scan_dest_package_ok(self): + response = self.service.dispatch( + "scan_dest_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": self.selected_lines.ids, + # we keep the goods in the same package, so we scan the source package + "barcode": self.package.name, + }, + ) + self._assert_package_set(response) + + def test_scan_dest_package_error_not_found(self): + response = self.service.dispatch( + "scan_dest_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": self.selected_lines.ids, + "barcode": "NO BARCODE", + }, + ) + self._assert_response_select_dest_package( + response, self.picking, self.selected_lines, self.allowed_packages + ) + + def test_scan_dest_package_error_not_allowed(self): + package = self.env["stock.quant.package"].create({}) + response = self.service.dispatch( + "scan_dest_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": self.selected_lines.ids, + "barcode": package.name, + }, + ) + self._assert_response_select_dest_package( + response, + self.picking, + self.selected_lines, + self.allowed_packages, + message={ + "message_type": "error", + "message": "Not a valid destination package", + }, + ) + + def test_set_dest_package_ok(self): + response = self.service.dispatch( + "set_dest_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": self.selected_lines.ids, + "package_id": self.package.id, + }, + ) + self._assert_package_set(response) + + def test_set_dest_package_error_not_found(self): + response = self.service.dispatch( + "set_dest_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": self.selected_lines.ids, + "package_id": 0, + }, + ) + self._assert_response_select_dest_package( + response, self.picking, self.selected_lines, self.allowed_packages + ) + + def test_set_dest_package_error_not_allowed(self): + package = self.env["stock.quant.package"].create({}) + response = self.service.dispatch( + "set_dest_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": self.selected_lines.ids, + "package_id": package.id, + }, + ) + self._assert_response_select_dest_package( + response, + self.picking, + self.selected_lines, + self.allowed_packages, + message={ + "message_type": "error", + "message": "Not a valid destination package", + }, + ) From addcf8c3853eb26fb72657e3b8c20b28588faf36 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 27 Mar 2020 11:08:05 +0100 Subject: [PATCH 165/940] checkout: implement /list_packaging Note: remove dependencies on stock_storage_type_putaway_strategy (and its sub-dependencies), because it is in fact not required: the mention of "package type" in the functional specs means "packaging", not "packaging type". --- shopfloor/__manifest__.py | 6 -- shopfloor/services/checkout.py | 96 ++++++++++++------- shopfloor/services/schema.py | 4 +- shopfloor/tests/__init__.py | 1 + .../tests/test_checkout_change_packaging.py | 96 +++++++++++++++++++ .../test_checkout_scan_package_action.py | 7 +- 6 files changed, 160 insertions(+), 50 deletions(-) create mode 100644 shopfloor/tests/test_checkout_change_packaging.py diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index cc03632c6a..edba0364fa 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -18,12 +18,6 @@ "auth_api_key", # https://github.com/OCA/stock-logistics-warehouse/pull/808 "stock_picking_completion_info", - # https://github.com/OCA/wms/pull/12 - "stock_storage_type", - # https://github.com/OCA/wms/pull/13 - "stock_storage_type_putaway_strategy", - # https://github.com/OCA/stock-logistics-warehouse/pull/855 - "stock_location_children", # https://github.com/OCA/stock-logistics-workflow/pull/608 "stock_quant_package_dimension", # https://github.com/OCA/stock-logistics-workflow/pull/607 diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 108e96df7c..8f31b97385 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -105,14 +105,14 @@ def _select_picking(self, picking, state_for_error): def _response_for_selected_stock_picking(self, picking, message=None): if all(line.shopfloor_checkout_packed for line in picking.move_line_ids): - return self._response_for_all_lines_packed(picking, message=message) + return self._response_for_summary(picking, message=message) return self._response( next_state="select_line", data={"picking": self._data_for_stock_picking(picking)}, message=message, ) - def _response_for_all_lines_packed(self, picking, message=None): + def _response_for_summary(self, picking, message=None): return self._response( next_state="summary", data={"picking": self._data_for_stock_picking(picking)}, @@ -184,8 +184,8 @@ def _data_for_move_line(self, move_line): "weight": 0, # TODO "line_count": 0, - "package_type_name": ( - move_line.package_id.package_storage_type_id.name or "" + "packaging_name": ( + move_line.package_id.product_packaging_id.name or "" ), } if move_line.package_id @@ -197,8 +197,8 @@ def _data_for_move_line(self, move_line): "weight": 0, # TODO "line_count": 0, - "package_type_name": ( - move_line.result_package_id.package_storage_type_id.name or "" + "packaging_name": ( + move_line.result_package_id.product_packaging_id.name or "" ), } if move_line.result_package_id @@ -646,10 +646,8 @@ def _put_lines_in_allowed_package(self, picking, selected_lines, package): ) def _prepare_vals_package_from_packaging(self, packaging): - package_type = packaging.package_storage_type_id return { - "package_storage_type_id": package_type.id, - "packaging_id": packaging.id, + "product_packaging_id": packaging.id, "lngth": packaging.lngth, "width": packaging.width, "height": packaging.height, @@ -762,8 +760,7 @@ def _data_package(self, picking, package): # TODO "weight": 0, "line_count": line_count, - # TODO - "package_type_name": "", + "packaging_name": package.product_packaging_id.name or "", } def list_dest_package(self, picking_id, selected_line_ids): @@ -880,25 +877,55 @@ def summary(self, picking_id): picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): return self._response_stock_picking_does_not_exist() - return self._response_for_all_lines_packed(picking) + return self._response_for_summary(picking) + + def _data_packaging(self, packaging): + return {"id": packaging.id, "name": packaging.name} - def list_package_type(self, picking_id, package_id): + def _get_allowed_packaging(self): + return self.env["product.packaging"].search([("product_id", "=", False)]) + + def list_packaging(self, picking_id, package_id): """List the available package types for a package - For a package, we can change the package type. The available - package types are the ones with no product. + For a package, we can change the packaging. The available + packaging are the ones with no product. Transitions: - * change_package_type + * change_packaging + * summary: if the package_id no longer exists """ - # TODO list product.packaging where product_id is False - return self._response() + picking = self.env["stock.picking"].browse(picking_id) + if not picking.exists(): + return self._response_stock_picking_does_not_exist() + + package = self.env["stock.quant.package"].browse(package_id).exists() + packaging_list = self._get_allowed_packaging() + return self._response_for_change_packaging(picking, package, packaging_list) + + def _response_for_change_packaging(self, picking, package, packaging_list): + message = self.actions_for("message") + if not package: + return self._response_for_summary( + picking, message=message.record_not_found() + ) + return self._response( + next_state="change_packaging", + data={ + "picking": self._data_picking_base(picking), + "package": self._data_package(picking, package), + "packagings": [ + self._data_packaging(packaging) + for packaging in packaging_list.sorted() + ], + }, + ) - def set_package_type(self, picking_id, package_id, package_type_id): + def set_packaging(self, picking_id, package_id, packaging_id): """Set a package type on a package Transitions: - * change_package_type: in case of error + * change_packaging: in case of error * summary """ return self._response() @@ -1048,17 +1075,17 @@ def set_dest_package(self): def summary(self): return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}} - def list_package_type(self): + def list_packaging(self): return { "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, "package_id": {"coerce": to_int, "required": True, "type": "integer"}, } - def set_package_type(self): + def set_packaging(self): return { "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, "package_id": {"coerce": to_int, "required": True, "type": "integer"}, - "package_type_id": {"coerce": to_int, "required": True, "type": "integer"}, + "packaging_id": {"coerce": to_int, "required": True, "type": "integer"}, } def remove_package(self): @@ -1097,7 +1124,7 @@ def _states(self): "change_quantity": self._schema_selected_lines, "select_dest_package": self._schema_select_package, "summary": self._schema_stock_picking_details, - "change_package_type": self._schema_select_package_type, + "change_packaging": self._schema_select_packaging, "confirm_done": self._schema_stock_picking_details, } @@ -1138,17 +1165,14 @@ def _schema_select_package(self): } @property - def _schema_select_package_type(self): + def _schema_select_packaging(self): return { - "selected_move_lines": { - "type": "list", - "schema": {"type": "dict", "schema": self.schemas().move_line()}, - }, - "package_types": { + "picking": {"type": "dict", "schema": self.schemas().picking()}, + "package": {"type": "dict", "schema": self.schemas().package()}, + "packagings": { "type": "list", - "schema": {"type": "dict", "schema": self.schemas().package_type()}, + "schema": {"type": "dict", "schema": self.schemas().packaging()}, }, - "picking": {"type": "dict", "schema": self.schemas().picking()}, } @property @@ -1215,11 +1239,11 @@ def set_dest_package(self): def summary(self): return self._response_schema(next_states={"summary"}) - def list_package_type(self): - return self._response_schema(next_states={"change_package_type"}) + def list_packaging(self): + return self._response_schema(next_states={"change_packaging", "summary"}) - def set_package_type(self): - return self._response_schema(next_states={"change_package_type", "summary"}) + def set_packaging(self): + return self._response_schema(next_states={"change_packaging", "summary"}) def remove_package(self): return self._response_schema(next_states={"summary"}) diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 63bede3dad..e4762d247b 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -82,7 +82,7 @@ def package(self): "name": {"type": "string", "nullable": False, "required": True}, "weight": {"required": True, "nullable": True, "type": "float"}, "line_count": {"required": True, "nullable": True, "type": "integer"}, - "package_type_name": {"required": True, "nullable": True, "type": "string"}, + "packaging_name": {"required": True, "nullable": True, "type": "string"}, } def lot(self): @@ -97,7 +97,7 @@ def location(self): "name": {"type": "string", "nullable": False, "required": True}, } - def package_type(self): + def packaging(self): return { "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 709fa5cd76..d1fcb46556 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -20,4 +20,5 @@ from . import test_checkout_new_package from . import test_checkout_list_package from . import test_checkout_summary +from . import test_checkout_change_packaging from . import test_delivery_base diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py new file mode 100644 index 0000000000..71c2883a37 --- /dev/null +++ b/shopfloor/tests/test_checkout_change_packaging.py @@ -0,0 +1,96 @@ +from .test_checkout_base import CheckoutCommonCase + + +class CheckoutListPackagingCase(CheckoutCommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.packaging_pallet = cls.env["product.packaging"].create( + { + "sequence": 3, + "name": "Pallet", + "barcode": "PPP", + "height": 100, + "width": 100, + "lngth": 100, + } + ) + cls.packaging_box = cls.env["product.packaging"].create( + { + "sequence": 2, + "name": "Box", + "barcode": "BBB", + "height": 20, + "width": 20, + "lngth": 20, + } + ) + cls.packaging_inner_box = cls.env["product.packaging"].create( + { + "sequence": 1, + "name": "Inner Box", + "barcode": "III", + "height": 10, + "width": 10, + "lngth": 10, + } + ) + + def test_list_packaging_ok(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + package = picking.move_line_ids.result_package_id + response = self.service.dispatch( + "list_packaging", + params={"picking_id": picking.id, "package_id": package.id}, + ) + + self.assert_response( + response, + next_state="change_packaging", + data={ + "picking": { + "id": picking.id, + "name": picking.name, + "note": "", + "origin": "", + "line_count": len(picking.move_line_ids), + "partner": {"id": self.customer.id, "name": self.customer.name}, + }, + "package": { + "id": package.id, + "name": package.name, + # TODO + "weight": 0, + "line_count": 1, + "packaging_name": package.product_packaging_id.name or "", + }, + "packagings": [ + { + "id": self.packaging_inner_box.id, + "name": self.packaging_inner_box.name, + }, + {"id": self.packaging_box.id, "name": self.packaging_box.name}, + { + "id": self.packaging_pallet.id, + "name": self.packaging_pallet.name, + }, + ], + }, + ) + + def test_list_packaging_error_package_not_found(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + response = self.service.dispatch( + "list_packaging", params={"picking_id": picking.id, "package_id": 0} + ) + self.assert_response( + response, + next_state="summary", + data={"picking": self._stock_picking_data(picking)}, + message={ + "message_type": "error", + "message": "The record you were working on does not exist anymore.", + }, + ) diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py index 9aa13fd6e3..1af133148d 100644 --- a/shopfloor/tests/test_checkout_scan_package_action.py +++ b/shopfloor/tests/test_checkout_scan_package_action.py @@ -291,9 +291,6 @@ def test_scan_package_action_scan_packaging_ok(self): move_line2.qty_done = move_line2.product_uom_qty move_line3.qty_done = 0 - packaging_type = self.env["stock.package.storage.type"].create( - {"name": "Pallet"} - ) packaging = self.env["product.packaging"].create( { "name": "Pallet", @@ -301,7 +298,6 @@ def test_scan_package_action_scan_packaging_ok(self): "height": 12, "width": 13, "lngth": 14, - "package_storage_type_id": packaging_type.id, } ) @@ -322,8 +318,7 @@ def test_scan_package_action_scan_packaging_ok(self): new_package, [ { - "package_storage_type_id": packaging_type.id, - "packaging_id": packaging.id, + "product_packaging_id": packaging.id, "lngth": packaging.lngth, "width": packaging.width, "height": packaging.height, From fb17690a65f5681ca6c605777eba6757358080ef Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 27 Mar 2020 11:47:46 +0100 Subject: [PATCH 166/940] checkout: implement /set_packaging --- shopfloor/services/checkout.py | 21 ++++- .../tests/test_checkout_change_packaging.py | 90 +++++++++++++++---- 2 files changed, 95 insertions(+), 16 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 8f31b97385..b3d2edf65c 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -928,7 +928,26 @@ def set_packaging(self, picking_id, package_id, packaging_id): * change_packaging: in case of error * summary """ - return self._response() + message = self.actions_for("message") + + picking = self.env["stock.picking"].browse(picking_id) + if not picking.exists(): + return self._response_stock_picking_does_not_exist() + + package = self.env["stock.quant.package"].browse(package_id).exists() + packaging = self.env["product.packaging"].browse(packaging_id).exists() + if not (package and packaging): + return self._response_for_summary( + picking, message=message.record_not_found() + ) + package.product_packaging_id = packaging + return self._response_for_summary( + picking, + message={ + "message_type": "success", + "message": _("Packaging changed on package {}").format(package.name), + }, + ) def remove_package(self, picking_id, package_id): """Remove destination package from move lines and set qty done to 0 diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py index 71c2883a37..929809f5f7 100644 --- a/shopfloor/tests/test_checkout_change_packaging.py +++ b/shopfloor/tests/test_checkout_change_packaging.py @@ -1,7 +1,7 @@ from .test_checkout_base import CheckoutCommonCase -class CheckoutListPackagingCase(CheckoutCommonCase): +class CheckoutListSetPackagingCase(CheckoutCommonCase): @classmethod def setUpClass(cls): super().setUpClass() @@ -35,15 +35,16 @@ def setUpClass(cls): "lngth": 10, } ) + cls.picking = cls._create_picking(lines=[(cls.product_a, 10)]) + cls._fill_stock_for_moves(cls.picking.move_lines, in_package=True) + cls.picking.action_assign() + cls.package = cls.picking.move_line_ids.result_package_id + cls.package.product_packaging_id = cls.packaging_pallet def test_list_packaging_ok(self): - picking = self._create_picking(lines=[(self.product_a, 10)]) - self._fill_stock_for_moves(picking.move_lines, in_package=True) - picking.action_assign() - package = picking.move_line_ids.result_package_id response = self.service.dispatch( "list_packaging", - params={"picking_id": picking.id, "package_id": package.id}, + params={"picking_id": self.picking.id, "package_id": self.package.id}, ) self.assert_response( @@ -51,20 +52,20 @@ def test_list_packaging_ok(self): next_state="change_packaging", data={ "picking": { - "id": picking.id, - "name": picking.name, + "id": self.picking.id, + "name": self.picking.name, "note": "", "origin": "", - "line_count": len(picking.move_line_ids), + "line_count": len(self.picking.move_line_ids), "partner": {"id": self.customer.id, "name": self.customer.name}, }, "package": { - "id": package.id, - "name": package.name, + "id": self.package.id, + "name": self.package.name, # TODO "weight": 0, "line_count": 1, - "packaging_name": package.product_packaging_id.name or "", + "packaging_name": self.package.product_packaging_id.name or "", }, "packagings": [ { @@ -81,14 +82,73 @@ def test_list_packaging_ok(self): ) def test_list_packaging_error_package_not_found(self): - picking = self._create_picking(lines=[(self.product_a, 10)]) response = self.service.dispatch( - "list_packaging", params={"picking_id": picking.id, "package_id": 0} + "list_packaging", params={"picking_id": self.picking.id, "package_id": 0} ) self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(picking)}, + data={"picking": self._stock_picking_data(self.picking)}, + message={ + "message_type": "error", + "message": "The record you were working on does not exist anymore.", + }, + ) + + def test_set_packaging_ok(self): + response = self.service.dispatch( + "set_packaging", + params={ + "picking_id": self.picking.id, + "package_id": self.package.id, + "packaging_id": self.packaging_inner_box.id, + }, + ) + self.assertRecordValues( + self.package, [{"product_packaging_id": self.packaging_inner_box.id}] + ) + self.assert_response( + response, + next_state="summary", + data={"picking": self._stock_picking_data(self.picking)}, + message={ + "message_type": "success", + "message": "Packaging changed on package {}".format(self.package.name), + }, + ) + + def test_set_packaging_error_package_not_found(self): + response = self.service.dispatch( + "set_packaging", + params={ + "picking_id": self.picking.id, + "package_id": 0, + "packaging_id": self.packaging_inner_box.id, + }, + ) + self.assert_response( + response, + next_state="summary", + data={"picking": self._stock_picking_data(self.picking)}, + message={ + "message_type": "error", + "message": "The record you were working on does not exist anymore.", + }, + ) + + def test_set_packaging_error_packaging_not_found(self): + response = self.service.dispatch( + "set_packaging", + params={ + "picking_id": self.picking.id, + "package_id": self.package.id, + "packaging_id": 0, + }, + ) + self.assert_response( + response, + next_state="summary", + data={"picking": self._stock_picking_data(self.picking)}, message={ "message_type": "error", "message": "The record you were working on does not exist anymore.", From f05c53715a51f2e8c7ca5af6df03254d67eabb50 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 27 Mar 2020 13:25:11 +0100 Subject: [PATCH 167/940] checkout: implement /remove_package --- shopfloor/services/checkout.py | 43 ++++++-- shopfloor/tests/__init__.py | 1 + .../tests/test_checkout_remove_package.py | 104 ++++++++++++++++++ 3 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 shopfloor/tests/test_checkout_remove_package.py diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index b3d2edf65c..9489223fef 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -5,10 +5,6 @@ from .service import to_float -# NOTE: we need to know if the destination package is set, but sometimes -# the dest. package is kept, so we will use field shopfloor_checkout_packed -# on the move line - class Checkout(Component): """ @@ -27,6 +23,9 @@ class Checkout(Component): 5) Products are not packed (e.g. raw products) and we create new packages 6) Products are not packed (e.g. raw products) and we do not create packages + A new flag ``shopfloor_checkout_packed`` on move lines allows to track which + lines have been put in a package. + Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP """ @@ -225,9 +224,7 @@ def _data_for_stock_picking(self, picking): return data def _lines_to_pack(self, picking): - return picking.move_line_ids.filtered( - lambda l: l.qty_done == 0 and not l.shopfloor_checkout_packed - ) + return picking.move_line_ids.filtered(self._filter_lines_unpacked) def _domain_for_list_stock_picking(self): return [ @@ -602,10 +599,18 @@ def _switch_line_qty_done(self, picking, selected_lines, switch_lines): lambda l: l.product_uom_qty, ) + @staticmethod + def _filter_lines_unpacked(move_line): + return move_line.qty_done == 0 and not move_line.shopfloor_checkout_packed + @staticmethod def _filter_lines_to_pack(move_line): return move_line.qty_done > 0 and not move_line.shopfloor_checkout_packed + @staticmethod + def _filter_lines_packed(move_line): + return move_line.qty_done > 0 and move_line.shopfloor_checkout_packed + def _is_package_allowed(self, picking, package): existing_packages = picking.mapped("move_line_ids.result_package_id") return package in existing_packages @@ -955,11 +960,33 @@ def remove_package(self, picking_id, package_id): All the move lines with the package as ``result_package_id`` have their ``result_package_id`` reset to the source package (default odoo behavior) and their ``qty_done`` set to 0. + It flags ``shopfloor_checkout_packed`` to False so they have to be packed again. Transitions: * summary """ - return self._response() + message = self.actions_for("message") + picking = self.env["stock.picking"].browse(picking_id) + if not picking.exists(): + return self._response_stock_picking_does_not_exist() + + package = self.env["stock.quant.package"].browse(package_id).exists() + if not package: + return self._response_for_summary( + picking, message=message.record_not_found() + ) + move_lines = picking.move_line_ids.filtered( + lambda l: self._filter_lines_packed(l) and l.result_package_id == package + ) + for move_line in move_lines: + move_line.write( + { + "qty_done": 0, + "result_package_id": move_line.package_id, + "shopfloor_checkout_packed": False, + } + ) + return self._response_for_summary(picking) def done(self, picking_id, confirmation=False): """Set the moves as done diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index d1fcb46556..408731f3bf 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -21,4 +21,5 @@ from . import test_checkout_list_package from . import test_checkout_summary from . import test_checkout_change_packaging +from . import test_checkout_remove_package from . import test_delivery_base diff --git a/shopfloor/tests/test_checkout_remove_package.py b/shopfloor/tests/test_checkout_remove_package.py new file mode 100644 index 0000000000..9a2d407118 --- /dev/null +++ b/shopfloor/tests/test_checkout_remove_package.py @@ -0,0 +1,104 @@ +from .test_checkout_base import CheckoutCommonCase + + +class CheckoutRemovePackageCase(CheckoutCommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.picking = picking = cls._create_picking( + lines=[ + (cls.product_a, 10), + (cls.product_b, 10), + (cls.product_c, 10), + (cls.product_d, 10), + ] + ) + cls.pack1_moves = picking.move_lines[:2] + cls.pack2_moves = picking.move_lines[2] + cls.raw_move = picking.move_lines[3] + cls._fill_stock_for_moves(cls.pack1_moves, in_package=True) + cls._fill_stock_for_moves(cls.pack2_moves, in_package=True) + cls._fill_stock_for_moves(cls.raw_move) + picking.action_assign() + + def test_remove_package_ok(self): + picking = self.picking + + pack1_lines = self.pack1_moves.move_line_ids + pack2_lines = self.pack2_moves.move_line_ids + raw_line = self.raw_move.move_line_ids + + # do as we packed the lines in 2 different packages + new_package = self.env["stock.quant.package"].create({}) + (pack1_lines | raw_line).write( + { + "qty_done": 10, + "result_package_id": new_package.id, + "shopfloor_checkout_packed": True, + } + ) + new_package2 = self.env["stock.quant.package"].create({}) + pack2_lines.write( + { + "qty_done": 10, + "result_package_id": new_package2.id, + "shopfloor_checkout_packed": True, + } + ) + + # and now, we want to drop the new_package + response = self.service.dispatch( + "remove_package", + params={"picking_id": picking.id, "package_id": new_package.id}, + ) + + self.assertRecordValues( + pack1_lines + raw_line + pack2_lines, + [ + { + "qty_done": 0, + # reset to origin package + "result_package_id": pack1_lines.mapped("package_id").id, + "shopfloor_checkout_packed": False, + }, + { + "qty_done": 0, + # reset to origin package + "result_package_id": pack1_lines.mapped("package_id").id, + "shopfloor_checkout_packed": False, + }, + { + "qty_done": 0, + # result to an empty package (raw product) + "result_package_id": False, + "shopfloor_checkout_packed": False, + }, + # different package, leave untouched + { + "qty_done": 10, + "result_package_id": new_package2.id, + "shopfloor_checkout_packed": True, + }, + ], + ) + + self.assert_response( + response, + next_state="summary", + data={"picking": self._stock_picking_data(picking)}, + ) + + def test_remove_package_error_package_not_found(self): + # and now, we want to drop the new_package + response = self.service.dispatch( + "remove_package", params={"picking_id": self.picking.id, "package_id": 0} + ) + self.assert_response( + response, + next_state="summary", + data={"picking": self._stock_picking_data(self.picking)}, + message={ + "message_type": "error", + "message": "The record you were working on does not exist anymore.", + }, + ) From a693044f426d3333705e1e109939a7170a2b7e84 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 27 Mar 2020 14:24:59 +0100 Subject: [PATCH 168/940] checkout: implement /done --- shopfloor/services/checkout.py | 48 +++++++++- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_checkout_done.py | 131 ++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 shopfloor/tests/test_checkout_done.py diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 9489223fef..31c5a111b1 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -111,9 +111,9 @@ def _response_for_selected_stock_picking(self, picking, message=None): message=message, ) - def _response_for_summary(self, picking, message=None): + def _response_for_summary(self, picking, need_confirm=False, message=None): return self._response( - next_state="summary", + next_state="summary" if not need_confirm else "confirm_done", data={"picking": self._data_for_stock_picking(picking)}, message=message, ) @@ -988,6 +988,13 @@ def remove_package(self, picking_id, package_id): ) return self._response_for_summary(picking) + @staticmethod + def _filter_lines_(move_line): + return ( + move_line.qty_done == move_line.product_uom_qty + and move_line.shopfloor_checkout_packed + ) + def done(self, picking_id, confirmation=False): """Set the moves as done @@ -999,7 +1006,42 @@ def done(self, picking_id, confirmation=False): * select_document: after done, goes back to start * confirm_done: confirm a partial """ - return self._response() + picking = self.env["stock.picking"].browse(picking_id) + if not picking.exists(): + return self._response_stock_picking_does_not_exist() + lines = picking.move_line_ids + if not confirmation: + if not all(line.qty_done == line.product_uom_qty for line in lines): + return self._response_for_summary( + picking, + need_confirm=True, + message={ + "message_type": "warning", + "message": _( + "Not all lines have been processed, do you" + " want to confirm partial operation?" + ), + }, + ) + elif not all(line.shopfloor_checkout_packed for line in lines): + return self._response_for_summary( + picking, + need_confirm=True, + message={ + "message_type": "warning", + "message": _( + "Remaining raw product not packed, proceed anyway?" + ), + }, + ) + picking.action_done() + return self._response( + next_state="select_document", + message={ + "message_type": "success", + "message": _("Transfer {} done").format(picking.name), + }, + ) class ShopfloorCheckoutValidator(Component): diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 408731f3bf..4f4fe71172 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -22,4 +22,5 @@ from . import test_checkout_summary from . import test_checkout_change_packaging from . import test_checkout_remove_package +from . import test_checkout_done from . import test_delivery_base diff --git a/shopfloor/tests/test_checkout_done.py b/shopfloor/tests/test_checkout_done.py new file mode 100644 index 0000000000..373fb2727e --- /dev/null +++ b/shopfloor/tests/test_checkout_done.py @@ -0,0 +1,131 @@ +from .test_checkout_base import CheckoutCommonCase + + +class CheckoutDoneCase(CheckoutCommonCase): + def test_done_ok(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + # line is done + picking.move_line_ids.write({"qty_done": 10, "shopfloor_checkout_packed": True}) + response = self.service.dispatch("done", params={"picking_id": picking.id}) + + self.assertRecordValues(picking, [{"state": "done"}]) + + self.assert_response( + response, + next_state="select_document", + message={ + "message_type": "success", + "message": "Transfer {} done".format(picking.name), + }, + ) + + +class CheckoutDonePartialCase(CheckoutCommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.picking = picking = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls._fill_stock_for_moves(picking.move_lines) + picking.action_assign() + cls.line1 = picking.move_line_ids[0] + cls.line2 = picking.move_line_ids[1] + cls.line1.write({"qty_done": 10, "shopfloor_checkout_packed": True}) + cls.line2.write({"qty_done": 2, "shopfloor_checkout_packed": True}) + + def test_done_partial(self): + # line is done + response = self.service.dispatch("done", params={"picking_id": self.picking.id}) + + self.assertRecordValues(self.picking, [{"state": "assigned"}]) + + self.assert_response( + response, + next_state="confirm_done", + data={"picking": self._stock_picking_data(self.picking)}, + message={ + "message_type": "warning", + "message": "Not all lines have been processed, do" + " you want to confirm partial operation?", + }, + ) + + def test_done_partial_confirm(self): + # line is done + response = self.service.dispatch( + "done", params={"picking_id": self.picking.id, "confirmation": True} + ) + + self.assertRecordValues(self.picking, [{"state": "done"}]) + self.assertTrue(self.picking.backorder_ids) + + self.assert_response( + response, + next_state="select_document", + message={ + "message_type": "success", + "message": "Transfer {} done".format(self.picking.name), + }, + ) + + +class CheckoutDoneRawUnpackedCase(CheckoutCommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.picking = picking = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls._fill_stock_for_moves(picking.move_lines) + picking.action_assign() + cls.line1 = picking.move_line_ids[0] + cls.line2 = picking.move_line_ids[1] + cls.package = cls.env["stock.quant.package"].create({}) + cls.line1.write( + { + "qty_done": 10, + "shopfloor_checkout_packed": True, + "result_package_id": cls.package.id, + } + ) + cls.line2.write({"qty_done": 10, "shopfloor_checkout_packed": False}) + + def test_done_partial(self): + # line is done + response = self.service.dispatch("done", params={"picking_id": self.picking.id}) + + self.assertRecordValues(self.picking, [{"state": "assigned"}]) + + self.assert_response( + response, + next_state="confirm_done", + data={"picking": self._stock_picking_data(self.picking)}, + message={ + "message_type": "warning", + "message": "Remaining raw product not packed, proceed anyway?", + }, + ) + + def test_done_partial_confirm(self): + # line is done + response = self.service.dispatch( + "done", params={"picking_id": self.picking.id, "confirmation": True} + ) + + self.assertRecordValues(self.picking, [{"state": "done", "backorder_ids": []}]) + self.assertRecordValues( + self.line1 + self.line2, + [{"result_package_id": self.package.id}, {"result_package_id": False}], + ) + + self.assert_response( + response, + next_state="select_document", + message={ + "message_type": "success", + "message": "Transfer {} done".format(self.picking.name), + }, + ) From 080e60e82000eca5a7c2222bc91d71164a2f3e88 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 30 Mar 2020 13:34:12 +0200 Subject: [PATCH 169/940] checkout: use common methods for getting data for endpoints The idea is to share data and validation schemas for data returned by endpoints, per model, across the different scenarii. Currently, only the checkout uses it, but the new ones we implement should use the same schemas and data methods as much as possible. Ideally, we should rework the existing ones to use them. --- shopfloor/actions/data.py | 67 +++++ shopfloor/services/checkout.py | 110 ++------ shopfloor/tests/__init__.py | 1 + shopfloor/tests/common.py | 76 ++++++ shopfloor/tests/test_actions_data.py | 250 ++++++++++++++++++ shopfloor/tests/test_checkout_base.py | 55 +--- .../tests/test_checkout_change_packaging.py | 31 +-- shopfloor/tests/test_checkout_list_package.py | 2 +- shopfloor/tests/test_checkout_select.py | 31 +-- .../test_checkout_select_package_base.py | 9 +- shopfloor/tests/test_cluster_picking_base.py | 18 -- 11 files changed, 436 insertions(+), 214 deletions(-) create mode 100644 shopfloor/tests/test_actions_data.py diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index ee2b903950..9cab2186c6 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -11,3 +11,70 @@ class DataAction(Component): _name = "shopfloor.data.action" _inherit = "shopfloor.process.action" _usage = "data" + + def picking_summary(self, picking): + return { + "id": picking.id, + "name": picking.name, + "origin": picking.origin or "", + "note": picking.note or "", + "line_count": len(picking.move_line_ids), + "partner": {"id": picking.partner_id.id, "name": picking.partner_id.name} + if picking.partner_id + else None, + } + + def package(self, package, picking=None): + """Return data for a stock.quant.package + + If a picking is given, it will include the number of lines of the package + for the picking. + """ + line_count = ( + len(picking.move_line_ids.filtered(lambda l: l.package_id == package)) + if picking + else 0 + ) + return { + "id": package.id, + "name": package.name, + # TODO + "weight": 0, + "line_count": line_count, + "packaging_name": package.product_packaging_id.name or "", + } + + def packaging(self, packaging): + return {"id": packaging.id, "name": packaging.name} + + def lot(self, lot): + return {"id": lot.id, "name": lot.name} + + def location(self, location): + return {"id": location.id, "name": location.name} + + def move_line(self, move_line): + return { + "id": move_line.id, + "qty_done": move_line.qty_done, + "quantity": move_line.product_uom_qty, + "product": { + "id": move_line.product_id.id, + "name": move_line.product_id.name, + "display_name": move_line.product_id.display_name, + "default_code": move_line.product_id.default_code or "", + }, + "lot": {"id": move_line.lot_id.id, "name": move_line.lot_id.name} + if move_line.lot_id + else None, + "package_src": self.package(move_line.package_id, move_line.picking_id) + if move_line.package_id + else None, + "package_dest": self.package( + move_line.result_package_id, move_line.picking_id + ) + if move_line.result_package_id + else None, + "location_src": self.location(move_line.location_id), + "location_dest": self.location(move_line.location_dest_id), + } diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 31c5a111b1..f30383c6ff 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -162,62 +162,13 @@ def _response_for_stock_picking_has_been_deleted(self): next_state="select_document", message=message.stock_picking_not_found() ) - def _data_for_move_line(self, move_line): - return { - "id": move_line.id, - "qty_done": move_line.qty_done, - "quantity": move_line.product_uom_qty, - "product": { - "id": move_line.product_id.id, - "name": move_line.product_id.name, - "display_name": move_line.product_id.display_name, - "default_code": move_line.product_id.default_code or "", - }, - "lot": {"id": move_line.lot_id.id, "name": move_line.lot_id.name} - if move_line.lot_id - else None, - "package_src": { - "id": move_line.package_id.id, - "name": move_line.package_id.name, - # TODO - "weight": 0, - # TODO - "line_count": 0, - "packaging_name": ( - move_line.package_id.product_packaging_id.name or "" - ), - } - if move_line.package_id - else None, - "package_dest": { - "id": move_line.result_package_id.id, - "name": move_line.result_package_id.name, - # TODO - "weight": 0, - # TODO - "line_count": 0, - "packaging_name": ( - move_line.result_package_id.product_packaging_id.name or "" - ), - } - if move_line.result_package_id - else None, - "location_src": { - "id": move_line.location_id.id, - "name": move_line.location_id.name, - }, - "location_dest": { - "id": move_line.location_dest_id.id, - "name": move_line.location_dest_id.name, - }, - } - def _data_for_stock_picking(self, picking): - data = self._data_picking_base(picking) + data_struct = self.actions_for("data") + data = data_struct.picking_summary(picking) data.update( { "move_lines": [ - self._data_for_move_line(ml) for ml in self._lines_to_pack(picking) + data_struct.move_line(ml) for ml in self._lines_to_pack(picking) ] } ) @@ -251,20 +202,11 @@ def _response_for_manual_selection(self, message=None): self._domain_for_list_stock_picking(), order=self._order_for_list_stock_picking(), ) - data = {"pickings": [self._data_picking_base(picking) for picking in pickings]} - return self._response(next_state="manual_selection", data=data, message=message) - - def _data_picking_base(self, picking): - return { - "id": picking.id, - "name": picking.name, - "origin": picking.origin or "", - "note": picking.note or "", - "line_count": len(picking.move_line_ids), - "partner": {"id": picking.partner_id.id, "name": picking.partner_id.name} - if picking.partner_id - else None, + data_struct = self.actions_for("data") + data = { + "pickings": [data_struct.picking_summary(picking) for picking in pickings] } + return self._response(next_state="manual_selection", data=data, message=message) def select(self, picking_id): """Select a stock picking for the process @@ -288,14 +230,15 @@ def select(self, picking_id): return self._select_picking(picking, "manual_selection") def _response_for_select_package(self, lines, message=None): + data_struct = self.actions_for("data") picking = lines.mapped("picking_id") return self._response( next_state="select_package", data={ "selected_move_lines": [ - self._data_for_move_line(line) for line in lines.sorted() + data_struct.move_line(line) for line in lines.sorted() ], - "picking": self._data_picking_base(picking), + "picking": data_struct.picking_summary(picking), }, message=message, ) @@ -755,19 +698,6 @@ def new_package(self, picking_id, selected_line_ids): selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() return self._create_and_assign_new_packaging(picking, selected_lines) - def _data_package(self, picking, package): - line_count = len( - picking.move_line_ids.filtered(lambda l: l.package_id == package) - ) - return { - "id": package.id, - "name": package.name, - # TODO - "weight": 0, - "line_count": line_count, - "packaging_name": package.product_packaging_id.name or "", - } - def list_dest_package(self, picking_id, selected_line_ids): """Return a list of packages the user can select for the lines @@ -797,17 +727,20 @@ def _response_for_select_dest_package(self, picking, move_lines, message=None): "message": _("No valid package to select."), }, ) - picking_data = self._data_picking_base(picking) + data_struct = self.actions_for("data") + picking_data = data_struct.picking_summary(picking) packages_data = [ - self._data_package(picking, package) for package in packages.sorted() + data_struct.package(package, picking=picking) + for package in packages.sorted() ] + data_struct = self.actions_for("data") return self._response( next_state="select_dest_package", data={ "picking": picking_data, "packages": packages_data, "selected_move_lines": [ - self._data_for_move_line(line) for line in move_lines.sorted() + data_struct.move_line(line) for line in move_lines.sorted() ], }, message=message, @@ -884,9 +817,6 @@ def summary(self, picking_id): return self._response_stock_picking_does_not_exist() return self._response_for_summary(picking) - def _data_packaging(self, packaging): - return {"id": packaging.id, "name": packaging.name} - def _get_allowed_packaging(self): return self.env["product.packaging"].search([("product_id", "=", False)]) @@ -910,17 +840,19 @@ def list_packaging(self, picking_id, package_id): def _response_for_change_packaging(self, picking, package, packaging_list): message = self.actions_for("message") + data_struct = self.actions_for("data") if not package: return self._response_for_summary( picking, message=message.record_not_found() ) + return self._response( next_state="change_packaging", data={ - "picking": self._data_picking_base(picking), - "package": self._data_package(picking, package), + "picking": data_struct.picking_summary(picking), + "package": data_struct.package(package, picking=picking), "packagings": [ - self._data_packaging(packaging) + data_struct.packaging(packaging) for packaging in packaging_list.sorted() ], }, diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 4f4fe71172..8911b00f82 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -2,6 +2,7 @@ from . import test_menu from . import test_openapi from . import test_profile +from . import test_actions_data from . import test_picking_batch from . import test_single_pack_putaway from . import test_single_pack_transfer diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 2ae7f007ee..3342045245 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -39,6 +39,14 @@ def work_on_services(self, **params): model_name="rest.service.registration", collection=collection, **params ) + @contextmanager + def work_on_actions(self, **params): + params = params or {} + collection = _PseudoCollection("shopfloor.action", self.env) + yield WorkContext( + model_name="rest.service.registration", collection=collection, **params + ) + # pylint: disable=method-required-super # super is called "the old-style way" to call both super classes in the # order we want @@ -56,6 +64,7 @@ def setUpClass(cls): ) cls.setUpComponent() cls.setUpClassVars() + cls.setUpClassBaseData() @classmethod def setUpClassVars(cls): @@ -69,6 +78,57 @@ def setUpClassVars(cls): cls.shelf2 = cls.env.ref("stock.stock_location_14") cls.customer = cls.env["res.partner"].create({"name": "Customer"}) + @classmethod + def setUpClassBaseData(cls): + cls.product_a = cls.env["product.product"].create( + { + "name": "Product A", + "type": "product", + "default_code": "A", + "barcode": "A", + "weight": 2, + } + ) + cls.product_a_packaging = cls.env["product.packaging"].create( + {"name": "Box", "product_id": cls.product_a.id, "barcode": "ProductABox"} + ) + cls.product_b = cls.env["product.product"].create( + { + "name": "Product B", + "type": "product", + "default_code": "B", + "barcode": "B", + "weight": 3, + } + ) + cls.product_b_packaging = cls.env["product.packaging"].create( + {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductBBox"} + ) + cls.product_c = cls.env["product.product"].create( + { + "name": "Product C", + "type": "product", + "default_code": "C", + "barcode": "C", + "weight": 3, + } + ) + cls.product_c_packaging = cls.env["product.packaging"].create( + {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductCBox"} + ) + cls.product_d = cls.env["product.product"].create( + { + "name": "Product D", + "type": "product", + "default_code": "D", + "barcode": "D", + "weight": 3, + } + ) + cls.product_d_packaging = cls.env["product.packaging"].create( + {"name": "Box", "product_id": cls.product_d.id, "barcode": "ProductDBox"} + ) + def assert_response(self, response, next_state=None, message=None, data=None): """Assert a response from the webservice @@ -91,6 +151,22 @@ def assert_response(self, response, next_state=None, message=None, data=None): "\n\nExpected:\n%s" % (pformat(response), pformat(expected)), ) + @classmethod + def _create_picking(cls, picking_type=None, lines=None, confirm=True): + picking_form = Form(cls.env["stock.picking"]) + picking_form.picking_type_id = picking_type or cls.picking_type + picking_form.partner_id = cls.customer + if lines is None: + lines = [(cls.product_a, 10), (cls.product_b, 10)] + for product, qty in lines: + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = qty + picking = picking_form.save() + if confirm: + picking.action_confirm() + return picking + @classmethod def _update_qty_in_location( cls, location, product, quantity, package=None, lot=None diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py new file mode 100644 index 0000000000..c8f2207b9e --- /dev/null +++ b/shopfloor/tests/test_actions_data.py @@ -0,0 +1,250 @@ +import logging + +from .common import CommonCase + +_logger = logging.getLogger(__name__) + + +try: + from cerberus import Validator +except ImportError: + _logger.debug("Can not import cerberus") + + +class ActionsDataCase(CommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.wh = cls.env.ref("stock.warehouse0") + cls.picking_type = cls.wh.out_type_id + cls.packaging = cls.env["product.packaging"].create({"name": "Pallet"}) + cls.product_b.tracking = "lot" + cls.product_c.tracking = "lot" + cls.picking = cls._create_picking( + lines=[ + (cls.product_a, 10), + (cls.product_b, 10), + (cls.product_c, 10), + (cls.product_d, 10), + ] + ) + # put product A in a package + cls.move_a = cls.picking.move_lines[0] + cls._fill_stock_for_moves(cls.move_a, in_package=True) + # product B has a lot + cls.move_b = cls.picking.move_lines[1] + cls._fill_stock_for_moves(cls.move_b, in_lot=True) + # product C has a lot and package + cls.move_c = cls.picking.move_lines[2] + cls._fill_stock_for_moves(cls.move_c, in_package=True, in_lot=True) + # product D is raw + cls.move_d = cls.picking.move_lines[3] + cls._fill_stock_for_moves(cls.move_d) + cls.picking.action_assign() + + def setUp(self): + super().setUp() + with self.work_on_actions() as work: + self.data = work.component(usage="data") + with self.work_on_services() as work: + self.schema = work.component(usage="schema") + + def assert_schema(self, schema, data): + validator = Validator(schema) + self.assertTrue(validator.validate(data), validator.errors) + + def test_data_packaging(self): + data = self.data.packaging(self.packaging) + self.assert_schema(self.schema.packaging(), data) + expected = {"id": self.packaging.id, "name": self.packaging.name} + self.assertDictEqual(data, expected) + + def test_data_location(self): + location = self.stock_location + data = self.data.location(location) + self.assert_schema(self.schema.location(), data) + expected = {"id": location.id, "name": location.name} + self.assertDictEqual(data, expected) + + def test_data_lot(self): + lot = self.env["stock.production.lot"].create( + {"product_id": self.product_b.id, "company_id": self.env.company.id} + ) + data = self.data.lot(lot) + self.assert_schema(self.schema.lot(), data) + expected = {"id": lot.id, "name": lot.name} + self.assertDictEqual(data, expected) + + def test_data_package(self): + package = self.move_a.move_line_ids.package_id + package.product_packaging_id = self.packaging.id + data = self.data.package(package, picking=self.picking) + self.assert_schema(self.schema.package(), data) + expected = { + "id": package.id, + "name": package.name, + "line_count": 1, + "packaging_name": self.packaging.name, + # TODO + "weight": 0, + } + self.assertDictEqual(data, expected) + + def test_data_picking_summary(self): + self.picking.write({"origin": "created by test", "note": "read me"}) + data = self.data.picking_summary(self.picking) + self.assert_schema(self.schema.picking(), data) + expected = { + "id": self.picking.id, + "line_count": 4, + "name": self.picking.name, + "note": "read me", + "origin": "created by test", + "partner": {"id": self.customer.id, "name": self.customer.name}, + } + self.assertDictEqual(data, expected) + + def test_data_move_line_package(self): + move_line = self.move_a.move_line_ids + result_package = self.env["stock.quant.package"].create( + {"product_packaging_id": self.packaging.id} + ) + move_line.write({"qty_done": 3.0, "result_package_id": result_package.id}) + data = self.data.move_line(move_line) + self.assert_schema(self.schema.move_line(), data) + expected = { + "id": move_line.id, + "qty_done": 3.0, + "quantity": move_line.product_uom_qty, + "product": { + "id": self.product_a.id, + "name": "Product A", + "display_name": "[A] Product A", + "default_code": "A", + }, + "lot": None, + "package_src": { + "id": move_line.package_id.id, + "name": move_line.package_id.name, + "line_count": 1, + "packaging_name": "", + # TODO + "weight": 0, + }, + "package_dest": { + "id": result_package.id, + "name": result_package.name, + "line_count": 0, + "packaging_name": self.packaging.name, + # TODO + "weight": 0, + }, + "location_src": { + "id": move_line.location_id.id, + "name": move_line.location_id.name, + }, + "location_dest": { + "id": move_line.location_dest_id.id, + "name": move_line.location_dest_id.name, + }, + } + self.assertDictEqual(data, expected) + + def test_data_move_line_lot(self): + move_line = self.move_b.move_line_ids + data = self.data.move_line(move_line) + self.assert_schema(self.schema.move_line(), data) + expected = { + "id": move_line.id, + "qty_done": 0.0, + "quantity": move_line.product_uom_qty, + "product": { + "id": self.product_b.id, + "name": "Product B", + "display_name": "[B] Product B", + "default_code": "B", + }, + "lot": {"id": move_line.lot_id.id, "name": move_line.lot_id.name}, + "package_src": None, + "package_dest": None, + "location_src": { + "id": move_line.location_id.id, + "name": move_line.location_id.name, + }, + "location_dest": { + "id": move_line.location_dest_id.id, + "name": move_line.location_dest_id.name, + }, + } + self.assertDictEqual(data, expected) + + def test_data_move_line_package_lot(self): + self.maxDiff = None + move_line = self.move_c.move_line_ids + data = self.data.move_line(move_line) + self.assert_schema(self.schema.move_line(), data) + expected = { + "id": move_line.id, + "qty_done": 0.0, + "quantity": move_line.product_uom_qty, + "product": { + "id": self.product_c.id, + "name": "Product C", + "display_name": "[C] Product C", + "default_code": "C", + }, + "lot": {"id": move_line.lot_id.id, "name": move_line.lot_id.name}, + "package_src": { + "id": move_line.package_id.id, + "name": move_line.package_id.name, + "line_count": 1, + "packaging_name": "", + # TODO + "weight": 0, + }, + "package_dest": { + "id": move_line.result_package_id.id, + "name": move_line.result_package_id.name, + "line_count": 1, + "packaging_name": "", + # TODO + "weight": 0, + }, + "location_src": { + "id": move_line.location_id.id, + "name": move_line.location_id.name, + }, + "location_dest": { + "id": move_line.location_dest_id.id, + "name": move_line.location_dest_id.name, + }, + } + self.assertDictEqual(data, expected) + + def test_data_move_line_raw(self): + move_line = self.move_d.move_line_ids + data = self.data.move_line(move_line) + self.assert_schema(self.schema.move_line(), data) + expected = { + "id": move_line.id, + "qty_done": 0.0, + "quantity": move_line.product_uom_qty, + "product": { + "id": self.product_d.id, + "name": "Product D", + "display_name": "[D] Product D", + "default_code": "D", + }, + "lot": None, + "package_src": None, + "package_dest": None, + "location_src": { + "id": move_line.location_id.id, + "name": move_line.location_id.name, + }, + "location_dest": { + "id": move_line.location_dest_id.id, + "name": move_line.location_dest_id.name, + }, + } + self.assertDictEqual(data, expected) diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index 8053ee6ace..cbf348b246 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -1,5 +1,3 @@ -from odoo.tests.common import Form - from .common import CommonCase @@ -7,30 +5,6 @@ class CheckoutCommonCase(CommonCase): @classmethod def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) - cls.product_a = cls.env["product.product"].create( - {"name": "Product A", "type": "product", "barcode": "product_a"} - ) - cls.product_a_packaging = cls.env["product.packaging"].create( - {"name": "Box", "product_id": cls.product_a.id, "barcode": "ProductABox"} - ) - cls.product_b = cls.env["product.product"].create( - {"name": "Product B", "type": "product", "barcode": "product_b"} - ) - cls.product_b_packaging = cls.env["product.packaging"].create( - {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductBBox"} - ) - cls.product_c = cls.env["product.product"].create( - {"name": "Product C", "type": "product", "barcode": "product_c"} - ) - cls.product_c_packaging = cls.env["product.packaging"].create( - {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductCBox"} - ) - cls.product_d = cls.env["product.product"].create( - {"name": "Product D", "type": "product", "barcode": "product_d"} - ) - cls.product_d_packaging = cls.env["product.packaging"].create( - {"name": "Box", "product_id": cls.product_d.id, "barcode": "ProductDBox"} - ) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout") cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") @@ -43,27 +17,18 @@ def setUp(self): with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="checkout") - @classmethod - def _create_picking(cls, picking_type=None, lines=None, confirm=True): - picking_form = Form(cls.env["stock.picking"]) - picking_form.picking_type_id = picking_type or cls.picking_type - picking_form.partner_id = cls.customer - if lines is None: - lines = [(cls.product_a, 10), (cls.product_b, 10)] - for product, qty in lines: - with picking_form.move_ids_without_package.new() as move: - move.product_id = product - move.product_uom_qty = qty - picking = picking_form.save() - if confirm: - picking.action_confirm() - return picking - def _stock_picking_data(self, picking): return self.service._data_for_stock_picking(picking) + # we test the methods that structure data in test_actions_data.py + def _picking_summary_data(self, picking): + return self.service.actions_for("data").picking_summary(picking) + def _move_line_data(self, move_line): - return self.service._data_for_move_line(move_line) + return self.service.actions_for("data").move_line(move_line) + + def _package_data(self, package, picking): + return self.service.actions_for("data").package(package, picking=picking) - def _package_data(self, picking, package): - return self.service._data_package(picking, package) + def _packaging_data(self, packaging): + return self.service.actions_for("data").packaging(packaging) diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py index 929809f5f7..d532830a1b 100644 --- a/shopfloor/tests/test_checkout_change_packaging.py +++ b/shopfloor/tests/test_checkout_change_packaging.py @@ -51,32 +51,13 @@ def test_list_packaging_ok(self): response, next_state="change_packaging", data={ - "picking": { - "id": self.picking.id, - "name": self.picking.name, - "note": "", - "origin": "", - "line_count": len(self.picking.move_line_ids), - "partner": {"id": self.customer.id, "name": self.customer.name}, - }, - "package": { - "id": self.package.id, - "name": self.package.name, - # TODO - "weight": 0, - "line_count": 1, - "packaging_name": self.package.product_packaging_id.name or "", - }, + "picking": self._picking_summary_data(self.picking), + "package": self._package_data(self.package, self.picking), "packagings": [ - { - "id": self.packaging_inner_box.id, - "name": self.packaging_inner_box.name, - }, - {"id": self.packaging_box.id, "name": self.packaging_box.name}, - { - "id": self.packaging_pallet.id, - "name": self.packaging_pallet.name, - }, + self._packaging_data(packaging) + for packaging in self.packaging_inner_box + + self.packaging_box + + self.packaging_pallet ], }, ) diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py index dcbd7a59c9..e008422ac2 100644 --- a/shopfloor/tests/test_checkout_list_package.py +++ b/shopfloor/tests/test_checkout_list_package.py @@ -19,7 +19,7 @@ def _assert_response_select_dest_package( "partner": {"id": self.customer.id, "name": self.customer.name}, }, "packages": [ - self._package_data(picking, package) for package in packages + self._package_data(package, picking) for package in packages ], "selected_move_lines": [ self._move_line_data(ml) for ml in selected_lines.sorted() diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py index 8393a9763c..c8e30df13a 100644 --- a/shopfloor/tests/test_checkout_select.py +++ b/shopfloor/tests/test_checkout_select.py @@ -15,22 +15,8 @@ def test_list_stock_picking(self): response = self.service.dispatch("list_stock_picking", params={}) expected = { "pickings": [ - { - "id": picking1.id, - "line_count": len(picking1.move_line_ids), - "name": picking1.name, - "note": "", - "origin": "", - "partner": {"id": self.customer.id, "name": self.customer.name}, - }, - { - "id": picking2.id, - "line_count": len(picking2.move_line_ids), - "name": picking2.name, - "note": "", - "origin": "", - "partner": {"id": self.customer.id, "name": self.customer.name}, - }, + self._picking_summary_data(picking1), + self._picking_summary_data(picking2), ] } @@ -61,18 +47,7 @@ def _test_error(self, picking, msg): response, next_state="manual_selection", message={"message_type": "error", "message": msg}, - data={ - "pickings": [ - { - "id": self.picking.id, - "line_count": len(self.picking.move_line_ids), - "name": self.picking.name, - "note": "", - "origin": "", - "partner": {"id": self.customer.id, "name": self.customer.name}, - } - ] - }, + data={"pickings": [self._picking_summary_data(self.picking)]}, ) def test_select_error_not_found(self): diff --git a/shopfloor/tests/test_checkout_select_package_base.py b/shopfloor/tests/test_checkout_select_package_base.py index 4a66c075e4..e82ac9f9af 100644 --- a/shopfloor/tests/test_checkout_select_package_base.py +++ b/shopfloor/tests/test_checkout_select_package_base.py @@ -8,14 +8,7 @@ def _assert_selected_response(self, response, selected_lines, message=None): "selected_move_lines": [ self._move_line_data(ml) for ml in selected_lines ], - "picking": { - "id": picking.id, - "name": picking.name, - "note": "", - "origin": "", - "line_count": len(picking.move_line_ids), - "partner": {"id": self.customer.id, "name": self.customer.name}, - }, + "picking": self._picking_summary_data(picking), }, message=message, ) diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py index 3d52804aff..62c1aa6550 100644 --- a/shopfloor/tests/test_cluster_picking_base.py +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -5,24 +5,6 @@ class ClusterPickingCommonCase(CommonCase, PickingBatchMixin): @classmethod def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) - cls.product_a = cls.env["product.product"].create( - { - "name": "Product A", - "type": "product", - "default_code": "A", - "barcode": "A", - "weight": 2, - } - ) - cls.product_b = cls.env["product.product"].create( - { - "name": "Product B", - "type": "product", - "default_code": "B", - "barcode": "B", - "weight": 3, - } - ) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") From 62bdc320de6cbe563490e7a808c57f82dd349bd7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 30 Mar 2020 14:26:08 +0200 Subject: [PATCH 170/940] checkout: refactor _response_for methods Only use one _response_for method for each next state. We should use the same pattern for the other scenarii. --- shopfloor/services/checkout.py | 104 +++++++++++++-------------------- 1 file changed, 41 insertions(+), 63 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index f30383c6ff..da34175d40 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -57,6 +57,7 @@ def scan_document(self, barcode): destination pack set """ search = self.actions_for("search") + message = self.actions_for("message") picking = search.stock_picking_from_scan(barcode) if not picking: location = search.location_from_scan(barcode) @@ -64,13 +65,23 @@ def scan_document(self, barcode): if not location.is_sublocation_of( self.picking_type.default_location_src_id ): - return self._response_for_scan_location_not_allowed() + return self._response_for_select_document( + message=message.location_not_allowed() + ) lines = location.source_move_line_ids pickings = lines.mapped("picking_id") if len(pickings) == 1: picking = pickings else: - return self._response_for_several_stock_picking_found() + return self._response_for_select_document( + message={ + "message_type": "error", + "message": _( + "Several transfers found, please scan a package" + " or select a transfer manually." + ), + } + ) if not picking: package = search.package_from_scan(barcode) if package: @@ -87,22 +98,28 @@ def _select_picking(self, picking, state_for_error): return self._response_for_manual_selection( message=message.stock_picking_not_found() ) - return self._response_for_barcode_no_stock_picking_found() + return self._response_for_select_document( + message=message.barcode_not_found() + ) if picking.picking_type_id != self.picking_type: if state_for_error == "manual_selection": return self._response_for_manual_selection( message=message.cannot_move_something_in_picking_type() ) - return self._response_for_scan_picking_type_not_allowed() + return self._response_for_select_document( + message=message.cannot_move_something_in_picking_type() + ) if picking.state != "assigned": if state_for_error == "manual_selection": return self._response_for_manual_selection( message=message.stock_picking_not_available(picking) ) - return self._response_for_picking_not_assigned(picking) - return self._response_for_selected_stock_picking(picking) + return self._response_for_select_document( + picking, message=message.stock_picking_not_available(picking) + ) + return self._response_for_select_line(picking) - def _response_for_selected_stock_picking(self, picking, message=None): + def _response_for_select_line(self, picking, message=None): if all(line.shopfloor_checkout_packed for line in picking.move_line_ids): return self._response_for_summary(picking, message=message) return self._response( @@ -118,49 +135,9 @@ def _response_for_summary(self, picking, need_confirm=False, message=None): message=message, ) - def _response_for_picking_not_assigned(self, picking): - message = self.actions_for("message") - return self._response( - next_state="select_document", - message=message.stock_picking_not_available(picking), - ) - - def _response_for_several_stock_picking_found(self): - return self._response( - next_state="select_document", - message={ - "message_type": "error", - "message": _( - "Several transfers found, please scan a package" - " or select a transfer manually." - ), - }, - ) - - def _response_for_scan_picking_type_not_allowed(self): - message = self.actions_for("message") - return self._response( - next_state="select_document", - message=message.cannot_move_something_in_picking_type(), - ) - - def _response_for_scan_location_not_allowed(self): - message = self.actions_for("message") - return self._response( - next_state="select_document", message=message.location_not_allowed() - ) - - def _response_for_barcode_no_stock_picking_found(self): - message = self.actions_for("message") - return self._response( - next_state="select_document", message=message.barcode_not_found() - ) - - def _response_for_stock_picking_has_been_deleted(self): + def _response_for_select_document(self, picking, message=None): message = self.actions_for("message") - return self._response( - next_state="select_document", message=message.stock_picking_not_found() - ) + return self._response(next_state="select_document", message=message) def _data_for_stock_picking(self, picking): data_struct = self.actions_for("data") @@ -297,14 +274,14 @@ def scan_line(self, picking_id, barcode): if lot: return self._select_lines_from_lot(picking, selection_lines, lot) - return self._response_for_selected_stock_picking( + return self._response_for_select_line( picking, message=message.barcode_not_found() ) def _select_lines_from_package(self, picking, selection_lines, package): lines = selection_lines.filtered(lambda l: l.package_id == package) if not lines: - return self._response_for_selected_stock_picking( + return self._response_for_select_line( picking, message={ "message_type": "error", @@ -319,13 +296,13 @@ def _select_lines_from_package(self, picking, selection_lines, package): def _select_lines_from_product(self, picking, selection_lines, product): message = self.actions_for("message") if product.tracking in ("lot", "serial"): - return self._response_for_selected_stock_picking( + return self._response_for_select_line( picking, message=message.scan_lot_on_product_tracked_by_lot() ) lines = selection_lines.filtered(lambda l: l.product_id == product) if not lines: - return self._response_for_selected_stock_picking( + return self._response_for_select_line( picking, message={ "message_type": "error", @@ -343,7 +320,7 @@ def _select_lines_from_product(self, picking, selection_lines, product): # a unit in another line. In both cases, we want the user to scan the # package. if packages and len({l.package_id for l in lines}) > 1: - return self._response_for_selected_stock_picking( + return self._response_for_select_line( picking, message=message.product_multiple_packages_scan_package() ) elif packages: @@ -357,7 +334,7 @@ def _select_lines_from_product(self, picking, selection_lines, product): def _select_lines_from_lot(self, picking, selection_lines, lot): lines = selection_lines.filtered(lambda l: l.lot_id == lot) if not lines: - return self._response_for_selected_stock_picking( + return self._response_for_select_line( picking, message={ "message_type": "error", @@ -376,7 +353,7 @@ def _select_lines_from_lot(self, picking, selection_lines, lot): # a unit in another line. In both cases, we want the user to scan the # package. if packages and len({l.package_id for l in lines}) > 1: - return self._response_for_selected_stock_picking( + return self._response_for_select_line( picking, message=message.lot_multiple_packages_scan_package() ) elif packages: @@ -390,7 +367,7 @@ def _select_lines_from_lot(self, picking, selection_lines, lot): def _select_line_package(self, picking, selection_lines, package): if not package: message = self.actions_for("message") - return self._response_for_selected_stock_picking( + return self._response_for_select_line( picking, message=message.record_not_found() ) return self._select_lines_from_package(picking, selection_lines, package) @@ -398,7 +375,7 @@ def _select_line_package(self, picking, selection_lines, package): def _select_line_move_line(self, picking, selection_lines, move_line): if not move_line: message = self.actions_for("message") - return self._response_for_selected_stock_picking( + return self._response_for_select_line( picking, message=message.record_not_found() ) # normally, the client should sent only move lines out of packages, but @@ -585,7 +562,7 @@ def _put_lines_in_allowed_package(self, picking, selected_lines, package): {"result_package_id": package.id, "shopfloor_checkout_packed": True} ) # go back to the screen to select the next lines to pack - return self._response_for_selected_stock_picking( + return self._response_for_select_line( picking, message={ "message_type": "info", @@ -967,12 +944,11 @@ def done(self, picking_id, confirmation=False): }, ) picking.action_done() - return self._response( - next_state="select_document", + return self._response_for_select_document( message={ "message_type": "success", "message": _("Transfer {} done").format(picking.name), - }, + } ) @@ -1219,7 +1195,9 @@ def select(self): ) def scan_line(self): - return self._response_schema(next_states={"select_line", "select_package"}) + return self._response_schema( + next_states={"select_line", "select_package", "summary"} + ) def select_line(self): return self.scan_line() From a5a7d12e52de923829878697db198ea21d797aac Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 30 Mar 2020 14:29:12 +0200 Subject: [PATCH 171/940] checkout: go to summary when no more lines to select When we want to scan or select a line and all lines already have been processed. --- shopfloor/services/checkout.py | 6 +++-- shopfloor/tests/test_checkout_scan_line.py | 25 ++++++++++++++++++++ shopfloor/tests/test_checkout_select_line.py | 20 ++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index da34175d40..26e12ab0c7 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -260,7 +260,8 @@ def scan_line(self, picking_id, barcode): message = self.actions_for("message") selection_lines = self._lines_to_pack(picking) - # TODO handle no lines in selection go to summary + if not selection_lines: + return self._response_for_summary(picking) package = search.package_from_scan(barcode) if package: @@ -410,7 +411,8 @@ def select_line(self, picking_id, package_id=None, move_line_id=None): return self._response_stock_picking_does_not_exist() selection_lines = self._lines_to_pack(picking) - # TODO if no remaining lines, go to summary + if not selection_lines: + return self._response_for_summary(picking) if package_id: package = self.env["stock.quant.package"].browse(package_id).exists() diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py index 4344b8f8a9..64401d051d 100644 --- a/shopfloor/tests/test_checkout_scan_line.py +++ b/shopfloor/tests/test_checkout_scan_line.py @@ -274,3 +274,28 @@ def test_scan_line_error_lot_in_one_package_and_unit(self): " packages, please scan a package.", }, ) + + def test_scan_line_all_lines_done(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10)] + ) + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + # set all lines as done + picking.move_line_ids.write( + {"qty_done": 10.0, "shopfloor_checkout_packed": True} + ) + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + # the barcode doesn't matter as we have no + # lines to pack anymore + "barcode": self.product_a.barcode, + }, + ) + self.assert_response( + response, + next_state="summary", + data={"picking": self._stock_picking_data(picking)}, + ) diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py index 5db7c188a1..ef033ee1ce 100644 --- a/shopfloor/tests/test_checkout_select_line.py +++ b/shopfloor/tests/test_checkout_select_line.py @@ -90,3 +90,23 @@ def test_select_line_move_line_error_not_found(self): "message": "The record you were working on does not exist anymore.", }, ) + + def test_select_line_all_lines_done(self): + # set all lines as done + self.picking.move_line_ids.write( + {"qty_done": 10.0, "shopfloor_checkout_packed": True} + ) + response = self.service.dispatch( + "select_line", + params={ + "picking_id": self.picking.id, + # doesn't matter as all lines are done, we should be + # redirected to the summary + "package_id": self.picking.move_line_ids[0].package_id.id, + }, + ) + self.assert_response( + response, + next_state="summary", + data={"picking": self._stock_picking_data(self.picking)}, + ) From de35d3b1acd1af26ca8e2f386a9db320a4c80828 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 1 Apr 2020 11:44:26 +0200 Subject: [PATCH 172/940] delivery: update endpoints specifications We simplified the workflow for delivery, now everything is on a single main screen. We only need other screens for the selection of stock.picking and for confirmation of partials. --- shopfloor/services/delivery.py | 175 +++++++++------------------------ 1 file changed, 49 insertions(+), 126 deletions(-) diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 6db8933d8d..3a33be7b3f 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -17,6 +17,11 @@ class Delivery(Component): * Products are moved to customer location as raw products * Bin packed products are placed in new shipping package and shipped to customer + Every time a package, product or lot is scanned, the package level and move line + are set to done. When the last line is scanned, the transfer is set to done. + Data for the last transfer for which we have been scanning a line if it is not done. + When a transfer is scanned, it returns its data to be shown on the screen. + Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP """ @@ -25,32 +30,33 @@ class Delivery(Component): _usage = "delivery" _description = __doc__ - # TODO we don't know yet if we have to select a destination package or a - # destination location - def xxx(self, barcode): - return self._result() + def scan_deliver(self, barcode): + """Scan a stock picking or a package/product/lot - # TODO we'll probably to add the selected destination package or location - # id in every endpoint parameters and returns - def scan_stock_picking(self, barcode): - """Scan a stock picking or a package + When a stock picking is scanned and is partially or fully available, it + is returned to show its lines. When a package is scanned, and has an available move line part of the expected picking type, the package level is directly set to "done" and the stock picking of the line is returned to work on its other lines. - When a stock picking is scanned and is partially or fully available, it - is returned to work on its lines. + If the barcode is a product or a product's packaging, the move lines + for this product are set to done. However, if the product is in more + than one package, a package barcode is requested, and if the product is + tracked by lot/serial, a lot is asked. - When all the available move lines and package levels of the stock picking - are done, the user is directed to the summary screen. + If the barcode is a lot, the mbarcode ove lines for this lot are set to + done. However, if the lot is in more than one package, a package + barcode is requested. + + NOTE: see scan_line in the Checkout service. + + When all the available move lines of the stock picking are done, the + stock picking is set to done. Transitions: - * select_source: error when scanning (stock picking not available, ...) - * move_set_done: when a valid package or stock picking has been scanned - and the stock picking still have lines / package levels not done - * summary: when a valid package or stock picking has been scanned - and all the lines of the stock picking are done + * deliver: always return here with the data for the last touched picking + or no picking if the picking has been set to done """ return self._response() @@ -71,18 +77,18 @@ def select(self, picking_id): Transitions: * manual_selection: the selected stock picking is no longer valid - * move_set_done: the selected stock picking has lines not done - * summary: the selected stock picking has all lines done + * deliver: with information about the stock.picking """ return self._response() def set_qty_done_pack(self, picking_id, package_id): """Set a package to "Done" + When all the available move lines of the stock picking are done, the + stock picking is set to done. + Transitions: - * move_set_done: error when setting done, or success but the stock - picking has other lines to set done - * summary: all the lines of the stock picking are now done + * deliver: always return here with updated data """ return self._response() @@ -92,42 +98,11 @@ def set_qty_done_line(self, picking_id, line_id): Should be called only for lines of raw products, /set_qty_done_pack must be used for lines that move a package. - Transitions: - * move_set_done: error when setting done, or success but the stock - picking has other lines to set done - * summary: all the lines of the stock picking are now done - """ - return self._response() - - def scan_line(self, picking_id, barcode): - """Set a move line or package to "Done" from a barcode - - If the barcode is a package in the picking and is available, it - sets it to done. - - If the barcode is a product or a product's packaging, the move lines - for this product are set to done. However, if the product is in more - than one package, a package barcode is requested, and if the product is - tracked by lot/serial, a lot is asked. - - If the barcode is a lot, the mbarcode ove lines for this lot are set to - done. However, if the lot is in more than one package, a package - barcode is requested. - - NOTE: see scan_line in the Checkout service. + When all the available move lines of the stock picking are done, the + stock picking is set to done. Transitions: - * move_set_done: error when setting done, or success but the stock - picking has other lines to set done - * summary: all the lines of the stock picking are now done - """ - return self._response() - - def summary(self, picking_id): - """Return data for the summary screen - - Transitions: - * summary + * deliver: always return here with updated data """ return self._response() @@ -135,7 +110,7 @@ def reset_qty_done_pack(self, picking_id, package_id): """Remove "Done" on a package Transitions: - * summary: return back to this state + * deliver: always return here with updated data """ return self._response() @@ -146,7 +121,7 @@ def reset_qty_done_line(self, picking_id, line_id): must be used for lines that move a package. Transitions: - * summary: return back to this state + * deliver: always return here with updated data """ return self._response() @@ -154,19 +129,8 @@ def done(self, picking_id): """Set the stock picking to done Transitions: - * summary: error during action - * select_source: stock picking was set to done, user can work on the - next stock picking - """ - return self._response() - - def back_to_move_set_done(self, picking_id): - """Allow to return to the "move_set_done" state with refreshed data - - Transitions: - * move_set_done: return to this state when not all lines of the - stock picking are done - * summary: return back to this state when all lines are done + * deliver: error during action + * confirm_done: when not all lines of the stock.picking are done """ return self._response() @@ -178,11 +142,7 @@ class ShopfloorDeliveryValidator(Component): _name = "shopfloor.delivery.validator" _usage = "delivery.validator" - # TODO - def xxx(self): - return {} - - def scan_stock_picking(self): + def scan_deliver(self): return {"barcode": {"required": True, "type": "string"}} def list_stock_picking(self): @@ -203,15 +163,6 @@ def set_qty_done_line(self): "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, } - def scan_line(self): - return { - "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, - "barcode": {"required": True, "type": "string"}, - } - - def summary(self): - return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}} - def reset_qty_done_pack(self): return { "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, @@ -227,9 +178,6 @@ def reset_qty_done_line(self): def done(self): return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}} - def back_to_move_set_done(self): - return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}} - class ShopfloorDeliveryValidatorResponse(Component): """Validators for the Delivery endpoints responses""" @@ -238,7 +186,7 @@ class ShopfloorDeliveryValidatorResponse(Component): _name = "shopfloor.delivery.validator.response" _usage = "delivery.validator.response" - _start_state = "start" + _start_state = "deliver" def _states(self): """List of possible next states @@ -247,17 +195,13 @@ def _states(self): to the next state. """ return { - "start": {}, - "select_source": self._schema_select_source, + "deliver": self._schema_deliver, "manual_selection": self._schema_selection_list, - "move_set_done": self._schema_stock_picking_details, - "summary": self._schema_stock_picking_details, - "confirm_summary": self._schema_stock_picking_details, + "confirm_done": self._schema_deliver, } - # TODO add the selected dest. package or location in returns (to keep it stateless) @property - def _schema_stock_picking_details(self): + def _schema_deliver(self): schema = self.schemas().picking() schema.update( { @@ -267,7 +211,7 @@ def _schema_stock_picking_details(self): } } ) - return {"picking": {"type": "dict", "schema": schema}} + return {"picking": {"type": "dict", "required": False, "schema": schema}} @property def _schema_selection_list(self): @@ -278,47 +222,26 @@ def _schema_selection_list(self): } } - @property - def _schema_select_source(self): - # TODO we don't know yet if we want to show the dest. location or package - return {} - - def xxx(self): - return self._response_schema(next_states={"start", "select_source"}) - - def scan_stock_picking(self): - return self._response_schema( - next_states={"select_source", "move_set_done", "summary"} - ) + def scan_deliver(self): + return self._response_schema(next_states={"deliver"}) def list_stock_picking(self): return self._response_schema(next_states={"manual_selection"}) def select(self): - return self._response_schema( - next_states={"manual_selection", "move_set_done", "summary"} - ) + return self._response_schema(next_states={"deliver"}) def set_qty_done_pack(self): - return self._response_schema(next_states={"move_set_done", "summary"}) + return self._response_schema(next_states={"deliver"}) def set_qty_done_line(self): - return self._response_schema(next_states={"move_set_done", "summary"}) - - def scan_line(self): - return self._response_schema(next_states={"move_set_done", "summary"}) - - def summary(self): - return self._response_schema(next_states={"summary"}) + return self._response_schema(next_states={"deliver"}) def reset_qty_done_pack(self): - return self._response_schema(next_states={"summary"}) + return self._response_schema(next_states={"deliver"}) def reset_qty_done_line(self): - return self._response_schema(next_states={"summary"}) + return self._response_schema(next_states={"deliver"}) def done(self): - return self._response_schema(next_states={"confirm_summary", "start"}) - - def back_to_move_set_done(self): - return self._response_schema(next_states={"move_set_done", "summary"}) + return self._response_schema(next_states={"deliver", "confirm_done"}) From 58f51548044772deeac72ea67b3d6eff89355be3 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 1 Apr 2020 11:55:13 +0200 Subject: [PATCH 173/940] checkout: fix parameters for _response_for_select_document introduced in 6ba3915 --- shopfloor/services/checkout.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 26e12ab0c7..bb9db670c7 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -115,7 +115,7 @@ def _select_picking(self, picking, state_for_error): message=message.stock_picking_not_available(picking) ) return self._response_for_select_document( - picking, message=message.stock_picking_not_available(picking) + message=message.stock_picking_not_available(picking) ) return self._response_for_select_line(picking) @@ -135,8 +135,7 @@ def _response_for_summary(self, picking, need_confirm=False, message=None): message=message, ) - def _response_for_select_document(self, picking, message=None): - message = self.actions_for("message") + def _response_for_select_document(self, message=None): return self._response(next_state="select_document", message=message) def _data_for_stock_picking(self, picking): From a9c737abafe45634efd1a11c18f56e1a24bed919 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 10 Apr 2020 14:51:50 +0200 Subject: [PATCH 174/940] frontend: adapt data to backend changes checkout --- shopfloor/services/cluster_picking.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index a81634f37d..41da540a84 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -266,6 +266,7 @@ def _data_for_next_move_line(self, picking_batch): return self._data_move_line(remaining_lines[0]) def _data_move_line(self, line): + # TODO: use shopfloor.data.action.move_line picking = line.picking_id batch = picking.batch_id product = line.product_id From 9f2581bb6efe6e65aad84efdda3fab737c60c48c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 24 Apr 2020 13:33:22 +0200 Subject: [PATCH 175/940] Remove dead code --- shopfloor/services/checkout.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index bb9db670c7..76acbfae7c 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -898,13 +898,6 @@ def remove_package(self, picking_id, package_id): ) return self._response_for_summary(picking) - @staticmethod - def _filter_lines_(move_line): - return ( - move_line.qty_done == move_line.product_uom_qty - and move_line.shopfloor_checkout_packed - ) - def done(self, picking_id, confirmation=False): """Set the moves as done From 9815b7185b91218aef2e06357dc9b3eecc237980 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 24 Apr 2020 09:45:42 +0200 Subject: [PATCH 176/940] backend: fix summary endpoints --- shopfloor/services/checkout.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 76acbfae7c..883ea54efb 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -131,25 +131,25 @@ def _response_for_select_line(self, picking, message=None): def _response_for_summary(self, picking, need_confirm=False, message=None): return self._response( next_state="summary" if not need_confirm else "confirm_done", - data={"picking": self._data_for_stock_picking(picking)}, + data={"picking": self._data_for_stock_picking(picking, packed=True)}, message=message, ) def _response_for_select_document(self, message=None): return self._response(next_state="select_document", message=message) - def _data_for_stock_picking(self, picking): + def _data_for_stock_picking(self, picking, packed=False): data_struct = self.actions_for("data") data = data_struct.picking_summary(picking) + line_picker = self._lines_packed if packed else self._lines_to_pack data.update( - { - "move_lines": [ - data_struct.move_line(ml) for ml in self._lines_to_pack(picking) - ] - } + {"move_lines": [data_struct.move_line(ml) for ml in line_picker(picking)]} ) return data + def _lines_packed(self, picking): + return picking.move_line_ids.filtered(self._filter_lines_packed) + def _lines_to_pack(self, picking): return picking.move_line_ids.filtered(self._filter_lines_unpacked) @@ -1211,7 +1211,7 @@ def scan_package_action(self): ) def new_package(self): - return self._response_schema(next_states={"select_line"}) + return self._response_schema(next_states={"select_line", "summary"}) def list_dest_package(self): return self._response_schema( From 35886a795ca877e4261c2c4879375b8be42f3da3 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 29 Apr 2020 10:11:54 +0200 Subject: [PATCH 177/940] backend: add checkout endpoint no_package --- shopfloor/services/checkout.py | 29 +++++++++++ shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_checkout_no_package.py | 56 +++++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 shopfloor/tests/test_checkout_no_package.py diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 883ea54efb..6b8080d510 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -676,6 +676,29 @@ def new_package(self, picking_id, selected_line_ids): selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() return self._create_and_assign_new_packaging(picking, selected_lines) + def no_package(self, picking_id, selected_line_ids): + """Process all selected lines without any package. + + Selected lines are move lines in the list of ``move_line_ids`` where + ``qty_done`` > 0 and have no destination package + (shopfloor_checkout_packed is False). + + Transitions: + * select_line: goes back to selection of lines to work on next lines + """ + picking = self.env["stock.picking"].browse(picking_id) + if not picking.exists(): + return self._response_stock_picking_does_not_exist() + selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() + selected_lines.write({"shopfloor_checkout_packed": True}) + return self._response_for_select_line( + picking, + message={ + "message_type": "info", + "message": _("Product(s) processed as raw product(s)"), + }, + ) + def list_dest_package(self, picking_id, selected_line_ids): """Return a list of packages the user can select for the lines @@ -1030,6 +1053,9 @@ def new_package(self): }, } + def no_package(self): + return self.new_package() + def list_dest_package(self): return { "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, @@ -1213,6 +1239,9 @@ def scan_package_action(self): def new_package(self): return self._response_schema(next_states={"select_line", "summary"}) + def no_package(self): + return self.new_package() + def list_dest_package(self): return self._response_schema( next_states={"select_dest_package", "select_package"} diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 8911b00f82..87e4ace1c2 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -19,6 +19,7 @@ from . import test_checkout_set_qty from . import test_checkout_scan_package_action from . import test_checkout_new_package +from . import test_checkout_no_package from . import test_checkout_list_package from . import test_checkout_summary from . import test_checkout_change_packaging diff --git a/shopfloor/tests/test_checkout_no_package.py b/shopfloor/tests/test_checkout_no_package.py new file mode 100644 index 0000000000..9cdfd567c9 --- /dev/null +++ b/shopfloor/tests/test_checkout_no_package.py @@ -0,0 +1,56 @@ +from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin + + +class CheckoutNoPackageCase(CheckoutCommonCase, CheckoutSelectPackageMixin): + def test_no_package_ok(self): + picking = self._create_picking( + lines=[ + (self.product_a, 10), + (self.product_b, 10), + (self.product_c, 10), + (self.product_d, 10), + ] + ) + pack1_moves = picking.move_lines[:3] + pack2_moves = picking.move_lines[3:] + # put in 2 packs, for this test, we'll work on pack1 + self._fill_stock_for_moves(pack1_moves) + self._fill_stock_for_moves(pack2_moves) + picking.action_assign() + + move_line1, move_line2, move_line3 = pack1_moves.move_line_ids + selected_lines = move_line1 + move_line2 + + # we'll put only the first 2 lines (product A and B) w/ no package + move_line1.qty_done = move_line1.product_uom_qty + move_line2.qty_done = move_line2.product_uom_qty + move_line3.qty_done = 0 + + response = self.service.dispatch( + "no_package", + params={"picking_id": picking.id, "selected_line_ids": selected_lines.ids}, + ) + + self.assertRecordValues( + move_line1, + [{"result_package_id": False, "shopfloor_checkout_packed": True}], + ) + self.assertRecordValues( + move_line2, + [{"result_package_id": False, "shopfloor_checkout_packed": True}], + ) + self.assertRecordValues( + move_line3, + [{"result_package_id": False, "shopfloor_checkout_packed": False}], + ) + self.assert_response( + response, + # go pack to the screen to select lines to put in packages + next_state="select_line", + data={"picking": self._stock_picking_data(picking)}, + message={ + "message_type": "info", + "message": "Product(s) processed as raw product(s)", + }, + ) From 778c54059f1ba3e13db9697d3d532765da5bc459 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 29 Apr 2020 11:09:58 +0200 Subject: [PATCH 178/940] backend: checkout summary load only done lines --- shopfloor/models/stock_move_line.py | 2 +- shopfloor/services/checkout.py | 47 ++++++++++--------- shopfloor/tests/test_checkout_base.py | 4 +- .../tests/test_checkout_change_packaging.py | 8 ++-- shopfloor/tests/test_checkout_done.py | 14 +++--- shopfloor/tests/test_checkout_list_package.py | 9 ++-- shopfloor/tests/test_checkout_new_package.py | 6 +-- shopfloor/tests/test_checkout_no_package.py | 8 ++-- .../tests/test_checkout_remove_package.py | 16 +++---- shopfloor/tests/test_checkout_scan_line.py | 6 +-- .../test_checkout_scan_package_action.py | 24 +++++----- shopfloor/tests/test_checkout_select_line.py | 4 +- shopfloor/tests/test_checkout_summary.py | 2 +- 13 files changed, 72 insertions(+), 78 deletions(-) diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py index 0d772accc9..0ee951644f 100644 --- a/shopfloor/models/stock_move_line.py +++ b/shopfloor/models/stock_move_line.py @@ -12,7 +12,7 @@ class StockMoveLine(models.Model): help="Technical field. " "Indicates if a the move has been postponed in a process.", ) - shopfloor_checkout_packed = fields.Boolean(default=False) + shopfloor_checkout_done = fields.Boolean(default=False) # we search lines based on their location in some workflows location_id = fields.Many2one(index=True) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 6b8080d510..79cd7470a4 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -23,8 +23,8 @@ class Checkout(Component): 5) Products are not packed (e.g. raw products) and we create new packages 6) Products are not packed (e.g. raw products) and we do not create packages - A new flag ``shopfloor_checkout_packed`` on move lines allows to track which - lines have been put in a package. + A new flag ``shopfloor_checkout_done`` on move lines allows to track which + lines have been checked out (can be with or without package). Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP """ @@ -120,7 +120,7 @@ def _select_picking(self, picking, state_for_error): return self._response_for_select_line(picking) def _response_for_select_line(self, picking, message=None): - if all(line.shopfloor_checkout_packed for line in picking.move_line_ids): + if all(line.shopfloor_checkout_done for line in picking.move_line_ids): return self._response_for_summary(picking, message=message) return self._response( next_state="select_line", @@ -131,24 +131,24 @@ def _response_for_select_line(self, picking, message=None): def _response_for_summary(self, picking, need_confirm=False, message=None): return self._response( next_state="summary" if not need_confirm else "confirm_done", - data={"picking": self._data_for_stock_picking(picking, packed=True)}, + data={"picking": self._data_for_stock_picking(picking, done=True)}, message=message, ) def _response_for_select_document(self, message=None): return self._response(next_state="select_document", message=message) - def _data_for_stock_picking(self, picking, packed=False): + def _data_for_stock_picking(self, picking, done=False): data_struct = self.actions_for("data") data = data_struct.picking_summary(picking) - line_picker = self._lines_packed if packed else self._lines_to_pack + line_picker = self._lines_checkout_done if done else self._lines_to_pack data.update( {"move_lines": [data_struct.move_line(ml) for ml in line_picker(picking)]} ) return data - def _lines_packed(self, picking): - return picking.move_line_ids.filtered(self._filter_lines_packed) + def _lines_checkout_done(self, picking): + return picking.move_line_ids.filtered(self._filter_lines_checkout_done) def _lines_to_pack(self, picking): return picking.move_line_ids.filtered(self._filter_lines_unpacked) @@ -221,7 +221,7 @@ def _response_for_select_package(self, lines, message=None): def _select_lines(self, lines): for line in lines: - if line.shopfloor_checkout_packed: + if line.shopfloor_checkout_done: continue line.qty_done = line.product_uom_qty @@ -230,7 +230,7 @@ def _select_lines(self, lines): self._deselect_lines(other_lines) def _deselect_lines(self, lines): - lines.filtered(lambda l: not l.shopfloor_checkout_packed).qty_done = 0 + lines.filtered(lambda l: not l.shopfloor_checkout_done).qty_done = 0 def scan_line(self, picking_id, barcode): """Scan move lines of the stock picking @@ -522,15 +522,15 @@ def _switch_line_qty_done(self, picking, selected_lines, switch_lines): @staticmethod def _filter_lines_unpacked(move_line): - return move_line.qty_done == 0 and not move_line.shopfloor_checkout_packed + return move_line.qty_done == 0 and not move_line.shopfloor_checkout_done @staticmethod def _filter_lines_to_pack(move_line): - return move_line.qty_done > 0 and not move_line.shopfloor_checkout_packed + return move_line.qty_done > 0 and not move_line.shopfloor_checkout_done @staticmethod - def _filter_lines_packed(move_line): - return move_line.qty_done > 0 and move_line.shopfloor_checkout_packed + def _filter_lines_checkout_done(move_line): + return move_line.qty_done > 0 and move_line.shopfloor_checkout_done def _is_package_allowed(self, picking, package): existing_packages = picking.mapped("move_line_ids.result_package_id") @@ -560,7 +560,7 @@ def _put_lines_in_package(self, picking, selected_lines, package): def _put_lines_in_allowed_package(self, picking, selected_lines, package): lines_to_pack = selected_lines.filtered(self._filter_lines_to_pack) lines_to_pack.write( - {"result_package_id": package.id, "shopfloor_checkout_packed": True} + {"result_package_id": package.id, "shopfloor_checkout_done": True} ) # go back to the screen to select the next lines to pack return self._response_for_select_line( @@ -610,7 +610,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): Lines to pack are move lines in the list of ``selected_line_ids`` where ``qty_done`` > 0 and have not been packed yet - (``shopfloor_checkout_packed is False``). + (``shopfloor_checkout_done is False``). Transitions: * select_package: when a product or lot is scanned to select/deselect, @@ -665,7 +665,7 @@ def new_package(self, picking_id, selected_line_ids): Selected lines are move lines in the list of ``move_line_ids`` where ``qty_done`` > 0 and have no destination package - (shopfloor_checkout_packed is False). + (shopfloor_checkout_done is False). Transitions: * select_line: goes back to selection of lines to work on next lines @@ -681,7 +681,7 @@ def no_package(self, picking_id, selected_line_ids): Selected lines are move lines in the list of ``move_line_ids`` where ``qty_done`` > 0 and have no destination package - (shopfloor_checkout_packed is False). + (shopfloor_checkout_done is False). Transitions: * select_line: goes back to selection of lines to work on next lines @@ -690,7 +690,7 @@ def no_package(self, picking_id, selected_line_ids): if not picking.exists(): return self._response_stock_picking_does_not_exist() selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() - selected_lines.write({"shopfloor_checkout_packed": True}) + selected_lines.write({"shopfloor_checkout_done": True}) return self._response_for_select_line( picking, message={ @@ -893,7 +893,7 @@ def remove_package(self, picking_id, package_id): All the move lines with the package as ``result_package_id`` have their ``result_package_id`` reset to the source package (default odoo behavior) and their ``qty_done`` set to 0. - It flags ``shopfloor_checkout_packed`` to False so they have to be packed again. + It flags ``shopfloor_checkout_done`` to False so they have to be packed again. Transitions: * summary @@ -909,14 +909,15 @@ def remove_package(self, picking_id, package_id): picking, message=message.record_not_found() ) move_lines = picking.move_line_ids.filtered( - lambda l: self._filter_lines_packed(l) and l.result_package_id == package + lambda l: self._filter_lines_checkout_done(l) + and l.result_package_id == package ) for move_line in move_lines: move_line.write( { "qty_done": 0, "result_package_id": move_line.package_id, - "shopfloor_checkout_packed": False, + "shopfloor_checkout_done": False, } ) return self._response_for_summary(picking) @@ -949,7 +950,7 @@ def done(self, picking_id, confirmation=False): ), }, ) - elif not all(line.shopfloor_checkout_packed for line in lines): + elif not all(line.shopfloor_checkout_done for line in lines): return self._response_for_summary( picking, need_confirm=True, diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index cbf348b246..1f09b9d0a3 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -17,8 +17,8 @@ def setUp(self): with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="checkout") - def _stock_picking_data(self, picking): - return self.service._data_for_stock_picking(picking) + def _stock_picking_data(self, picking, **kw): + return self.service._data_for_stock_picking(picking, **kw) # we test the methods that structure data in test_actions_data.py def _picking_summary_data(self, picking): diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py index d532830a1b..3d06fc48ad 100644 --- a/shopfloor/tests/test_checkout_change_packaging.py +++ b/shopfloor/tests/test_checkout_change_packaging.py @@ -69,7 +69,7 @@ def test_list_packaging_error_package_not_found(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(self.picking)}, + data={"picking": self._stock_picking_data(self.picking, done=True)}, message={ "message_type": "error", "message": "The record you were working on does not exist anymore.", @@ -91,7 +91,7 @@ def test_set_packaging_ok(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(self.picking)}, + data={"picking": self._stock_picking_data(self.picking, done=True)}, message={ "message_type": "success", "message": "Packaging changed on package {}".format(self.package.name), @@ -110,7 +110,7 @@ def test_set_packaging_error_package_not_found(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(self.picking)}, + data={"picking": self._stock_picking_data(self.picking, done=True)}, message={ "message_type": "error", "message": "The record you were working on does not exist anymore.", @@ -129,7 +129,7 @@ def test_set_packaging_error_packaging_not_found(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(self.picking)}, + data={"picking": self._stock_picking_data(self.picking, done=True)}, message={ "message_type": "error", "message": "The record you were working on does not exist anymore.", diff --git a/shopfloor/tests/test_checkout_done.py b/shopfloor/tests/test_checkout_done.py index 373fb2727e..f47db4fd6c 100644 --- a/shopfloor/tests/test_checkout_done.py +++ b/shopfloor/tests/test_checkout_done.py @@ -7,7 +7,7 @@ def test_done_ok(self): self._fill_stock_for_moves(picking.move_lines, in_package=True) picking.action_assign() # line is done - picking.move_line_ids.write({"qty_done": 10, "shopfloor_checkout_packed": True}) + picking.move_line_ids.write({"qty_done": 10, "shopfloor_checkout_done": True}) response = self.service.dispatch("done", params={"picking_id": picking.id}) self.assertRecordValues(picking, [{"state": "done"}]) @@ -33,8 +33,8 @@ def setUpClass(cls): picking.action_assign() cls.line1 = picking.move_line_ids[0] cls.line2 = picking.move_line_ids[1] - cls.line1.write({"qty_done": 10, "shopfloor_checkout_packed": True}) - cls.line2.write({"qty_done": 2, "shopfloor_checkout_packed": True}) + cls.line1.write({"qty_done": 10, "shopfloor_checkout_done": True}) + cls.line2.write({"qty_done": 2, "shopfloor_checkout_done": True}) def test_done_partial(self): # line is done @@ -45,7 +45,7 @@ def test_done_partial(self): self.assert_response( response, next_state="confirm_done", - data={"picking": self._stock_picking_data(self.picking)}, + data={"picking": self._stock_picking_data(self.picking, done=True)}, message={ "message_type": "warning", "message": "Not all lines have been processed, do" @@ -87,11 +87,11 @@ def setUpClass(cls): cls.line1.write( { "qty_done": 10, - "shopfloor_checkout_packed": True, + "shopfloor_checkout_done": True, "result_package_id": cls.package.id, } ) - cls.line2.write({"qty_done": 10, "shopfloor_checkout_packed": False}) + cls.line2.write({"qty_done": 10, "shopfloor_checkout_done": False}) def test_done_partial(self): # line is done @@ -102,7 +102,7 @@ def test_done_partial(self): self.assert_response( response, next_state="confirm_done", - data={"picking": self._stock_picking_data(self.picking)}, + data={"picking": self._stock_picking_data(self.picking, done=True)}, message={ "message_type": "warning", "message": "Remaining raw product not packed, proceed anyway?", diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py index e008422ac2..a17bcb9d91 100644 --- a/shopfloor/tests/test_checkout_list_package.py +++ b/shopfloor/tests/test_checkout_list_package.py @@ -130,17 +130,14 @@ def _assert_package_set(self, response): [ { "result_package_id": self.package.id, - "shopfloor_checkout_packed": True, + "shopfloor_checkout_done": True, }, { "result_package_id": self.package.id, - "shopfloor_checkout_packed": True, + "shopfloor_checkout_done": True, }, # qty_done was zero so we don't set it as packed - { - "result_package_id": self.pack1.id, - "shopfloor_checkout_packed": False, - }, + {"result_package_id": self.pack1.id, "shopfloor_checkout_done": False}, ], ) self.assert_response( diff --git a/shopfloor/tests/test_checkout_new_package.py b/shopfloor/tests/test_checkout_new_package.py index bd62d52c49..ab720ce5ae 100644 --- a/shopfloor/tests/test_checkout_new_package.py +++ b/shopfloor/tests/test_checkout_new_package.py @@ -38,17 +38,17 @@ def test_new_package_ok(self): self.assertRecordValues( move_line1, - [{"result_package_id": new_package.id, "shopfloor_checkout_packed": True}], + [{"result_package_id": new_package.id, "shopfloor_checkout_done": True}], ) self.assertRecordValues( move_line2, - [{"result_package_id": new_package.id, "shopfloor_checkout_packed": True}], + [{"result_package_id": new_package.id, "shopfloor_checkout_done": True}], ) self.assertRecordValues( move_line3, # qty_done was zero so we don't set it as packed and it remains in # the same package - [{"result_package_id": pack1.id, "shopfloor_checkout_packed": False}], + [{"result_package_id": pack1.id, "shopfloor_checkout_done": False}], ) self.assert_response( response, diff --git a/shopfloor/tests/test_checkout_no_package.py b/shopfloor/tests/test_checkout_no_package.py index 9cdfd567c9..2da2f732a6 100644 --- a/shopfloor/tests/test_checkout_no_package.py +++ b/shopfloor/tests/test_checkout_no_package.py @@ -33,16 +33,14 @@ def test_no_package_ok(self): ) self.assertRecordValues( - move_line1, - [{"result_package_id": False, "shopfloor_checkout_packed": True}], + move_line1, [{"result_package_id": False, "shopfloor_checkout_done": True}], ) self.assertRecordValues( - move_line2, - [{"result_package_id": False, "shopfloor_checkout_packed": True}], + move_line2, [{"result_package_id": False, "shopfloor_checkout_done": True}], ) self.assertRecordValues( move_line3, - [{"result_package_id": False, "shopfloor_checkout_packed": False}], + [{"result_package_id": False, "shopfloor_checkout_done": False}], ) self.assert_response( response, diff --git a/shopfloor/tests/test_checkout_remove_package.py b/shopfloor/tests/test_checkout_remove_package.py index 9a2d407118..09c8e73976 100644 --- a/shopfloor/tests/test_checkout_remove_package.py +++ b/shopfloor/tests/test_checkout_remove_package.py @@ -34,7 +34,7 @@ def test_remove_package_ok(self): { "qty_done": 10, "result_package_id": new_package.id, - "shopfloor_checkout_packed": True, + "shopfloor_checkout_done": True, } ) new_package2 = self.env["stock.quant.package"].create({}) @@ -42,7 +42,7 @@ def test_remove_package_ok(self): { "qty_done": 10, "result_package_id": new_package2.id, - "shopfloor_checkout_packed": True, + "shopfloor_checkout_done": True, } ) @@ -59,25 +59,25 @@ def test_remove_package_ok(self): "qty_done": 0, # reset to origin package "result_package_id": pack1_lines.mapped("package_id").id, - "shopfloor_checkout_packed": False, + "shopfloor_checkout_done": False, }, { "qty_done": 0, # reset to origin package "result_package_id": pack1_lines.mapped("package_id").id, - "shopfloor_checkout_packed": False, + "shopfloor_checkout_done": False, }, { "qty_done": 0, # result to an empty package (raw product) "result_package_id": False, - "shopfloor_checkout_packed": False, + "shopfloor_checkout_done": False, }, # different package, leave untouched { "qty_done": 10, "result_package_id": new_package2.id, - "shopfloor_checkout_packed": True, + "shopfloor_checkout_done": True, }, ], ) @@ -85,7 +85,7 @@ def test_remove_package_ok(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(picking)}, + data={"picking": self._stock_picking_data(picking, done=True)}, ) def test_remove_package_error_package_not_found(self): @@ -96,7 +96,7 @@ def test_remove_package_error_package_not_found(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(self.picking)}, + data={"picking": self._stock_picking_data(self.picking, done=True)}, message={ "message_type": "error", "message": "The record you were working on does not exist anymore.", diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py index 64401d051d..8163bcdfcd 100644 --- a/shopfloor/tests/test_checkout_scan_line.py +++ b/shopfloor/tests/test_checkout_scan_line.py @@ -282,9 +282,7 @@ def test_scan_line_all_lines_done(self): self._fill_stock_for_moves(picking.move_lines, in_package=True) picking.action_assign() # set all lines as done - picking.move_line_ids.write( - {"qty_done": 10.0, "shopfloor_checkout_packed": True} - ) + picking.move_line_ids.write({"qty_done": 10.0, "shopfloor_checkout_done": True}) response = self.service.dispatch( "scan_line", params={ @@ -297,5 +295,5 @@ def test_scan_line_all_lines_done(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(picking)}, + data={"picking": self._stock_picking_data(picking, done=True)}, ) diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py index 1af133148d..0c293e308f 100644 --- a/shopfloor/tests/test_checkout_scan_package_action.py +++ b/shopfloor/tests/test_checkout_scan_package_action.py @@ -148,16 +148,16 @@ def test_scan_package_action_scan_package_keep_source_package_ok(self): self.assertRecordValues( move_line1, - [{"result_package_id": pack1.id, "shopfloor_checkout_packed": True}], + [{"result_package_id": pack1.id, "shopfloor_checkout_done": True}], ) self.assertRecordValues( move_line2, - [{"result_package_id": pack1.id, "shopfloor_checkout_packed": True}], + [{"result_package_id": pack1.id, "shopfloor_checkout_done": True}], ) self.assertRecordValues( move_line3, # qty_done was zero so we don't set it as packed - [{"result_package_id": pack1.id, "shopfloor_checkout_packed": False}], + [{"result_package_id": pack1.id, "shopfloor_checkout_done": False}], ) self.assert_response( response, @@ -195,7 +195,7 @@ def test_scan_package_action_scan_package_error_invalid(self): # the result package must remain identical, so equal to the # source package "result_package_id": selected_line.package_id.id, - "shopfloor_checkout_packed": False, + "shopfloor_checkout_done": False, } ], ) @@ -229,7 +229,7 @@ def test_scan_package_action_scan_package_use_existing_package_ok(self): # assume that product d was already put in a package, # we must be able to put the lines of pack1 inside the same pack2_moves.move_line_ids.write( - {"result_package_id": package.id, "shopfloor_checkout_packed": True} + {"result_package_id": package.id, "shopfloor_checkout_done": True} ) selected_lines = pack1_moves.move_line_ids @@ -249,9 +249,9 @@ def test_scan_package_action_scan_package_use_existing_package_ok(self): self.assertRecordValues( selected_lines, [ - {"result_package_id": package.id, "shopfloor_checkout_packed": True}, - {"result_package_id": package.id, "shopfloor_checkout_packed": True}, - {"result_package_id": package.id, "shopfloor_checkout_packed": True}, + {"result_package_id": package.id, "shopfloor_checkout_done": True}, + {"result_package_id": package.id, "shopfloor_checkout_done": True}, + {"result_package_id": package.id, "shopfloor_checkout_done": True}, ], ) @@ -259,7 +259,7 @@ def test_scan_package_action_scan_package_use_existing_package_ok(self): response, # all the lines are packed, so we expect to go the summary screen next_state="summary", - data={"picking": self._stock_picking_data(picking)}, + data={"picking": self._stock_picking_data(picking, done=True)}, message={ "message_type": "info", "message": "Product(s) packed in {}".format(package.name), @@ -328,17 +328,17 @@ def test_scan_package_action_scan_packaging_ok(self): self.assertRecordValues( move_line1, - [{"result_package_id": new_package.id, "shopfloor_checkout_packed": True}], + [{"result_package_id": new_package.id, "shopfloor_checkout_done": True}], ) self.assertRecordValues( move_line2, - [{"result_package_id": new_package.id, "shopfloor_checkout_packed": True}], + [{"result_package_id": new_package.id, "shopfloor_checkout_done": True}], ) self.assertRecordValues( move_line3, # qty_done was zero so we don't set it as packed and it remains in # the same package - [{"result_package_id": pack1.id, "shopfloor_checkout_packed": False}], + [{"result_package_id": pack1.id, "shopfloor_checkout_done": False}], ) self.assert_response( response, diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py index ef033ee1ce..800daf56de 100644 --- a/shopfloor/tests/test_checkout_select_line.py +++ b/shopfloor/tests/test_checkout_select_line.py @@ -94,7 +94,7 @@ def test_select_line_move_line_error_not_found(self): def test_select_line_all_lines_done(self): # set all lines as done self.picking.move_line_ids.write( - {"qty_done": 10.0, "shopfloor_checkout_packed": True} + {"qty_done": 10.0, "shopfloor_checkout_done": True} ) response = self.service.dispatch( "select_line", @@ -108,5 +108,5 @@ def test_select_line_all_lines_done(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(self.picking)}, + data={"picking": self._stock_picking_data(self.picking, done=True)}, ) diff --git a/shopfloor/tests/test_checkout_summary.py b/shopfloor/tests/test_checkout_summary.py index c58803c11c..9b0149335d 100644 --- a/shopfloor/tests/test_checkout_summary.py +++ b/shopfloor/tests/test_checkout_summary.py @@ -16,5 +16,5 @@ def test_summary_ok(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(picking)}, + data={"picking": self._stock_picking_data(picking, done=True)}, ) From 965ec6b2d2212d49f229936bb47cf63905944303 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 29 Apr 2020 12:12:25 +0200 Subject: [PATCH 179/940] backend: replace 'remove_package' w/ 'cancel_line' We can checkout raw products and we might want to cancel them as we were able to do w/ packages by removing them. 'remove_package' is renamed to 'cancel_line' and accept both a package_id or a line_id. --- shopfloor/services/checkout.py | 58 +++++++++++++------ shopfloor/tests/__init__.py | 2 +- ...ackage.py => test_checkout_cancel_line.py} | 45 ++++++++++++-- 3 files changed, 82 insertions(+), 23 deletions(-) rename shopfloor/tests/{test_checkout_remove_package.py => test_checkout_cancel_line.py} (70%) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 79cd7470a4..e8c59d4c08 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -887,13 +887,18 @@ def set_packaging(self, picking_id, package_id, packaging_id): }, ) - def remove_package(self, picking_id, package_id): - """Remove destination package from move lines and set qty done to 0 + def cancel_line(self, picking_id, package_id=None, line_id=None): + """Cancel work done on given line or package. + + If package, remove destination package from lines and set qty done to 0. + If line is a raw product, set qty done to 0. All the move lines with the package as ``result_package_id`` have their ``result_package_id`` reset to the source package (default odoo behavior) and their ``qty_done`` set to 0. - It flags ``shopfloor_checkout_done`` to False so they have to be packed again. + + It flags ``shopfloor_checkout_done`` to False + so they have to be processed again. Transitions: * summary @@ -904,22 +909,28 @@ def remove_package(self, picking_id, package_id): return self._response_stock_picking_does_not_exist() package = self.env["stock.quant.package"].browse(package_id).exists() - if not package: + line = self.env["stock.move.line"].browse(line_id).exists() + if not package and not line: return self._response_for_summary( picking, message=message.record_not_found() ) - move_lines = picking.move_line_ids.filtered( - lambda l: self._filter_lines_checkout_done(l) - and l.result_package_id == package - ) - for move_line in move_lines: - move_line.write( - { - "qty_done": 0, - "result_package_id": move_line.package_id, - "shopfloor_checkout_done": False, - } + + if package: + move_lines = picking.move_line_ids.filtered( + lambda l: self._filter_lines_checkout_done(l) + and l.result_package_id == package ) + for move_line in move_lines: + move_line.write( + { + "qty_done": 0, + "result_package_id": move_line.package_id, + "shopfloor_checkout_done": False, + } + ) + if line: + line.write({"qty_done": 0, "shopfloor_checkout_done": False}) + return self._response_for_summary(picking) def done(self, picking_id, confirmation=False): @@ -1105,10 +1116,21 @@ def set_packaging(self): "packaging_id": {"coerce": to_int, "required": True, "type": "integer"}, } - def remove_package(self): + def cancel_line(self): return { "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, - "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": { + "coerce": to_int, + "required": True, + "type": "integer", + "excludes": "line_id", + }, + "line_id": { + "coerce": to_int, + "required": True, + "type": "integer", + "excludes": "package_id", + }, } def done(self): @@ -1267,7 +1289,7 @@ def list_packaging(self): def set_packaging(self): return self._response_schema(next_states={"change_packaging", "summary"}) - def remove_package(self): + def cancel_line(self): return self._response_schema(next_states={"summary"}) def done(self): diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 87e4ace1c2..baa5b40e96 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -23,6 +23,6 @@ from . import test_checkout_list_package from . import test_checkout_summary from . import test_checkout_change_packaging -from . import test_checkout_remove_package +from . import test_checkout_cancel_line from . import test_checkout_done from . import test_delivery_base diff --git a/shopfloor/tests/test_checkout_remove_package.py b/shopfloor/tests/test_checkout_cancel_line.py similarity index 70% rename from shopfloor/tests/test_checkout_remove_package.py rename to shopfloor/tests/test_checkout_cancel_line.py index 09c8e73976..76f5cda812 100644 --- a/shopfloor/tests/test_checkout_remove_package.py +++ b/shopfloor/tests/test_checkout_cancel_line.py @@ -21,7 +21,7 @@ def setUpClass(cls): cls._fill_stock_for_moves(cls.raw_move) picking.action_assign() - def test_remove_package_ok(self): + def test_cancel_package_ok(self): picking = self.picking pack1_lines = self.pack1_moves.move_line_ids @@ -48,7 +48,7 @@ def test_remove_package_ok(self): # and now, we want to drop the new_package response = self.service.dispatch( - "remove_package", + "cancel_line", params={"picking_id": picking.id, "package_id": new_package.id}, ) @@ -88,10 +88,47 @@ def test_remove_package_ok(self): data={"picking": self._stock_picking_data(picking, done=True)}, ) - def test_remove_package_error_package_not_found(self): + def test_cancel_line_ok(self): + picking = self.picking + + raw_line = self.raw_move.move_line_ids + + raw_line.write({"qty_done": 10, "shopfloor_checkout_done": True}) + + # and now, we want to drop the new_package + response = self.service.dispatch( + "cancel_line", params={"picking_id": picking.id, "line_id": raw_line.id}, + ) + + self.assertRecordValues( + raw_line, [{"qty_done": 0, "shopfloor_checkout_done": False}], + ) + + self.assert_response( + response, + next_state="summary", + data={"picking": self._stock_picking_data(picking, done=True)}, + ) + + def test_cancel_line_error_package_not_found(self): + # and now, we want to drop the new_package + response = self.service.dispatch( + "cancel_line", params={"picking_id": self.picking.id, "package_id": 0} + ) + self.assert_response( + response, + next_state="summary", + data={"picking": self._stock_picking_data(self.picking, done=True)}, + message={ + "message_type": "error", + "message": "The record you were working on does not exist anymore.", + }, + ) + + def test_cancel_line_error_line_not_found(self): # and now, we want to drop the new_package response = self.service.dispatch( - "remove_package", params={"picking_id": self.picking.id, "package_id": 0} + "cancel_line", params={"picking_id": self.picking.id, "line_id": 0} ) self.assert_response( response, From 91c1dc0f4450e0b244a3802c528828b74f473bab Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 29 Apr 2020 16:24:18 +0200 Subject: [PATCH 180/940] backend: checkout fix lookup of pickings via package --- shopfloor/services/checkout.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index e8c59d4c08..9746e2bf7d 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -85,8 +85,17 @@ def scan_document(self, barcode): if not picking: package = search.package_from_scan(barcode) if package: - lines = package.planned_move_line_ids - pickings = lines.mapped("picking_id") + pickings = ( + self.env["stock.move.line"] + .search( + [ + ("state", "not in", ("cancel", "done")), + ("package_id", "=", package.id), + ("picking_id.picking_type_id", "=", self.picking_type.id), + ] + ) + .mapped("picking_id") + ) if len(pickings) == 1: picking = pickings return self._select_picking(picking, "select_document") From 6dbd56cc7be8a26d3abdef9a958c06163ded6258 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 29 Apr 2020 16:25:42 +0200 Subject: [PATCH 181/940] backend: checkout summary add 'all_processed' flag --- shopfloor/services/checkout.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 9746e2bf7d..236356bd5a 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -140,7 +140,10 @@ def _response_for_select_line(self, picking, message=None): def _response_for_summary(self, picking, need_confirm=False, message=None): return self._response( next_state="summary" if not need_confirm else "confirm_done", - data={"picking": self._data_for_stock_picking(picking, done=True)}, + data={ + "picking": self._data_for_stock_picking(picking, done=True), + "all_processed": not bool(self._lines_to_pack(picking)), + }, message=message, ) @@ -965,8 +968,8 @@ def done(self, picking_id, confirmation=False): message={ "message_type": "warning", "message": _( - "Not all lines have been processed, do you" - " want to confirm partial operation?" + "Not all lines have been processed with full quantity. " + "Do you confirm partial operation?" ), }, ) @@ -1171,7 +1174,7 @@ def _states(self): "select_package": self._schema_selected_lines, "change_quantity": self._schema_selected_lines, "select_dest_package": self._schema_select_package, - "summary": self._schema_stock_picking_details, + "summary": self._schema_summary, "change_packaging": self._schema_select_packaging, "confirm_done": self._schema_stock_picking_details, } @@ -1184,11 +1187,17 @@ def _schema_stock_picking_details(self): "move_lines": { "type": "list", "schema": {"type": "dict", "schema": self.schemas().move_line()}, - } + }, } ) return {"picking": {"type": "dict", "schema": schema}} + @property + def _schema_summary(self): + return dict( + self._schema_stock_picking_details, all_processed={"type": "boolean"} + ) + @property def _schema_selection_list(self): return { From bd40d0179bb3fb7d570a874f2f319cdbf9719fa3 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 29 Apr 2020 16:39:03 +0200 Subject: [PATCH 182/940] backend: rename location dest return key We should use data component to output location data in all processes. So far it has been used only in checkout. This change makes the schema similar while we are not using it yet --- shopfloor/services/cluster_picking.py | 12 ++++---- shopfloor/services/single_pack_putaway.py | 4 +-- shopfloor/services/single_pack_transfer.py | 4 +-- shopfloor/tests/test_cluster_picking_base.py | 2 +- shopfloor/tests/test_cluster_picking_scan.py | 2 +- .../tests/test_cluster_picking_select.py | 2 +- .../tests/test_cluster_picking_unload.py | 28 +++++++++---------- shopfloor/tests/test_single_pack_putaway.py | 6 ++-- shopfloor/tests/test_single_pack_transfer.py | 6 ++-- 9 files changed, 33 insertions(+), 33 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 41da540a84..0ec6fd0ee4 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -297,7 +297,7 @@ def _data_move_line(self, line): if lot else None, "location_src": {"id": line.location_id.id, "name": line.location_id.name}, - "location_dst": { + "location_dest": { "id": line.location_dest_id.id, "name": line.location_dest_id.name, }, @@ -637,7 +637,7 @@ def _data_for_unload_all(self, batch): return { "id": batch.id, "name": batch.name, - "location_dst": { + "location_dest": { "id": first_line.location_dest_id.id, "name": first_line.location_dest_id.name, }, @@ -652,7 +652,7 @@ def _data_for_unload_single(self, batch, package): "id": batch.id, "name": batch.name, "package": {"id": package.id, "name": package.name}, - "location_dst": { + "location_dest": { "id": line.location_dest_id.id, "name": line.location_dest_id.name, }, @@ -1435,7 +1435,7 @@ def _schema_for_single_line_details(self): "name": {"type": "string", "nullable": False, "required": True}, }, }, - "location_dst": { + "location_dest": { "type": "dict", "schema": { "id": {"required": True, "type": "integer"}, @@ -1468,7 +1468,7 @@ def _schema_for_unload_all(self): # stock.batch.picking "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, - "location_dst": { + "location_dest": { "type": "dict", "schema": { "id": {"required": True, "type": "integer"}, @@ -1490,7 +1490,7 @@ def _schema_for_unload_single(self): "name": {"type": "string", "nullable": False, "required": True}, }, }, - "location_dst": { + "location_dest": { "type": "dict", "schema": { "id": {"required": True, "type": "integer"}, diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 9c2857d348..73f4e5fdab 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -59,7 +59,7 @@ def _data_after_package_scanned(self, move_line, pack): "id": move_line.package_level_id.id, "name": pack.name, "location_src": {"id": pack.location_id.id, "name": pack.location_id.name}, - "location_dst": { + "location_dest": { "id": move_line.location_dest_id.id, "name": move_line.location_dest_id.name, }, @@ -328,7 +328,7 @@ def _schema_for_location(self): "name": {"type": "string", "nullable": False, "required": True}, }, }, - "location_dst": { + "location_dest": { "type": "dict", "schema": { "id": {"required": True, "type": "integer"}, diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index edef7da3b1..d2db50456f 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -57,7 +57,7 @@ def _data_after_package_scanned(self, move_line, pack): "id": move_line.package_level_id.id, "name": pack.name, "location_src": {"id": pack.location_id.id, "name": pack.location_id.name}, - "location_dst": { + "location_dest": { "id": move_line.location_dest_id.id, "name": move_line.location_dest_id.name, }, @@ -299,7 +299,7 @@ def _schema_for_location(self): "name": {"type": "string", "nullable": False, "required": True}, }, }, - "location_dst": { + "location_dest": { "type": "dict", "schema": { "id": {"required": True, "type": "integer"}, diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py index 62c1aa6550..7463a7e0b2 100644 --- a/shopfloor/tests/test_cluster_picking_base.py +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -46,7 +46,7 @@ def _line_data(self, move_line, qty=None): "id": move_line.id, "quantity": qty or move_line.product_uom_qty, "postponed": move_line.shopfloor_postponed, - "location_dst": { + "location_dest": { "id": move_line.location_dest_id.id, "name": move_line.location_dest_id.name, }, diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index 5192134661..181b9e0e15 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -350,7 +350,7 @@ def test_scan_destination_pack_ok_last_line(self): data={ "id": self.batch.id, "name": self.batch.name, - "location_dst": { + "location_dest": { "id": self.packing_location.id, "name": self.packing_location.name, }, diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py index 5bc64cd816..dfc89c6c2a 100644 --- a/shopfloor/tests/test_cluster_picking_select.py +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -367,7 +367,7 @@ def test_confirm_start_ok(self): "id": first_move_line.id, "quantity": 1.0, "postponed": False, - "location_dst": { + "location_dest": { "id": first_move_line.location_dest_id.id, "name": first_move_line.location_dest_id.name, }, diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index 5668308fe2..34de5253ad 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -63,7 +63,7 @@ def test_prepare_unload_all_same_dest(self): data={ "id": self.batch.id, "name": self.batch.name, - "location_dst": { + "location_dest": { "id": self.packing_location.id, "name": self.packing_location.name, }, @@ -89,7 +89,7 @@ def test_prepare_unload_different_dest(self): "id": self.batch.id, "name": self.batch.name, "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dst": { + "location_dest": { "id": first_line.location_dest_id.id, "name": first_line.location_dest_id.name, }, @@ -249,7 +249,7 @@ def test_set_destination_all_but_different_dest(self): "id": self.batch.id, "name": self.batch.name, "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dst": { + "location_dest": { "id": move_lines[0].location_dest_id.id, "name": move_lines[0].location_dest_id.name, }, @@ -272,7 +272,7 @@ def test_set_destination_all_error_location_not_found(self): data={ "id": self.batch.id, "name": self.batch.name, - "location_dst": { + "location_dest": { "id": move_lines[0].location_dest_id.id, "name": move_lines[0].location_dest_id.name, }, @@ -306,7 +306,7 @@ def test_set_destination_all_error_location_invalid(self): data={ "id": self.batch.id, "name": self.batch.name, - "location_dst": { + "location_dest": { "id": move_lines[0].location_dest_id.id, "name": move_lines[0].location_dest_id.name, }, @@ -333,7 +333,7 @@ def test_set_destination_all_need_confirmation(self): data={ "id": self.batch.id, "name": self.batch.name, - "location_dst": { + "location_dest": { "id": move_lines[0].location_dest_id.id, "name": move_lines[0].location_dest_id.name, }, @@ -406,7 +406,7 @@ def test_unload_split_ok(self): "id": self.batch.id, "name": self.batch.name, "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dst": { + "location_dest": { "id": move_lines[0].location_dest_id.id, "name": move_lines[0].location_dest_id.name, }, @@ -450,7 +450,7 @@ def test_unload_scan_pack_ok(self): "id": self.batch.id, "name": self.batch.name, "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dst": { + "location_dest": { "id": self.move_lines[0].location_dest_id.id, "name": self.move_lines[0].location_dest_id.name, }, @@ -474,7 +474,7 @@ def test_unload_scan_pack_wrong_barcode(self): "id": self.batch.id, "name": self.batch.name, "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dst": { + "location_dest": { "id": self.move_lines[0].location_dest_id.id, "name": self.move_lines[0].location_dest_id.name, }, @@ -567,7 +567,7 @@ def test_unload_scan_destination_ok(self): "name": self.batch.name, # the line of bin1 is unloaded, next one will be bin2 "package": {"id": self.bin2.id, "name": self.bin2.name}, - "location_dst": { + "location_dest": { "id": self.bin2_lines[0].location_dest_id.id, "name": self.bin2_lines[0].location_dest_id.name, }, @@ -637,7 +637,7 @@ def test_unload_scan_destination_one_line_of_picking_only(self): "name": self.batch.name, # the line of bin2 is unloaded, next one will be bin3 "package": {"id": bin3.id, "name": bin3.name}, - "location_dst": { + "location_dest": { "id": bin3_line.location_dest_id.id, "name": bin3_line.location_dest_id.name, }, @@ -708,7 +708,7 @@ def test_unload_scan_destination_error_location_not_found(self): "id": self.batch.id, "name": self.batch.name, "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dst": { + "location_dest": { "id": self.bin1_lines[0].location_dest_id.id, "name": self.bin1_lines[0].location_dest_id.name, }, @@ -740,7 +740,7 @@ def test_unload_scan_destination_error_location_invalid(self): "id": self.batch.id, "name": self.batch.name, "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dst": { + "location_dest": { "id": self.bin1_lines[0].location_dest_id.id, "name": self.bin1_lines[0].location_dest_id.name, }, @@ -765,7 +765,7 @@ def test_unload_scan_destination_need_confirmation(self): "id": self.batch.id, "name": self.batch.name, "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dst": { + "location_dest": { "id": self.bin1_lines[0].location_dest_id.id, "name": self.bin1_lines[0].location_dest_id.name, }, diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index cada111056..e943dfe953 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -87,7 +87,7 @@ def test_start(self): "id": self.dispatch_location.id, "name": self.dispatch_location.name, }, - "location_dst": {"id": self.shelf1.id, "name": self.shelf1.name}, + "location_dest": {"id": self.shelf1.id, "name": self.shelf1.name}, "name": package_level.package_id.name, "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, "product": {"id": move.product_id.id, "name": move.product_id.name}, @@ -222,7 +222,7 @@ def test_start_move_already_exist(self): "id": self.dispatch_location.id, "name": self.dispatch_location.name, }, - "location_dst": {"id": self.shelf1.id, "name": self.shelf1.name}, + "location_dest": {"id": self.shelf1.id, "name": self.shelf1.name}, "name": package_level.package_id.name, "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, "product": {"id": self.product_a.id, "name": self.product_a.name}, @@ -423,7 +423,7 @@ def test_validate_location_to_confirm(self): "id": self.dispatch_location.id, "name": self.dispatch_location.name, }, - "location_dst": {"id": self.shelf1.id, "name": self.shelf1.name}, + "location_dest": {"id": self.shelf1.id, "name": self.shelf1.name}, "name": package_level.package_id.name, "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, "product": {"id": move.product_id.id, "name": move.product_id.name}, diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index c5c49c4261..5a91659502 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -97,7 +97,7 @@ def test_start(self): "id": self.ANY, "name": package_level.package_id.name, "location_src": {"id": self.shelf1.id, "name": self.shelf1.name}, - "location_dst": {"id": self.shelf2.id, "name": self.shelf2.name}, + "location_dest": {"id": self.shelf2.id, "name": self.shelf2.name}, "picking": {"id": self.picking.id, "name": self.picking.name}, "product": {"id": self.product_a.id, "name": self.product_a.name}, }, @@ -320,7 +320,7 @@ def test_start_already_started(self): "id": self.ANY, "name": package_level.package_id.name, "location_src": {"id": self.shelf1.id, "name": self.shelf1.name}, - "location_dst": {"id": self.shelf2.id, "name": self.shelf2.name}, + "location_dest": {"id": self.shelf2.id, "name": self.shelf2.name}, "picking": {"id": self.picking.id, "name": self.picking.name}, "product": {"id": self.product_a.id, "name": self.product_a.name}, }, @@ -544,7 +544,7 @@ def test_validate_location_to_confirm(self): "id": self.ANY, "name": package_level.package_id.name, "location_src": {"id": self.shelf1.id, "name": self.shelf1.name}, - "location_dst": {"id": self.shelf2.id, "name": self.shelf2.name}, + "location_dest": {"id": self.shelf2.id, "name": self.shelf2.name}, "picking": {"id": self.picking.id, "name": self.picking.name}, "product": {"id": self.product_a.id, "name": self.product_a.name}, }, From 9d749264d8200a8417451aaed313dfb8a5f281fd Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 30 Apr 2020 15:46:18 +0200 Subject: [PATCH 183/940] backend: load menu by profile --- shopfloor/controllers/main.py | 62 +++++++++++++++++++++++------------ shopfloor/services/app.py | 28 +++++++++++----- shopfloor/services/menu.py | 22 ++++++++----- shopfloor/tests/test_app.py | 56 ++++++++++++++++++++++++++----- 4 files changed, 122 insertions(+), 46 deletions(-) diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py index 87ce6ef967..c5330310e9 100644 --- a/shopfloor/controllers/main.py +++ b/shopfloor/controllers/main.py @@ -4,12 +4,31 @@ from odoo.addons.base_rest.controllers import main +MENU_ID_HEADER = "HTTP_SERVICE_CTX_MENU_ID" +# (name, model, dest_key) +MENU_HEADER_RULE = (MENU_ID_HEADER, "shopfloor.menu", "menu") + +PROFILE_ID_HEADER = "HTTP_SERVICE_CTX_PROFILE_ID" +PROFILE_HEADER_RULE = (PROFILE_ID_HEADER, "shopfloor.profile", "profile") + class ShopfloorController(main.RestController): _root_path = "/shopfloor/" _collection_name = "shopfloor.service" _default_auth = "api_key" - _non_process_services = ("app", "menu", "profile") + _service_headers_rules = { + # no special header required for config + "app/user_config": (), + # profile header is required to get menu items + # fmt: off + # NOTE: turn off formatting here is mandatory + # otherwise black removes the space and flake8 w/ complain the comma + # before parenthesis which is required to make this a tuple! + "app/menu": (PROFILE_HEADER_RULE, ), + # profile + menu is required to call processes + "process": (PROFILE_HEADER_RULE, MENU_HEADER_RULE, ), + # fmt: on + } def _get_component_context(self): """ @@ -19,30 +38,31 @@ def _get_component_context(self): Components """ res = super(ShopfloorController, self)._get_component_context() - headers = request.httprequest.environ - res["menu"] = None res["profile"] = None - if self._is_process_enpoint(request.httprequest.path): - res.update(self._get_process_context(headers, request.env)) + res.update(self._get_process_context(request)) return res - def _is_process_enpoint(self, request_path): + # TODO: add tests + def _get_process_context(self, request): + ctx = {} + env = request.env + headers = request.httprequest.environ # '/shopfloor/app/user_config' -> app/config - service_path = request_path.split(self._root_path)[-1] - return not service_path.startswith(self._non_process_services) + service_path = request.httprequest.path.split(self._root_path)[-1] - def _get_process_context(self, headers, env): - ctx = {} - try: - menu_id = int(headers.get("HTTP_SERVICE_CTX_MENU_ID")) - except (TypeError, ValueError): - raise BadRequest("HTTP_SERVICE_CTX_MENU_ID must be set with an integer") - ctx["menu"] = env["shopfloor.menu"].browse(menu_id) - - try: - profile_id = int(headers.get("HTTP_SERVICE_CTX_PROFILE_ID")) - except (TypeError, ValueError): - raise BadRequest("HTTP_SERVICE_CTX_PROFILE_ID must be set with an integer") - ctx["profile"] = env["shopfloor.profile"].browse(profile_id) + # default to process rule + default = self._service_headers_rules["process"] + headers_map = self._service_headers_rules.get(service_path, default) + for header_name, model, dest_key in headers_map: + try: + rec_id = int(headers.get(header_name)) + except (TypeError, ValueError): + raise BadRequest("{} must be set with an integer".format(header_name)) + rec = env[model].browse(rec_id).exists() + if not rec: + raise BadRequest( + "Record {} with ID = {} not found".format(model, rec_id) + ) + ctx[dest_key] = rec return ctx diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py index 997c33eda4..0a87f0b6e3 100644 --- a/shopfloor/services/app.py +++ b/shopfloor/services/app.py @@ -10,11 +10,14 @@ class ShopfloorApp(Component): _description = __doc__ def user_config(self): - menu_comp = self.component("menu") profiles_comp = self.component("profile") - menus = menu_comp._to_json(menu_comp._search()) profiles = profiles_comp._to_json(profiles_comp._search()) - return self._response(data={"menus": menus, "profiles": profiles}) + return self._response(data={"profiles": profiles}) + + def menu(self): + menu_comp = self.component("menu") + menus = menu_comp._to_json(menu_comp._search()) + return self._response(data={"menus": menus}) class ShopfloorAppValidator(Component): @@ -27,6 +30,9 @@ class ShopfloorAppValidator(Component): def user_config(self): return {} + def menu(self): + return {} + class ShopfloorAppValidatorResponse(Component): """Validators for the Application endpoints responses""" @@ -36,24 +42,30 @@ class ShopfloorAppValidatorResponse(Component): _usage = "app.validator.response" def user_config(self): - menu_return_validator = self.component("menu.validator.response") profile_return_validator = self.component("profile.validator.response") return self._response_schema( { - "menus": { + "profiles": { "type": "list", "required": True, "schema": { "type": "dict", - "schema": menu_return_validator._record_schema, + "schema": profile_return_validator._record_schema, }, }, - "profiles": { + } + ) + + def menu(self): + menu_return_validator = self.component("menu.validator.response") + return self._response_schema( + { + "menus": { "type": "list", "required": True, "schema": { "type": "dict", - "schema": profile_return_validator._record_schema, + "schema": menu_return_validator._record_schema, }, }, } diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index 140c3bfde3..d516f9e029 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -22,16 +22,20 @@ class ShopfloorMenu(Component): def _get_base_search_domain(self): base_domain = super()._get_base_search_domain() user = self.env.user - return expression.AND( - [ - base_domain, - [ - "|", - ("operation_group_ids", "=", False), - ("operation_group_ids.user_ids", "=", user.id), - ], + profile = getattr(self.work, "profile", None) + op_group_domain = [ + "|", + ("operation_group_ids", "=", False), + ("operation_group_ids.user_ids", "=", user.id), + ] + if profile: + # TODO: this probably should be the default only one way + # What to do w/ profiles linked to specific user? + # This data model is a bit messy :/ + op_group_domain = [ + ("operation_group_ids", "in", profile.operation_group_ids.ids), ] - ) + return expression.AND([base_domain, op_group_domain]) def _search(self, name_fragment=None): domain = self._get_base_search_domain() diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py index f64bb0f91e..4c6039ce1b 100644 --- a/shopfloor/tests/test_app.py +++ b/shopfloor/tests/test_app.py @@ -11,8 +11,29 @@ def test_user_config(self): """Request /app/user_config""" # Simulate the client asking the configuration response = self.service.dispatch("user_config") - menus = self.env["shopfloor.menu"].search([]) profiles = self.env["shopfloor.profile"].search([]) + self.assert_response( + response, + data={ + "profiles": [ + { + "id": profile.id, + "name": profile.name, + "warehouse": { + "id": profile.warehouse_id.id, + "name": profile.warehouse_id.name, + }, + } + for profile in profiles + ], + }, + ) + + def test_menu_no_profile(self): + """Request /app/menu""" + # Simulate the client asking the menu + response = self.service.dispatch("menu") + menus = self.env["shopfloor.menu"].search([]) self.assert_response( response, data={ @@ -27,16 +48,35 @@ def test_user_config(self): } for menu in menus ], - "profiles": [ + }, + ) + + def test_menu_by_profile(self): + """Request /app/menu w/ a specific profile""" + # Simulate the client asking the menu + op_type = self.env.ref("shopfloor.shopfloor_operation_group_highbay_demo") + menus = self.env["shopfloor.menu"].search([]) + menu = menus[0] + menu.operation_group_ids = op_type + profile = self.env.ref("shopfloor.shopfloor_profile_hb_truck_demo") + profile.operation_group_ids = op_type + + with self.work_on_services(profile=profile) as work: + service = work.component(usage="app") + + response = service.dispatch("menu") + self.assert_response( + response, + data={ + "menus": [ { - "id": profile.id, - "name": profile.name, - "warehouse": { - "id": profile.warehouse_id.id, - "name": profile.warehouse_id.name, + "id": menu.id, + "name": menu.name, + "process": { + "id": menu.process_id.id, + "code": menu.process_id.code, }, } - for profile in profiles ], }, ) From e4681b481866ac55482ba490a093787865a9bbd8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 4 May 2020 13:13:06 +0200 Subject: [PATCH 184/940] backend: fix checkout optional required args --- shopfloor/services/checkout.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 236356bd5a..1604902196 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -1133,13 +1133,14 @@ def cancel_line(self): "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, "package_id": { "coerce": to_int, - "required": True, + "required": False, "type": "integer", + # excludes does not set the other as not required??? :/ "excludes": "line_id", }, "line_id": { "coerce": to_int, - "required": True, + "required": False, "type": "integer", "excludes": "package_id", }, From b855833a277dac909915174d1689f0b89fc42d27 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 4 May 2020 16:01:22 +0200 Subject: [PATCH 185/940] backend: include more details in menu item data --- shopfloor/services/menu.py | 60 +++++++++++++++++++++++++++++-------- shopfloor/tests/test_app.py | 13 ++++++++ 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index d516f9e029..839d5634f3 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -52,10 +52,21 @@ def search(self, name_fragment=None): ) def _convert_one_record(self, record): + # TODO: use `jsonify` return { "id": record.id, "name": record.name, - "process": {"id": record.process_id.id, "code": record.process_code}, + "process": { + "id": record.process_id.id, + "code": record.process_code, + "picking_type": { + "id": record.process_id.picking_type_id.id, + "name": record.process_id.picking_type_id.name, + }, + }, + "op_groups": [ + {"id": g.id, "name": g.name} for g in record.operation_group_ids + ], } @@ -79,29 +90,52 @@ class ShopfloorMenuValidatorResponse(Component): _name = "shopfloor.menu.validator.response" _usage = "menu.validator.response" + def return_search(self): + return self._response_schema( + { + "size": {"coerce": to_int, "required": True, "type": "integer"}, + "records": { + "type": "list", + "required": True, + "schema": {"type": "dict", "schema": self._record_schema}, + }, + } + ) + @property def _record_schema(self): return { "id": {"coerce": to_int, "required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, + "op_groups": { + "type": "list", + "required": True, + "schema": {"type": "dict", "schema": self._op_group_schema}, + }, "process": { "type": "dict", "required": True, "schema": { - "code": {"type": "string", "nullable": False, "required": True}, "id": {"coerce": to_int, "required": True, "type": "integer"}, + "code": {"type": "string", "nullable": False, "required": True}, + "picking_type": { + "type": "dict", + "schema": self._picking_type_schema, + }, }, }, } - def return_search(self): - return self._response_schema( - { - "size": {"coerce": to_int, "required": True, "type": "integer"}, - "records": { - "type": "list", - "required": True, - "schema": {"type": "dict", "schema": self._record_schema}, - }, - } - ) + @property + def _op_group_schema(self): + return { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + } + + @property + def _picking_type_schema(self): + return { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + } diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py index 4c6039ce1b..c3cab5356a 100644 --- a/shopfloor/tests/test_app.py +++ b/shopfloor/tests/test_app.py @@ -41,9 +41,17 @@ def test_menu_no_profile(self): { "id": menu.id, "name": menu.name, + "op_groups": [ + {"id": x.id, "name": x.name} + for x in menu.operation_group_ids + ], "process": { "id": menu.process_id.id, "code": menu.process_id.code, + "picking_type": { + "id": menu.process_id.picking_type_id.id, + "name": menu.process_id.picking_type_id.name, + }, }, } for menu in menus @@ -72,9 +80,14 @@ def test_menu_by_profile(self): { "id": menu.id, "name": menu.name, + "op_groups": [{"id": op_type.id, "name": op_type.name}], "process": { "id": menu.process_id.id, "code": menu.process_id.code, + "picking_type": { + "id": menu.process_id.picking_type_id.id, + "name": menu.process_id.picking_type_id.name, + }, }, } ], From b2315c7dcf3cc7b2b0f409856b2301d664b8d292 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 4 May 2020 16:14:53 +0200 Subject: [PATCH 186/940] backend: add handy debug fields to move line tree --- shopfloor/__manifest__.py | 1 + shopfloor/views/stock_move_line.xml | 34 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 shopfloor/views/stock_move_line.xml diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index edba0364fa..2342b041d1 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -30,6 +30,7 @@ "views/shopfloor_process.xml", "views/stock_picking_type.xml", "views/stock_location.xml", + "views/stock_move_line.xml", "views/shopfloor_profile_views.xml", "views/menus.xml", ], diff --git a/shopfloor/views/stock_move_line.xml b/shopfloor/views/stock_move_line.xml new file mode 100644 index 0000000000..3945954cdb --- /dev/null +++ b/shopfloor/views/stock_move_line.xml @@ -0,0 +1,34 @@ + + + + shopfloor stock_move_line_detailed_operation_tree + stock.move.line + + + + + + + + + + + From 261ba8692980d766af7c7398d259311290565d34 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 4 May 2020 09:55:12 +0200 Subject: [PATCH 187/940] backend: add TODO --- shopfloor/models/stock_quant_package.py | 7 +++++++ shopfloor/services/service.py | 3 +++ 2 files changed, 10 insertions(+) diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py index d41c531f2f..c835935fb3 100644 --- a/shopfloor/models/stock_quant_package.py +++ b/shopfloor/models/stock_quant_package.py @@ -10,3 +10,10 @@ class StockQuantPackage(models.Model): readonly=True, help="Technical field. Move lines for which destination is this package.", ) + + # TODO: we should refactor this like + + # source_planned_move_line_ids + # destination_planned_move_line_ids + + # filter out done/cancel lines diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 6c0861f9f6..46e2d63bde 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -28,6 +28,9 @@ def picking_type(self): """ Get the current picking type based on the menu and the warehouse of the profile. """ + # TODO: this validation should be done in the service + # and should return a proper error for the client + # or the client should handle exceptions properly. picking_type = self.work.menu.process_id.picking_type_id if picking_type.warehouse_id != self.work.profile.warehouse_id: raise exceptions.UserError( From caef06e92badf1383a2c00241815635bd9cf7a54 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 5 May 2020 09:40:16 +0200 Subject: [PATCH 188/940] backend: unify batch picking weight calc --- shopfloor/models/stock_picking_batch.py | 12 ++++++++++++ shopfloor/services/cluster_picking.py | 4 +--- shopfloor/services/picking_batch.py | 2 ++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/shopfloor/models/stock_picking_batch.py b/shopfloor/models/stock_picking_batch.py index 4101de4da5..c3f3173b03 100644 --- a/shopfloor/models/stock_picking_batch.py +++ b/shopfloor/models/stock_picking_batch.py @@ -10,3 +10,15 @@ class StockPickingBatch(models.Model): help="Technical field. Indicates if a batch is destination is" " asked once for all lines or for every line.", ) + + def total_weight(self): + return self.calc_weight(self.picking_ids) + + def picking_weight(self, picking): + return self.calc_weight(picking) + + def calc_weight(self, pickings): + weight = 0.0 + for move_line in pickings.mapped("move_line_ids"): + weight += move_line.product_qty * move_line.product_id.weight + return weight diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 0ec6fd0ee4..5de76b4244 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -143,9 +143,7 @@ def _response_for_no_batch_found(self): def _response_for_confirm_start(self, batch): pickings = [] for picking in batch.picking_ids: - p_weight = 0.0 - for move_line in picking.mapped("move_line_ids"): - p_weight += move_line.product_qty * move_line.product_id.weight + p_weight = batch.picking_weight(picking) p_values = { "id": picking.id, "name": picking.name, diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py index 2838373dc3..de5086ddf0 100644 --- a/shopfloor/services/picking_batch.py +++ b/shopfloor/services/picking_batch.py @@ -77,6 +77,7 @@ def _convert_one_record(self, record): "name": record.name, "picking_count": len(assigned_pickings), "move_line_count": len(assigned_pickings.mapped("move_line_ids")), + "weight": record.total_weight(), } @@ -119,4 +120,5 @@ def _record_schema(self): "name": {"type": "string", "nullable": False, "required": True}, "picking_count": {"required": True, "type": "integer"}, "move_line_count": {"required": True, "type": "integer"}, + "weight": {"required": True, "nullable": True, "type": "float"}, } From 16589444a5e61cf67f9d0a599dfeb83c0b2ea07d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 5 May 2020 17:51:52 +0200 Subject: [PATCH 189/940] backend: fix tests --- shopfloor/actions/search.py | 13 ++++++++++++ shopfloor/services/checkout.py | 12 +---------- shopfloor/tests/test_checkout_cancel_line.py | 20 +++++++++++++++---- .../tests/test_checkout_change_packaging.py | 20 +++++++++++++++---- shopfloor/tests/test_checkout_done.py | 4 ++-- shopfloor/tests/test_checkout_scan.py | 12 +++++------ shopfloor/tests/test_checkout_scan_line.py | 5 ++++- .../test_checkout_scan_package_action.py | 5 ++++- shopfloor/tests/test_checkout_select_line.py | 5 ++++- shopfloor/tests/test_checkout_summary.py | 5 ++++- .../tests/test_cluster_picking_select.py | 2 ++ shopfloor/tests/test_menu.py | 5 +++++ shopfloor/tests/test_picking_batch.py | 3 +++ 13 files changed, 79 insertions(+), 32 deletions(-) diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py index a547abbbc6..2bba15131d 100644 --- a/shopfloor/actions/search.py +++ b/shopfloor/actions/search.py @@ -37,3 +37,16 @@ def generic_packaging_from_scan(self, barcode): return self.env["product.packaging"].search( [("barcode", "=", barcode), ("product_id", "=", False)] ) + + def stock_picking_from_package(self, package, picking_type): + return ( + self.env["stock.move.line"] + .search( + [ + ("state", "not in", ("cancel", "done")), + ("package_id", "=", package.id), + ("picking_id.picking_type_id", "=", picking_type.id), + ] + ) + .mapped("picking_id") + ) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 1604902196..3d04bc72b8 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -85,17 +85,7 @@ def scan_document(self, barcode): if not picking: package = search.package_from_scan(barcode) if package: - pickings = ( - self.env["stock.move.line"] - .search( - [ - ("state", "not in", ("cancel", "done")), - ("package_id", "=", package.id), - ("picking_id.picking_type_id", "=", self.picking_type.id), - ] - ) - .mapped("picking_id") - ) + pickings = search.stock_picking_from_package(package, self.picking_type) if len(pickings) == 1: picking = pickings return self._select_picking(picking, "select_document") diff --git a/shopfloor/tests/test_checkout_cancel_line.py b/shopfloor/tests/test_checkout_cancel_line.py index 76f5cda812..cbe9aee027 100644 --- a/shopfloor/tests/test_checkout_cancel_line.py +++ b/shopfloor/tests/test_checkout_cancel_line.py @@ -85,7 +85,10 @@ def test_cancel_package_ok(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(picking, done=True)}, + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": False, + }, ) def test_cancel_line_ok(self): @@ -107,7 +110,10 @@ def test_cancel_line_ok(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(picking, done=True)}, + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": False, + }, ) def test_cancel_line_error_package_not_found(self): @@ -118,7 +124,10 @@ def test_cancel_line_error_package_not_found(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(self.picking, done=True)}, + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": False, + }, message={ "message_type": "error", "message": "The record you were working on does not exist anymore.", @@ -133,7 +142,10 @@ def test_cancel_line_error_line_not_found(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(self.picking, done=True)}, + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": False, + }, message={ "message_type": "error", "message": "The record you were working on does not exist anymore.", diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py index 3d06fc48ad..ea1c4c6e74 100644 --- a/shopfloor/tests/test_checkout_change_packaging.py +++ b/shopfloor/tests/test_checkout_change_packaging.py @@ -69,7 +69,10 @@ def test_list_packaging_error_package_not_found(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(self.picking, done=True)}, + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": False, + }, message={ "message_type": "error", "message": "The record you were working on does not exist anymore.", @@ -91,7 +94,10 @@ def test_set_packaging_ok(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(self.picking, done=True)}, + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": False, + }, message={ "message_type": "success", "message": "Packaging changed on package {}".format(self.package.name), @@ -110,7 +116,10 @@ def test_set_packaging_error_package_not_found(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(self.picking, done=True)}, + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": False, + }, message={ "message_type": "error", "message": "The record you were working on does not exist anymore.", @@ -129,7 +138,10 @@ def test_set_packaging_error_packaging_not_found(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(self.picking, done=True)}, + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": False, + }, message={ "message_type": "error", "message": "The record you were working on does not exist anymore.", diff --git a/shopfloor/tests/test_checkout_done.py b/shopfloor/tests/test_checkout_done.py index f47db4fd6c..2ddb794c7c 100644 --- a/shopfloor/tests/test_checkout_done.py +++ b/shopfloor/tests/test_checkout_done.py @@ -48,8 +48,8 @@ def test_done_partial(self): data={"picking": self._stock_picking_data(self.picking, done=True)}, message={ "message_type": "warning", - "message": "Not all lines have been processed, do" - " you want to confirm partial operation?", + "message": "Not all lines have been processed with full quantity. " + "Do you confirm partial operation?", }, ) diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py index 43ba2cc6dd..4972a278cd 100644 --- a/shopfloor/tests/test_checkout_scan.py +++ b/shopfloor/tests/test_checkout_scan.py @@ -80,7 +80,7 @@ def test_scan_document_error_location_not_child_of_type(self): message={"message_type": "error", "message": "Location not allowed here."}, ) - def _test_scan_document_error_different_picking_type(self, barcode_func): + def _test_scan_document_error_different_picking_type(self, barcode_func, msg): picking = self._create_picking(picking_type=self.wh.pick_type_id) self._fill_stock_for_moves(picking.move_lines, in_package=True) picking.action_assign() @@ -89,20 +89,18 @@ def _test_scan_document_error_different_picking_type(self, barcode_func): self.assert_response( response, next_state="select_document", - message={ - "message_type": "error", - "message": "You cannot move this using this menu.", - }, + message={"message_type": "error", "message": msg}, ) def test_scan_document_error_different_picking_type_picking(self): self._test_scan_document_error_different_picking_type( - lambda picking: picking.name + lambda picking: picking.name, msg="You cannot move this using this menu." ) def test_scan_document_error_different_picking_type_package(self): self._test_scan_document_error_different_picking_type( - lambda picking: picking.move_line_ids.package_id.name + lambda picking: picking.move_line_ids.package_id.name, + msg="Barcode not found", ) def test_scan_document_error_location_several_pickings(self): diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py index 8163bcdfcd..720bfb9973 100644 --- a/shopfloor/tests/test_checkout_scan_line.py +++ b/shopfloor/tests/test_checkout_scan_line.py @@ -295,5 +295,8 @@ def test_scan_line_all_lines_done(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(picking, done=True)}, + data={ + "picking": self._stock_picking_data(picking, done=True), + "all_processed": True, + }, ) diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py index 0c293e308f..b9d5b870b1 100644 --- a/shopfloor/tests/test_checkout_scan_package_action.py +++ b/shopfloor/tests/test_checkout_scan_package_action.py @@ -259,7 +259,10 @@ def test_scan_package_action_scan_package_use_existing_package_ok(self): response, # all the lines are packed, so we expect to go the summary screen next_state="summary", - data={"picking": self._stock_picking_data(picking, done=True)}, + data={ + "picking": self._stock_picking_data(picking, done=True), + "all_processed": True, + }, message={ "message_type": "info", "message": "Product(s) packed in {}".format(package.name), diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py index 800daf56de..c327ab989a 100644 --- a/shopfloor/tests/test_checkout_select_line.py +++ b/shopfloor/tests/test_checkout_select_line.py @@ -108,5 +108,8 @@ def test_select_line_all_lines_done(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(self.picking, done=True)}, + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": True, + }, ) diff --git a/shopfloor/tests/test_checkout_summary.py b/shopfloor/tests/test_checkout_summary.py index 9b0149335d..2d765930a8 100644 --- a/shopfloor/tests/test_checkout_summary.py +++ b/shopfloor/tests/test_checkout_summary.py @@ -16,5 +16,8 @@ def test_summary_ok(self): self.assert_response( response, next_state="summary", - data={"picking": self._stock_picking_data(picking, done=True)}, + data={ + "picking": self._stock_picking_data(picking, done=True), + "all_processed": True, + }, ) diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py index dfc89c6c2a..b844873408 100644 --- a/shopfloor/tests/test_cluster_picking_select.py +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -191,6 +191,7 @@ def test_list_batch(self): "name": self.batch1.name, "picking_count": 1, "move_line_count": 1, + "weight": 6.0, }, # batch 2 is excluded because assigned to someone else { @@ -198,6 +199,7 @@ def test_list_batch(self): "name": self.batch3.name, "picking_count": 1, "move_line_count": 1, + "weight": 6.0, }, # batch 4 is excluded because not all of its pickings are # assigned diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py index a1a86ca74d..e7e93cc59a 100644 --- a/shopfloor/tests/test_menu.py +++ b/shopfloor/tests/test_menu.py @@ -20,9 +20,14 @@ def test_menu_search(self): { "id": menu.id, "name": menu.name, + "op_groups": [], "process": { "id": menu.process_id.id, "code": menu.process_id.code, + "picking_type": { + "id": menu.process_id.picking_type_id.id, + "name": menu.process_id.picking_type_id.name, + }, }, } for menu in menus diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py index bf76635a82..a68d6e9074 100644 --- a/shopfloor/tests/test_picking_batch.py +++ b/shopfloor/tests/test_picking_batch.py @@ -80,18 +80,21 @@ def test_search(self): "name": self.batch1.name, "picking_count": 1, "move_line_count": 1, + "weight": 0.0, }, { "id": self.batch2.id, "name": self.batch2.name, "picking_count": 1, "move_line_count": 1, + "weight": 0.0, }, { "id": self.batch3.id, "name": self.batch3.name, "picking_count": 1, "move_line_count": 1, + "weight": 0.0, }, ], }, From e8fb299efc1460be082ae224d806b7bebcfddf72 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 27 Apr 2020 11:16:44 +0200 Subject: [PATCH 190/940] Rework shopfloor datamodel This is a simplification of the model, which was hard to understand and configure. We had too many options to filter the visibility of menus / processes, which made things hard to debug. Some models were overlapping in term of concept and fonctionality. * Processes and menus are redundant, remove processes and keep only menus * Remove shopfloor groups, we can restrict the visibility of a menu using profiles. Later, only a manager should be able to change the profile on the devices. Additionally, we would still be able to use ir.rules if needed. * On the menus, we'll be able (still to implement in the services) to select several picking types. When a scenario has to know a single picking type, for instance to create a new move, we'll use a dedicated many2one for this purpose. --- shopfloor/__manifest__.py | 4 - shopfloor/demo/shopfloor_menu_demo.xml | 30 ++++++-- .../demo/shopfloor_operation_group_demo.xml | 9 --- shopfloor/demo/shopfloor_process_demo.xml | 27 ------- shopfloor/demo/shopfloor_profile_demo.xml | 2 - shopfloor/models/__init__.py | 3 - shopfloor/models/res_users.py | 10 --- shopfloor/models/shopfloor_menu.py | 23 +++++- shopfloor/models/shopfloor_operation_group.py | 12 --- shopfloor/models/shopfloor_process.py | 23 ------ shopfloor/models/shopfloor_profile.py | 21 +----- shopfloor/models/stock_move_line.py | 2 +- shopfloor/models/stock_picking_type.py | 7 +- shopfloor/security/ir.model.access.csv | 4 - shopfloor/services/checkout.py | 4 +- shopfloor/services/cluster_picking.py | 10 +-- shopfloor/services/menu.py | 73 +++++-------------- shopfloor/services/picking_batch.py | 2 +- shopfloor/services/profile.py | 32 +------- shopfloor/services/service.py | 20 +++-- shopfloor/services/single_pack_putaway.py | 2 +- shopfloor/tests/test_app.py | 52 ++++++------- shopfloor/tests/test_checkout_base.py | 3 +- shopfloor/tests/test_checkout_select_line.py | 10 +-- shopfloor/tests/test_cluster_picking_base.py | 3 +- shopfloor/tests/test_delivery_base.py | 3 +- shopfloor/tests/test_menu.py | 52 ++++++++++--- shopfloor/tests/test_openapi.py | 1 - shopfloor/tests/test_picking_batch.py | 3 +- shopfloor/tests/test_single_pack_putaway.py | 7 +- shopfloor/tests/test_single_pack_transfer.py | 3 +- shopfloor/views/menus.xml | 12 --- shopfloor/views/shopfloor_menu.xml | 16 +++- shopfloor/views/shopfloor_operation_group.xml | 47 ------------ shopfloor/views/shopfloor_process.xml | 50 ------------- shopfloor/views/shopfloor_profile_views.xml | 6 -- shopfloor/views/stock_picking_type.xml | 2 +- 37 files changed, 178 insertions(+), 412 deletions(-) delete mode 100644 shopfloor/demo/shopfloor_operation_group_demo.xml delete mode 100644 shopfloor/demo/shopfloor_process_demo.xml delete mode 100644 shopfloor/models/res_users.py delete mode 100644 shopfloor/models/shopfloor_operation_group.py delete mode 100644 shopfloor/models/shopfloor_process.py delete mode 100644 shopfloor/views/shopfloor_operation_group.xml delete mode 100644 shopfloor/views/shopfloor_process.xml diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 2342b041d1..bfb02438c8 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -25,9 +25,7 @@ ], "data": [ "security/ir.model.access.csv", - "views/shopfloor_operation_group.xml", "views/shopfloor_menu.xml", - "views/shopfloor_process.xml", "views/stock_picking_type.xml", "views/stock_location.xml", "views/stock_move_line.xml", @@ -37,9 +35,7 @@ "demo": [ "demo/auth_api_key_demo.xml", "demo/stock_picking_type_demo.xml", - "demo/shopfloor_process_demo.xml", "demo/shopfloor_menu_demo.xml", - "demo/shopfloor_operation_group_demo.xml", "demo/shopfloor_profile_demo.xml", ], } diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml index eaf4bb73e0..1276f19a98 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -2,26 +2,46 @@ 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 8d549c1648..0000000000 --- a/shopfloor/demo/shopfloor_operation_group_demo.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - HighBay - - - diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml deleted file mode 100644 index 0db0a727e4..0000000000 --- a/shopfloor/demo/shopfloor_process_demo.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - Put-Away Reach Truck - single_pack_putaway - - - - Single Pallet Transfer - single_pack_transfer - - - - Cluster Picking - cluster_picking - - - - Checkout - checkout - - - - Delivery - delivery - - - diff --git a/shopfloor/demo/shopfloor_profile_demo.xml b/shopfloor/demo/shopfloor_profile_demo.xml index 743890335c..0275b3e335 100644 --- a/shopfloor/demo/shopfloor_profile_demo.xml +++ b/shopfloor/demo/shopfloor_profile_demo.xml @@ -1,12 +1,10 @@ Highbay Truck - Shelf 1 - diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index e9f59c02e3..d86b15fec5 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -1,10 +1,7 @@ from . import shopfloor_menu -from . import shopfloor_operation_group -from . import shopfloor_process from . import stock_picking_type from . import shopfloor_profile from . import stock_location from . import stock_move_line from . import stock_picking_batch from . import stock_quant_package -from . import res_users diff --git a/shopfloor/models/res_users.py b/shopfloor/models/res_users.py deleted file mode 100644 index 8c136f725e..0000000000 --- a/shopfloor/models/res_users.py +++ /dev/null @@ -1,10 +0,0 @@ -from odoo import fields, models - - -class ResUsers(models.Model): - _inherit = "res.users" - - # in practice, it's a one2one - shopfloor_profile_ids = fields.One2many( - comodel_name="shopfloor.profile", inverse_name="user_id", readonly=True - ) diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 0f0a56e2bb..c5058c1f56 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -8,8 +8,23 @@ class ShopfloorMenu(models.Model): name = fields.Char(translate=True) sequence = fields.Integer() - operation_group_ids = fields.Many2many( - "shopfloor.operation.group", string="Groups", help="visible for these groups" + profile_ids = fields.Many2many( + "shopfloor.profile", string="Profiles", help="Visible for these profiles" ) - process_id = fields.Many2one("shopfloor.process", name="Process", required=True) - process_code = fields.Selection(related="process_id.code", readonly=True) + picking_type_ids = fields.Many2many( + comodel_name="stock.picking.type", string="Operation Types", + required=True, + ) + # TODO allow only one picking type when 'move creation' is allowed + + scenario = fields.Selection(selection="_selection_scenario", required=True) + + def _selection_scenario(self): + return [ + # these must match a REST service's '_usage' + ("single_pack_putaway", "Single Pack Put-away"), + ("single_pack_transfer", "Single Pack Transfer"), + ("cluster_picking", "Cluster Picking"), + ("checkout", "Checkout/Packing"), + ("delivery", "Delivery"), + ] diff --git a/shopfloor/models/shopfloor_operation_group.py b/shopfloor/models/shopfloor_operation_group.py deleted file mode 100644 index 0dc47fe272..0000000000 --- a/shopfloor/models/shopfloor_operation_group.py +++ /dev/null @@ -1,12 +0,0 @@ -from odoo import fields, models - - -class ShopfloorOperationGroup(models.Model): - _name = "shopfloor.operation.group" - _description = "Shopfloor operation group, governs which menu items are visible" - - name = fields.Char(required=True) - user_ids = fields.Many2many("res.users", string="Members") - menu_ids = fields.Many2many( - "shopfloor.menu", string="Menus", help="Can see these menus" - ) diff --git a/shopfloor/models/shopfloor_process.py b/shopfloor/models/shopfloor_process.py deleted file mode 100644 index 2eafef947c..0000000000 --- a/shopfloor/models/shopfloor_process.py +++ /dev/null @@ -1,23 +0,0 @@ -from odoo import fields, models - - -class ShopfloorProcess(models.Model): - _name = "shopfloor.process" - _description = "a process to be run from the scanners" - - name = fields.Char(required=True) - code = fields.Selection(selection="_selection_code", required=True) - picking_type_id = fields.Many2one( - comodel_name="stock.picking.type", string="Operation Type" - ) - menu_ids = fields.One2many(comodel_name="shopfloor.menu", inverse_name="process_id") - - def _selection_code(self): - return [ - # these must match a REST service's '_usage' - ("single_pack_putaway", "Single Pack Put-away"), - ("single_pack_transfer", "Single Pack Transfer"), - ("cluster_picking", "Cluster Picking"), - ("checkout", "Checkout"), - ("delivery", "Delivery"), - ] diff --git a/shopfloor/models/shopfloor_profile.py b/shopfloor/models/shopfloor_profile.py index f18d813b31..ec9e66c792 100644 --- a/shopfloor/models/shopfloor_profile.py +++ b/shopfloor/models/shopfloor_profile.py @@ -11,26 +11,9 @@ class ShopfloorProfile(models.Model): required=True, default=lambda self: self._default_warehouse_id(), ) - operation_group_ids = fields.Many2many( - "shopfloor.operation.group", - string="Shopfloor Operation Groups", - help="When unset, all users can use the profile. When set," - "only users belonging to at least one group can use the profile.", + menu_ids = fields.Many2many( + "shopfloor.menu", string="Menus", help="Menus visible for this profile" ) - user_id = fields.Many2one( - "res.users", - copy=False, - help="Optional user using the profile. When a profile has a" - "user assigned to it, the user is not allowed to use another profile.", - ) - - _sql_constraints = [ - ( - "user_id_uniq", - "unique(user_id)", - "A user can be assigned to only one profile.", - ) - ] @api.model def _default_warehouse_id(self): diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py index 0ee951644f..ce6d28630d 100644 --- a/shopfloor/models/stock_move_line.py +++ b/shopfloor/models/stock_move_line.py @@ -10,7 +10,7 @@ class StockMoveLine(models.Model): default=False, copy=False, help="Technical field. " - "Indicates if a the move has been postponed in a process.", + "Indicates if a the move has been postponed in a barcode scenario.", ) shopfloor_checkout_done = fields.Boolean(default=False) diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py index 80374d89b9..f974b0257a 100644 --- a/shopfloor/models/stock_picking_type.py +++ b/shopfloor/models/stock_picking_type.py @@ -4,9 +4,6 @@ class StockPickingType(models.Model): _inherit = "stock.picking.type" - process_ids = fields.One2many( - comodel_name="shopfloor.process", - inverse_name="picking_type_id", - string="Shopfloor Processes", - readonly=True, + shopfloor_menu_ids = fields.Many2many( + comodel_name="shopfloor.menu", string="Shopfloor Menus", readonly=True, ) diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv index 41e5dd07c5..6aca8c733c 100644 --- a/shopfloor/security/ir.model.access.csv +++ b/shopfloor/security/ir.model.access.csv @@ -1,9 +1,5 @@ "id","name","model_id/id","group_id/id","perm_read","perm_write","perm_create","perm_unlink" "access_shopfloor_menu_users","shopfloor menu","model_shopfloor_menu",,1,0,0,0 "access_shopfloor_menu_stock_manager","shopfloor menu inventory manager","model_shopfloor_menu","stock.group_stock_manager",1,1,1,1 -"access_shopfloor_operation_group_users","shopfloor operation group","model_shopfloor_operation_group",,1,0,0,0 -"access_shopfloor_operation_group_stock_manager","shopfloor operation group inventory manager","model_shopfloor_operation_group","stock.group_stock_manager",1,1,1,1 "access_shopfloor_profile_users","shopfloor profile","model_shopfloor_profile",,1,0,0,0 "access_shopfloor_profile_stock_manager","shopfloor profile inventory manager","model_shopfloor_profile","stock.group_stock_manager",1,1,1,1 -"access_shopfloor_process_users","shopfloor process","model_shopfloor_process",,1,0,0,0 -"access_shopfloor_process_stock_manager","shopfloor process inventory manager","model_shopfloor_process","stock.group_stock_manager",1,1,1,1 diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 3d04bc72b8..b5d7dafda9 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -10,7 +10,7 @@ class Checkout(Component): """ Methods for the Checkout Process - This process runs on existing moves. + This scenario runs on existing moves. It happens on the "Packing" step of a pick/pack/ship. Use cases: @@ -187,7 +187,7 @@ def _response_for_manual_selection(self, message=None): return self._response(next_state="manual_selection", data=data, message=message) def select(self, picking_id): - """Select a stock picking for the process + """Select a stock picking for the scenario Used from the list of stock pickings (manual_selection), from there, the user can click on a stock.picking record which calls this method. diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 5de76b4244..842e08d414 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -11,14 +11,14 @@ class ClusterPicking(Component): """ Methods for the Cluster Picking Process - The goal of this process is to do the pickings for a Picking Batch, for + The goal of this scenario is to do the pickings for a Picking Batch, for several customers at once. The process assumes that picking batch records already exist. At first, a user gets automatically a batch to work on (assigned to them), or can select one from a list. - The process has 2 main phases, which can be done one after the other or a + The scenario has 2 main phases, which can be done one after the other or a bit of both. The first one is picking goods and put them in a roller-cage. First phase, picking: @@ -59,7 +59,7 @@ class ClusterPicking(Component): the move lines will become unavailable or partially unavailable and will generate a back-order. * Full bin: declaring a full bin allows to move directly to the first phase - (picking) to the second one (unload). The process will go + (picking) to the second one (unload). The scenario will go back to the first phase if some lines remain in the queue of lines to pick. Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP @@ -73,7 +73,7 @@ class ClusterPicking(Component): def find_batch(self): """Find a picking batch to work on and start it - Usually the starting point of the process. + Usually the starting point of the scenario. Business rules to find a batch, try in order: @@ -604,7 +604,7 @@ def _are_all_dest_location_same(self, batch): return len(lines_to_unload.mapped("location_dest_id")) == 1 def prepare_unload(self, picking_batch_id): - """Initiate the unloading phase of the process + """Initiate the unloading phase of the scenario If the destination of all the move lines still to unload is the same, it sets the flag ``cluster_picking_unload_all`` to True on diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index 839d5634f3..b0322fd4e4 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -8,9 +8,8 @@ class ShopfloorMenu(Component): """ Menu Structure for the client application. - The list of menus is restricted by the operation groups. A menu without - groups is visible for all users, a menu with group(s) is visible if the - user is in at least one of the groups. + The list of menus is restricted by the profiles. + A menu without profile is shown in every profiles. """ _inherit = "base.shopfloor.service" @@ -21,21 +20,16 @@ class ShopfloorMenu(Component): def _get_base_search_domain(self): base_domain = super()._get_base_search_domain() - user = self.env.user - profile = getattr(self.work, "profile", None) - op_group_domain = [ - "|", - ("operation_group_ids", "=", False), - ("operation_group_ids.user_ids", "=", user.id), - ] - if profile: - # TODO: this probably should be the default only one way - # What to do w/ profiles linked to specific user? - # This data model is a bit messy :/ - op_group_domain = [ - ("operation_group_ids", "in", profile.operation_group_ids.ids), + return expression.AND( + [ + base_domain, + [ + "|", + ("profile_ids", "=", False), + ("profile_ids", "in", self.work.profile.id), + ], ] - return expression.AND([base_domain, op_group_domain]) + ) def _search(self, name_fragment=None): domain = self._get_base_search_domain() @@ -45,7 +39,7 @@ def _search(self, name_fragment=None): return records def search(self, name_fragment=None): - """List available menu entries for current user""" + """List available menu entries for current profile""" records = self._search(name_fragment=name_fragment) return self._response( data={"size": len(records), "records": self._to_json(records)} @@ -56,16 +50,10 @@ def _convert_one_record(self, record): return { "id": record.id, "name": record.name, - "process": { - "id": record.process_id.id, - "code": record.process_code, - "picking_type": { - "id": record.process_id.picking_type_id.id, - "name": record.process_id.picking_type_id.name, - }, - }, - "op_groups": [ - {"id": g.id, "name": g.name} for g in record.operation_group_ids + "scenario": record.scenario, + "picking_types": [ + {"id": picking_type.id, "name": picking_type.name} + for picking_type in record.picking_type_ids ], } @@ -78,9 +66,7 @@ class ShopfloorMenuValidator(Component): _usage = "menu.validator" def search(self): - return { - "name_fragment": {"type": "string", "nullable": True, "required": False} - } + return {"name_fragment": {"type": "string", "nullable": True, "required": False}} class ShopfloorMenuValidatorResponse(Component): @@ -107,32 +93,13 @@ def _record_schema(self): return { "id": {"coerce": to_int, "required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, - "op_groups": { + "scenario": {"type": "string", "nullable": False, "required": True}, + "picking_types": { "type": "list", - "required": True, - "schema": {"type": "dict", "schema": self._op_group_schema}, - }, - "process": { - "type": "dict", - "required": True, - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "code": {"type": "string", "nullable": False, "required": True}, - "picking_type": { - "type": "dict", - "schema": self._picking_type_schema, - }, - }, + "schema": {"type": "dict", "schema": self._picking_type_schema}, }, } - @property - def _op_group_schema(self): - return { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - } - @property def _picking_type_schema(self): return { diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py index de5086ddf0..e86fcf0e2d 100644 --- a/shopfloor/services/picking_batch.py +++ b/shopfloor/services/picking_batch.py @@ -61,7 +61,7 @@ def search(self, name_fragment=None): """List available stock picking batches for current user Show only picking batches where all the pickings are available and - where all pickings are in the picking type of the current process. + where all pickings are in the picking type of the current scenario. """ records = self._search(name_fragment=name_fragment) return self._response( diff --git a/shopfloor/services/profile.py b/shopfloor/services/profile.py index 94ee11d5ed..af9b9d2841 100644 --- a/shopfloor/services/profile.py +++ b/shopfloor/services/profile.py @@ -1,6 +1,3 @@ -from odoo import fields -from odoo.osv import expression - from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -10,13 +7,9 @@ class ShopfloorProfile(Component): Profile storing the configuration for the interaction from the client. A client application must use a profile, passed to every request in the - HTTP header (TODO put the name of the header). - - The list of profiles available for a user is restricted by 2 things: + HTTP header HTTP_SERVICE_CTX_PROFILE_ID. - * If the profile has operation groups, the profile can be used only - if the user is at least in one of these groups. - * If the user has an assigned profile, the user can use only this profile. + Only stock managers should be allowed to change the profile for a device. """ _inherit = "base.shopfloor.service" @@ -33,31 +26,12 @@ def _search(self, name_fragment=None): return records def search(self, name_fragment=None): - """List available profiles for current user""" + """List available profiles""" records = self._search(name_fragment=name_fragment) return self._response( data={"size": len(records), "records": self._to_json(records)} ) - def _get_base_search_domain(self): - # shopfloor_profile_ids is a one2one in practice. - base_domain = super()._get_base_search_domain() - user = self.env.user - assigned_profile = fields.first(user.shopfloor_profile_ids) - if assigned_profile: - return expression.AND([base_domain, [("id", "=", assigned_profile.id)]]) - - return expression.AND( - [ - base_domain, - [ - "|", - ("operation_group_ids", "=", False), - ("operation_group_ids.user_ids", "=", user.id), - ], - ] - ) - def _convert_one_record(self, record): return { "id": record.id, diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 46e2d63bde..e12c22195a 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -28,10 +28,9 @@ def picking_type(self): """ Get the current picking type based on the menu and the warehouse of the profile. """ - # TODO: this validation should be done in the service - # and should return a proper error for the client - # or the client should handle exceptions properly. - picking_type = self.work.menu.process_id.picking_type_id + # TODO adapt scenarios to handle multiple types, rename this to picking_types + # and filter by warehouse of the profile + picking_type = self.work.menu.picking_type_ids if picking_type.warehouse_id != self.work.profile.warehouse_id: raise exceptions.UserError( _("Process {} cannot be used on warehouse {}").format( @@ -74,9 +73,7 @@ def _get_input_validator(self, method_name): def _get_output_validator(self, method_name): # override the method to get the validator in a component # instead of a method, to keep things apart - validator_component = self.component( - usage="%s.validator.response" % self._usage - ) + validator_component = self.component(usage="%s.validator.response" % self._usage) return validator_component._get_validator(method_name) def _response(self, base_response=None, data=None, next_state=None, message=None): @@ -126,10 +123,11 @@ def _get_openapi_default_parameters(self): demo_api_key = self.env.ref("shopfloor.api_key_demo", raise_if_not_found=False) # Try to first the first menu that implements the current service. - # Not all usages have a process, in that case, well set the first - # process found, because it should not matter for the service. - processes = self.env["shopfloor.process"].search([("code", "=", self._usage)]) - menu = fields.first(processes.menu_ids) + # Not all usages have a process, in that case, we'll set the first + # menu found + menu = self.env["shopfloor.menu"].search( + [("scenario", "=", self._usage)], limit=1 + ) if not menu: menu = self.env["shopfloor.menu"].search([], limit=1) profile = self.env["shopfloor.profile"].search([], limit=1) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 73f4e5fdab..22047fb382 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -44,7 +44,7 @@ def _response_for_forbidden_start(self, existing_operations): "message_type": "error", "message": _( "An operation exists in %s %s. " - "You cannot process it with this shopfloor process." + "You cannot process it with this shopfloor scenario." ) % ( existing_operations[0].picking_id.picking_type_id.name, diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py index c3cab5356a..dcfada2fb7 100644 --- a/shopfloor/tests/test_app.py +++ b/shopfloor/tests/test_app.py @@ -2,9 +2,15 @@ class AppCase(CommonCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_hb_truck_demo") + cls.profile2 = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + def setUp(self): super().setUp() - with self.work_on_services() as work: + with self.work_on_services(profile=self.profile) as work: self.service = work.component(usage="app") def test_user_config(self): @@ -41,38 +47,26 @@ def test_menu_no_profile(self): { "id": menu.id, "name": menu.name, - "op_groups": [ - {"id": x.id, "name": x.name} - for x in menu.operation_group_ids + "scenario": menu.scenario, + "picking_types": [ + {"id": picking_type.id, "name": picking_type.name} + for picking_type in menu.picking_type_ids ], - "process": { - "id": menu.process_id.id, - "code": menu.process_id.code, - "picking_type": { - "id": menu.process_id.picking_type_id.id, - "name": menu.process_id.picking_type_id.name, - }, - }, } for menu in menus - ], + ] }, ) def test_menu_by_profile(self): """Request /app/menu w/ a specific profile""" # Simulate the client asking the menu - op_type = self.env.ref("shopfloor.shopfloor_operation_group_highbay_demo") menus = self.env["shopfloor.menu"].search([]) menu = menus[0] - menu.operation_group_ids = op_type - profile = self.env.ref("shopfloor.shopfloor_profile_hb_truck_demo") - profile.operation_group_ids = op_type - - with self.work_on_services(profile=profile) as work: - service = work.component(usage="app") + menu.profile_ids = self.profile + (menus - menu).profile_ids = self.profile2 - response = service.dispatch("menu") + response = self.service.dispatch("menu") self.assert_response( response, data={ @@ -80,16 +74,12 @@ def test_menu_by_profile(self): { "id": menu.id, "name": menu.name, - "op_groups": [{"id": op_type.id, "name": op_type.name}], - "process": { - "id": menu.process_id.id, - "code": menu.process_id.code, - "picking_type": { - "id": menu.process_id.picking_type_id.id, - "name": menu.process_id.picking_type_id.name, - }, - }, + "scenario": menu.scenario, + "picking_types": [ + {"id": picking_type.id, "name": picking_type.name} + for picking_type in menu.picking_type_ids + ], } - ], + ] }, ) diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index 1f09b9d0a3..16a2123148 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -6,11 +6,10 @@ class CheckoutCommonCase(CommonCase): def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout") - cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id cls.wh.delivery_steps = "pick_pack_ship" - cls.picking_type = cls.process.picking_type_id + cls.picking_type = cls.menu.picking_type_ids def setUp(self): super().setUp() diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py index c327ab989a..8e6ecc69bc 100644 --- a/shopfloor/tests/test_checkout_select_line.py +++ b/shopfloor/tests/test_checkout_select_line.py @@ -37,10 +37,7 @@ def test_select_line_move_line_package_ok(self): # a package and use the move line id only for lines without package response = self.service.dispatch( "select_line", - params={ - "picking_id": self.picking.id, - "move_line_id": selected_lines[0].id, - }, + params={"picking_id": self.picking.id, "move_line_id": selected_lines[0].id}, ) self._assert_selected(response, selected_lines) @@ -48,10 +45,7 @@ def test_select_line_move_line_ok(self): selected_lines = self.move_single.move_line_ids response = self.service.dispatch( "select_line", - params={ - "picking_id": self.picking.id, - "move_line_id": selected_lines[0].id, - }, + params={"picking_id": self.picking.id, "move_line_id": selected_lines[0].id}, ) self._assert_selected(response, selected_lines) diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py index 7463a7e0b2..aaa3be424a 100644 --- a/shopfloor/tests/test_cluster_picking_base.py +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -6,11 +6,10 @@ class ClusterPickingCommonCase(CommonCase, PickingBatchMixin): def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") - cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id cls.wh.delivery_steps = "pick_pack_ship" - cls.picking_type = cls.process.picking_type_id + cls.picking_type = cls.menu.picking_type_ids def setUp(self): super().setUp() diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py index 27d645a703..564960a4c4 100644 --- a/shopfloor/tests/test_delivery_base.py +++ b/shopfloor/tests/test_delivery_base.py @@ -7,11 +7,10 @@ def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_delivery") - cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id cls.wh.delivery_steps = "pick_pack_ship" - cls.picking_type = cls.process.picking_type_id + cls.picking_type = cls.menu.picking_type_ids def setUp(self): super().setUp() diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py index e7e93cc59a..45cc68e47c 100644 --- a/shopfloor/tests/test_menu.py +++ b/shopfloor/tests/test_menu.py @@ -2,9 +2,14 @@ class MenuCase(CommonCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + def setUp(self): super().setUp() - with self.work_on_services() as work: + with self.work_on_services(profile=self.profile) as work: self.service = work.component(usage="menu") def test_menu_search(self): @@ -20,17 +25,44 @@ def test_menu_search(self): { "id": menu.id, "name": menu.name, - "op_groups": [], - "process": { - "id": menu.process_id.id, - "code": menu.process_id.code, - "picking_type": { - "id": menu.process_id.picking_type_id.id, - "name": menu.process_id.picking_type_id.name, - }, - }, + "scenario": menu.scenario, + "picking_types": [ + {"id": picking_type.id, "name": picking_type.name} + for picking_type in menu.picking_type_ids + ], } for menu in menus ], }, ) + + def test_menu_search_restricted(self): + """Request /menu/search with profile attributions""" + # Simulate the client searching menus + menus = self.env["shopfloor.menu"].search([]) + menus_without_profile = menus[0:2] + # these menus should now be hidden for the current profile + other_profile = self.env.ref("shopfloor.shopfloor_profile_hb_truck_demo") + menus_without_profile.profile_ids = other_profile + + response = self.service.dispatch("search") + + my_menus = menus - menus_without_profile + self.assert_response( + response, + data={ + "size": len(my_menus), + "records": [ + { + "id": menu.id, + "name": menu.name, + "scenario": menu.scenario, + "picking_types": [ + {"id": picking_type.id, "name": picking_type.name} + for picking_type in menu.picking_type_ids + ], + } + for menu in my_menus + ], + }, + ) diff --git a/shopfloor/tests/test_openapi.py b/shopfloor/tests/test_openapi.py index 5eff2b327c..1eae2f5569 100644 --- a/shopfloor/tests/test_openapi.py +++ b/shopfloor/tests/test_openapi.py @@ -9,7 +9,6 @@ def setUpClass(cls, *args, **kwargs): # we don't really care about which menu and profile we use # to read the OpenAPI specs cls.menu = cls.env.ref("shopfloor.shopfloor_menu_delivery") - cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") def setUp(self): diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py index a68d6e9074..0eb20edc82 100644 --- a/shopfloor/tests/test_picking_batch.py +++ b/shopfloor/tests/test_picking_batch.py @@ -13,10 +13,9 @@ def setUpClass(cls, *args, **kwargs): ) # which menu we pick should not matter for the batch picking api cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") - cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id - cls.picking_type = cls.process.picking_type_id + cls.picking_type = cls.menu.picking_type_ids cls.batch1 = cls._create_picking_batch( [[cls.BatchProduct(product=cls.product_a, quantity=1)]] ) diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index e943dfe953..f28c329a4a 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -29,7 +29,6 @@ def setUpClass(cls, *args, **kwargs): } ) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck") - cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id @@ -141,7 +140,7 @@ def test_start_package_not_in_src_location(self): "message": "You cannot work on a package (%s) outside of location: %s" % ( self.pack_a.name, - self.process.picking_type_id.default_location_src_id.name, + self.menu.picking_type_ids.default_location_src_id.name, ), }, ) @@ -180,7 +179,7 @@ def test_start_move_in_different_picking_type(self): message={ "message_type": "error", "message": "An operation exists in Delivery Orders %s. You cannot" - " process it with this shopfloor process." % (picking.name,), + " process it with this shopfloor scenario." % (picking.name,), }, ) @@ -235,7 +234,7 @@ def _simulate_started(self): Used to test the next endpoints (/validate and /cancel) """ picking_form = Form(self.env["stock.picking"]) - picking_form.picking_type_id = self.menu.process_id.picking_type_id + picking_form.picking_type_id = self.menu.picking_type_ids with picking_form.move_ids_without_package.new() as move: move.product_id = self.product_a move.product_uom_qty = 1 diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index 5a91659502..6270163224 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -22,10 +22,9 @@ def setUpClass(cls, *args, **kwargs): } ) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_single_pallet_transfer") - cls.process = cls.menu.process_id cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id - cls.picking_type = cls.process.picking_type_id + cls.picking_type = cls.menu.picking_type_ids cls.picking = cls._create_initial_move() # disable the completion on the picking type, we'll have specific test(s) diff --git a/shopfloor/views/menus.xml b/shopfloor/views/menus.xml index 6ece0c498f..29876a3a77 100644 --- a/shopfloor/views/menus.xml +++ b/shopfloor/views/menus.xml @@ -12,18 +12,6 @@ parent="menu_shopfloor_settings" sequence="10" /> - - - - + + + @@ -18,6 +27,9 @@ + + + diff --git a/shopfloor/views/shopfloor_operation_group.xml b/shopfloor/views/shopfloor_operation_group.xml deleted file mode 100644 index d4286df223..0000000000 --- a/shopfloor/views/shopfloor_operation_group.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - shopfloor operation group tree - shopfloor.operation.group - - - - - - - - - shopfloor operation group form - shopfloor.operation.group - -
- - - - - - - - - - - -
-
-
- - shopfloor operation group search - shopfloor.operation.group - - - - - - - - Operation Groups - shopfloor.operation.group - ir.actions.act_window - tree,form - -
diff --git a/shopfloor/views/shopfloor_process.xml b/shopfloor/views/shopfloor_process.xml deleted file mode 100644 index 5ec7e2fb58..0000000000 --- a/shopfloor/views/shopfloor_process.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - shopfloor process tree - shopfloor.process - - - - - - - - - - shopfloor process form - shopfloor.process - -
- - - - - - - - - - - -
-
-
- - shopfloor process search - shopfloor.process - - - - - - - - - - Processes - shopfloor.process - ir.actions.act_window - tree,form - -
diff --git a/shopfloor/views/shopfloor_profile_views.xml b/shopfloor/views/shopfloor_profile_views.xml index 5f0fd5245b..785aa6231d 100644 --- a/shopfloor/views/shopfloor_profile_views.xml +++ b/shopfloor/views/shopfloor_profile_views.xml @@ -7,8 +7,6 @@ - -
@@ -24,8 +22,6 @@ - - @@ -39,8 +35,6 @@ - -
diff --git a/shopfloor/views/stock_picking_type.xml b/shopfloor/views/stock_picking_type.xml index 7d38e3074f..25313e1586 100644 --- a/shopfloor/views/stock_picking_type.xml +++ b/shopfloor/views/stock_picking_type.xml @@ -6,7 +6,7 @@ - + From fdc250d67242f155155fdf0f37b1e0db67c95503 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 5 May 2020 15:04:02 +0200 Subject: [PATCH 191/940] checkout: modify lookup by package The test CheckoutScanCase.test_scan_document_error_different_picking_type_package was no longer passing because was returning a "barcode not found" instead of "you cannot move this using this menu". The picking type should not be checked at this point, as it is checked in _select_picking() (called at the end of the method), which will return the appropriate message. Note: replaced the search by a field to reuse the cache if we had such data in cache. --- shopfloor/actions/search.py | 13 ------------- shopfloor/models/stock_quant_package.py | 7 +++++++ shopfloor/services/checkout.py | 13 ++++++++++++- shopfloor/tests/test_checkout_scan.py | 12 +++++++----- 4 files changed, 26 insertions(+), 19 deletions(-) diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py index 2bba15131d..a547abbbc6 100644 --- a/shopfloor/actions/search.py +++ b/shopfloor/actions/search.py @@ -37,16 +37,3 @@ def generic_packaging_from_scan(self, barcode): return self.env["product.packaging"].search( [("barcode", "=", barcode), ("product_id", "=", False)] ) - - def stock_picking_from_package(self, package, picking_type): - return ( - self.env["stock.move.line"] - .search( - [ - ("state", "not in", ("cancel", "done")), - ("package_id", "=", package.id), - ("picking_id.picking_type_id", "=", picking_type.id), - ] - ) - .mapped("picking_id") - ) diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py index c835935fb3..4716a95bb1 100644 --- a/shopfloor/models/stock_quant_package.py +++ b/shopfloor/models/stock_quant_package.py @@ -4,6 +4,13 @@ class StockQuantPackage(models.Model): _inherit = "stock.quant.package" + move_line_ids = fields.One2many( + comodel_name="stock.move.line", + inverse_name="package_id", + readonly=True, + help="Technical field. Move lines moving this package.", + ) + planned_move_line_ids = fields.One2many( comodel_name="stock.move.line", inverse_name="result_package_id", diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index b5d7dafda9..265bba4dec 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -85,7 +85,18 @@ def scan_document(self, barcode): if not picking: package = search.package_from_scan(barcode) if package: - pickings = search.stock_picking_from_package(package, self.picking_type) + pickings = package.move_line_ids.filtered( + lambda ml: ml.state not in ("cancel", "done") + + ).mapped("picking_id") + if len(pickings) > 1: + # Filter only if we find several pickings to narrow the + # selection to one of the good type. If we have one picking + # of the wrong type, it will be caught in _select_picking + # with the proper error message. + # Side note: rather unlikely to have several transfers ready + # and moving the same things + pickings = pickings.filtered(lambda p: p.picking_type_id == self.picking_type) if len(pickings) == 1: picking = pickings return self._select_picking(picking, "select_document") diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py index 4972a278cd..43ba2cc6dd 100644 --- a/shopfloor/tests/test_checkout_scan.py +++ b/shopfloor/tests/test_checkout_scan.py @@ -80,7 +80,7 @@ def test_scan_document_error_location_not_child_of_type(self): message={"message_type": "error", "message": "Location not allowed here."}, ) - def _test_scan_document_error_different_picking_type(self, barcode_func, msg): + def _test_scan_document_error_different_picking_type(self, barcode_func): picking = self._create_picking(picking_type=self.wh.pick_type_id) self._fill_stock_for_moves(picking.move_lines, in_package=True) picking.action_assign() @@ -89,18 +89,20 @@ def _test_scan_document_error_different_picking_type(self, barcode_func, msg): self.assert_response( response, next_state="select_document", - message={"message_type": "error", "message": msg}, + message={ + "message_type": "error", + "message": "You cannot move this using this menu.", + }, ) def test_scan_document_error_different_picking_type_picking(self): self._test_scan_document_error_different_picking_type( - lambda picking: picking.name, msg="You cannot move this using this menu." + lambda picking: picking.name ) def test_scan_document_error_different_picking_type_package(self): self._test_scan_document_error_different_picking_type( - lambda picking: picking.move_line_ids.package_id.name, - msg="Barcode not found", + lambda picking: picking.move_line_ids.package_id.name ) def test_scan_document_error_location_several_pickings(self): From 53e4441ed32bb98de1d87e7569922607c772a23f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 5 May 2020 15:51:03 +0200 Subject: [PATCH 192/940] backend: support multiple picking types in services Except for the cases where we can create a move, a single menu should be able to work on several picking types. Later, some menus will have a flag "create_move", which add an extra step in the scenario. In this case, we'll still have a selection of several picking types on the menu, but an additional M2o to select the picking type used for creation of moves. --- shopfloor/actions/message.py | 6 ++--- shopfloor/models/stock_location.py | 5 ++-- shopfloor/services/checkout.py | 8 +++---- shopfloor/services/menu.py | 2 ++ shopfloor/services/picking_batch.py | 2 +- shopfloor/services/service.py | 22 ++++++++++-------- shopfloor/services/single_pack_putaway.py | 8 +++++-- shopfloor/services/single_pack_transfer.py | 24 +++++++------------- shopfloor/tests/test_single_pack_putaway.py | 2 +- shopfloor/tests/test_single_pack_transfer.py | 2 +- 10 files changed, 41 insertions(+), 40 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index b2b6cc8433..d7166ece3e 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -38,11 +38,11 @@ def package_not_found_for_barcode(self, barcode): def bin_not_found_for_barcode(self, barcode): return {"message_type": "error", "message": _("Bin %s doesn't exist") % barcode} - def package_not_allowed_in_src_location(self, barcode, picking_type): + def package_not_allowed_in_src_location(self, barcode, picking_types): return { "message_type": "error", - "message": _("You cannot work on a package (%s) outside of location: %s") - % (barcode, picking_type.default_location_src_id.name), + "message": _("You cannot work on a package (%s) outside of locations: %s") + % (barcode, ", ".join(picking_types.mapped("default_location_src_id.name"))), } def already_running_ask_confirmation(self): diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py index 4e391e629d..6c9ce5962a 100644 --- a/shopfloor/models/stock_location.py +++ b/shopfloor/models/stock_location.py @@ -8,10 +8,11 @@ class StockLocation(models.Model): comodel_name="stock.move.line", inverse_name="location_id", readonly=True ) - def is_sublocation_of(self, other): + def is_sublocation_of(self, others): + """Return True if self is a sublocation of at least one other""" self.ensure_one() return bool( self.env["stock.location"].search_count( - [("id", "child_of", other.id), ("id", "=", self.id)] + [("id", "child_of", others.ids), ("id", "=", self.id)] ) ) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 265bba4dec..c48b5b9b70 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -63,7 +63,7 @@ def scan_document(self, barcode): location = search.location_from_scan(barcode) if location: if not location.is_sublocation_of( - self.picking_type.default_location_src_id + self.picking_types.mapped("default_location_src_id") ): return self._response_for_select_document( message=message.location_not_allowed() @@ -96,7 +96,7 @@ def scan_document(self, barcode): # with the proper error message. # Side note: rather unlikely to have several transfers ready # and moving the same things - pickings = pickings.filtered(lambda p: p.picking_type_id == self.picking_type) + pickings = pickings.filtered(lambda p: p.picking_type_id in self.picking_types) if len(pickings) == 1: picking = pickings return self._select_picking(picking, "select_document") @@ -111,7 +111,7 @@ def _select_picking(self, picking, state_for_error): return self._response_for_select_document( message=message.barcode_not_found() ) - if picking.picking_type_id != self.picking_type: + if picking.picking_type_id not in self.picking_types: if state_for_error == "manual_selection": return self._response_for_manual_selection( message=message.cannot_move_something_in_picking_type() @@ -169,7 +169,7 @@ def _lines_to_pack(self, picking): def _domain_for_list_stock_picking(self): return [ ("state", "=", "assigned"), - ("picking_type_id", "=", self.picking_type.id), + ("picking_type_id", "in", self.picking_types.ids), ] def _order_for_list_stock_picking(self): diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index b0322fd4e4..a54a62615b 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -20,6 +20,8 @@ class ShopfloorMenu(Component): def _get_base_search_domain(self): base_domain = super()._get_base_search_domain() + # FIXME exclude menus if they have no picking types + # in warehouse matching the profile return expression.AND( [ base_domain, diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py index e86fcf0e2d..44de8eb7e8 100644 --- a/shopfloor/services/picking_batch.py +++ b/shopfloor/services/picking_batch.py @@ -51,7 +51,7 @@ def _search(self, name_fragment=None, batch_ids=None): batch.state == "in_progress" or picking.state in ("assigned", "done", "cancel") ) - and picking.picking_type_id == self.picking_type + and picking.picking_type_id in self.picking_types for picking in batch.picking_ids ) ) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index e12c22195a..6d2bdfb7d8 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -24,21 +24,23 @@ class BaseShopfloorService(AbstractComponent): _expose_model = None @property - def picking_type(self): + def picking_types(self): """ - Get the current picking type based on the menu and the warehouse of the profile. + Get the allowed picking types based on the menu and the warehouse of the profile. """ - # TODO adapt scenarios to handle multiple types, rename this to picking_types - # and filter by warehouse of the profile - picking_type = self.work.menu.picking_type_ids - if picking_type.warehouse_id != self.work.profile.warehouse_id: + # TODO make this a lazy property or computed field avoid running the + # filter every time? + picking_types = self.work.menu.picking_type_ids.filtered( + lambda pt: not pt.warehouse_id + or pt.warehouse_id == self.work.profile.warehouse_id + ) + if not picking_types: raise exceptions.UserError( - _("Process {} cannot be used on warehouse {}").format( - picking_type.display_name, - self.work.profile.warehouse_id.display_name, + _("No operation types configured on menu {} for warehouse {}.").format( + self.work.menu.name, self.work.profile.warehouse_id.display_name ) ) - return picking_type + return picking_types def _get(self, _id): domain = expression.normalize_domain(self._get_base_search_domain()) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 22047fb382..9b312ea2ee 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -85,8 +85,12 @@ def _response_for_start_success(self, move_line, pack): def start(self, barcode): """Scan a pack barcode""" - - picking_type = self.picking_type + # TODO we have to rework this and single_pack_transfer, 'pack putaway' + # will be integrated within 'pack transfer' with an option "create + # move" on the menu. When "create move" is active on the menu, an + # additional M2o field must be filled on the menu with the picking type + # used for creations. + picking_type = self.picking_types if len(picking_type) > 1: return self._response_for_several_picking_types() elif not picking_type: diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index d2db50456f..2deccf5fc9 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -32,17 +32,11 @@ def _response_for_package_not_found(self, barcode): next_state="start", message=message.package_not_found_for_barcode(barcode) ) - def _response_for_forbidden_package(self, barcode, picking_type): + def _response_for_forbidden_package(self, barcode, picking_types): message = self.actions_for("message") return self._response( next_state="start", - message=message.package_not_allowed_in_src_location(barcode, picking_type), - ) - - def _response_for_several_picking_types(self): - message = self.actions_for("message") - return self._response( - next_state="start", message=message.several_picking_types() + message=message.package_not_allowed_in_src_location(barcode, picking_types), ) def _response_for_operation_not_found(self, pack): @@ -81,11 +75,7 @@ def _response_for_start_success(self, move_line, pack): def start(self, barcode): search = self.actions_for("search") - - picking_type = self.picking_type - if len(picking_type) > 1: - return self._response_for_several_picking_types() - + picking_types = self.picking_types location = search.location_from_scan(barcode) pack = self.env["stock.quant.package"] @@ -104,14 +94,16 @@ def start(self, barcode): if not pack: return self._response_for_package_not_found(barcode) - if not pack.location_id.is_sublocation_of(picking_type.default_location_src_id): - return self._response_for_forbidden_package(barcode, picking_type) + if not pack.location_id.is_sublocation_of( + picking_types.mapped("default_location_src_id") + ): + return self._response_for_forbidden_package(barcode, picking_types) existing_operations = self.env["stock.move.line"].search( [ ("package_id", "=", pack.id), ("state", "!=", "done"), - ("picking_id.picking_type_id", "=", self.picking_type.id), + ("picking_id.picking_type_id", "in", picking_types.ids), ] ) if not existing_operations: diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index f28c329a4a..6428638418 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -137,7 +137,7 @@ def test_start_package_not_in_src_location(self): next_state="start", message={ "message_type": "error", - "message": "You cannot work on a package (%s) outside of location: %s" + "message": "You cannot work on a package (%s) outside of locations: %s" % ( self.pack_a.name, self.menu.picking_type_ids.default_location_src_id.name, diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index 6270163224..7cb8ff577d 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -273,7 +273,7 @@ def test_start_pack_outside_of_location(self): next_state="start", message={ "message_type": "error", - "message": "You cannot work on a package (%s) outside of location: %s" + "message": "You cannot work on a package (%s) outside of locations: %s" % (self.pack_a.name, self.picking_type.default_location_src_id.name), }, ) From 13f3aecc4f69389ef6b587f7b6e7734d0d78f721 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 5 May 2020 15:57:21 +0200 Subject: [PATCH 193/940] run black --- shopfloor/actions/message.py | 5 ++++- shopfloor/models/shopfloor_menu.py | 3 +-- shopfloor/services/checkout.py | 5 +++-- shopfloor/services/menu.py | 4 +++- shopfloor/services/service.py | 10 +++++----- shopfloor/tests/test_checkout_cancel_line.py | 4 ++-- shopfloor/tests/test_checkout_select_line.py | 10 ++++++++-- 7 files changed, 26 insertions(+), 15 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index d7166ece3e..e035a3fdaa 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -42,7 +42,10 @@ def package_not_allowed_in_src_location(self, barcode, picking_types): return { "message_type": "error", "message": _("You cannot work on a package (%s) outside of locations: %s") - % (barcode, ", ".join(picking_types.mapped("default_location_src_id.name"))), + % ( + barcode, + ", ".join(picking_types.mapped("default_location_src_id.name")), + ), } def already_running_ask_confirmation(self): diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index c5058c1f56..0074c72999 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -12,8 +12,7 @@ class ShopfloorMenu(models.Model): "shopfloor.profile", string="Profiles", help="Visible for these profiles" ) picking_type_ids = fields.Many2many( - comodel_name="stock.picking.type", string="Operation Types", - required=True, + comodel_name="stock.picking.type", string="Operation Types", required=True, ) # TODO allow only one picking type when 'move creation' is allowed diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index c48b5b9b70..27ca05f3ab 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -87,7 +87,6 @@ def scan_document(self, barcode): if package: pickings = package.move_line_ids.filtered( lambda ml: ml.state not in ("cancel", "done") - ).mapped("picking_id") if len(pickings) > 1: # Filter only if we find several pickings to narrow the @@ -96,7 +95,9 @@ def scan_document(self, barcode): # with the proper error message. # Side note: rather unlikely to have several transfers ready # and moving the same things - pickings = pickings.filtered(lambda p: p.picking_type_id in self.picking_types) + pickings = pickings.filtered( + lambda p: p.picking_type_id in self.picking_types + ) if len(pickings) == 1: picking = pickings return self._select_picking(picking, "select_document") diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index a54a62615b..3c0321b23f 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -68,7 +68,9 @@ class ShopfloorMenuValidator(Component): _usage = "menu.validator" def search(self): - return {"name_fragment": {"type": "string", "nullable": True, "required": False}} + return { + "name_fragment": {"type": "string", "nullable": True, "required": False} + } class ShopfloorMenuValidatorResponse(Component): diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 6d2bdfb7d8..ba4b867ddb 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -1,4 +1,4 @@ -from odoo import _, exceptions, fields +from odoo import _, exceptions from odoo.exceptions import MissingError from odoo.osv import expression @@ -25,9 +25,7 @@ class BaseShopfloorService(AbstractComponent): @property def picking_types(self): - """ - Get the allowed picking types based on the menu and the warehouse of the profile. - """ + """Return picking types for the menu and profile""" # TODO make this a lazy property or computed field avoid running the # filter every time? picking_types = self.work.menu.picking_type_ids.filtered( @@ -75,7 +73,9 @@ def _get_input_validator(self, method_name): def _get_output_validator(self, method_name): # override the method to get the validator in a component # instead of a method, to keep things apart - validator_component = self.component(usage="%s.validator.response" % self._usage) + validator_component = self.component( + usage="%s.validator.response" % self._usage + ) return validator_component._get_validator(method_name) def _response(self, base_response=None, data=None, next_state=None, message=None): diff --git a/shopfloor/tests/test_checkout_cancel_line.py b/shopfloor/tests/test_checkout_cancel_line.py index cbe9aee027..5f0589c73c 100644 --- a/shopfloor/tests/test_checkout_cancel_line.py +++ b/shopfloor/tests/test_checkout_cancel_line.py @@ -86,7 +86,7 @@ def test_cancel_package_ok(self): response, next_state="summary", data={ - "picking": self._stock_picking_data(self.picking, done=True), + "picking": self._stock_picking_data(picking, done=True), "all_processed": False, }, ) @@ -111,7 +111,7 @@ def test_cancel_line_ok(self): response, next_state="summary", data={ - "picking": self._stock_picking_data(self.picking, done=True), + "picking": self._stock_picking_data(picking, done=True), "all_processed": False, }, ) diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py index 8e6ecc69bc..c327ab989a 100644 --- a/shopfloor/tests/test_checkout_select_line.py +++ b/shopfloor/tests/test_checkout_select_line.py @@ -37,7 +37,10 @@ def test_select_line_move_line_package_ok(self): # a package and use the move line id only for lines without package response = self.service.dispatch( "select_line", - params={"picking_id": self.picking.id, "move_line_id": selected_lines[0].id}, + params={ + "picking_id": self.picking.id, + "move_line_id": selected_lines[0].id, + }, ) self._assert_selected(response, selected_lines) @@ -45,7 +48,10 @@ def test_select_line_move_line_ok(self): selected_lines = self.move_single.move_line_ids response = self.service.dispatch( "select_line", - params={"picking_id": self.picking.id, "move_line_id": selected_lines[0].id}, + params={ + "picking_id": self.picking.id, + "move_line_id": selected_lines[0].id, + }, ) self._assert_selected(response, selected_lines) From 334f06530e4eee3773d3de6a0c4b143f6e7952b7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 6 May 2020 08:35:31 +0200 Subject: [PATCH 194/940] backend: hide menus with picking types of different warehouse --- shopfloor/services/menu.py | 15 ++++++++--- shopfloor/tests/test_menu.py | 48 +++++++++++++++++++----------------- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index 3c0321b23f..3d9bad9d98 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -20,24 +20,33 @@ class ShopfloorMenu(Component): def _get_base_search_domain(self): base_domain = super()._get_base_search_domain() - # FIXME exclude menus if they have no picking types - # in warehouse matching the profile return expression.AND( [ base_domain, [ "|", ("profile_ids", "=", False), - ("profile_ids", "in", self.work.profile.id), + ("profile_ids", "in", self.work.profile.ids), ], ] ) def _search(self, name_fragment=None): + if not self.work.profile: + # we need to know the warehouse of the profile + # to load menus + return self.env["shopfloor.menu"].browse() domain = self._get_base_search_domain() if name_fragment: domain.append(("name", "ilike", name_fragment)) records = self.env[self._expose_model].search(domain) + current_wh = self.work.profile.warehouse_id + records = records.filtered( + lambda menu: all( + not pt.warehouse_id or pt.warehouse_id == current_wh + for pt in menu.picking_type_ids + ) + ) return records def search(self, name_fragment=None): diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py index 45cc68e47c..0c0f9ba6a8 100644 --- a/shopfloor/tests/test_menu.py +++ b/shopfloor/tests/test_menu.py @@ -12,11 +12,7 @@ def setUp(self): with self.work_on_services(profile=self.profile) as work: self.service = work.component(usage="menu") - def test_menu_search(self): - """Request /menu/search""" - # Simulate the client searching menus - response = self.service.dispatch("search") - menus = self.env["shopfloor.menu"].search([]) + def _assert_menu_response(self, response, menus): self.assert_response( response, data={ @@ -36,6 +32,13 @@ def test_menu_search(self): }, ) + def test_menu_search(self): + """Request /menu/search""" + # Simulate the client searching menus + response = self.service.dispatch("search") + menus = self.env["shopfloor.menu"].search([]) + self._assert_menu_response(response, menus) + def test_menu_search_restricted(self): """Request /menu/search with profile attributions""" # Simulate the client searching menus @@ -48,21 +51,20 @@ def test_menu_search_restricted(self): response = self.service.dispatch("search") my_menus = menus - menus_without_profile - self.assert_response( - response, - data={ - "size": len(my_menus), - "records": [ - { - "id": menu.id, - "name": menu.name, - "scenario": menu.scenario, - "picking_types": [ - {"id": picking_type.id, "name": picking_type.name} - for picking_type in menu.picking_type_ids - ], - } - for menu in my_menus - ], - }, - ) + self._assert_menu_response(response, my_menus) + + def test_menu_search_warehouse_filter(self): + """Request /menu/search with different warehouse on profile""" + menus = self.env["shopfloor.menu"].search([]) + # should not be visible as the profile has another wh + menu_different_wh = menus[0] + other_wh = self.env["stock.warehouse"].create({"name": "Test", "code": "test"}) + menu_different_wh.picking_type_ids.warehouse_id = other_wh + + # should be visible to any profile + menu_no_wh = menus[1] + menu_no_wh.picking_type_ids.warehouse_id = False + + response = self.service.dispatch("search") + + self._assert_menu_response(response, menus - menu_different_wh) From 932c0d4c7ccf41709e79409b1204d85550c2d853 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 6 May 2020 12:29:37 +0200 Subject: [PATCH 195/940] backend: add info fields for pickings --- shopfloor/models/__init__.py | 1 + shopfloor/models/stock_picking.py | 32 +++++++++++++++++++ shopfloor/models/stock_picking_batch.py | 41 ++++++++++++++++++------- 3 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 shopfloor/models/stock_picking.py diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index d86b15fec5..46d5a1ccf6 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -3,5 +3,6 @@ from . import shopfloor_profile from . import stock_location from . import stock_move_line +from . import stock_picking from . import stock_picking_batch from . import stock_quant_package diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py new file mode 100644 index 0000000000..d2a7e60042 --- /dev/null +++ b/shopfloor/models/stock_picking.py @@ -0,0 +1,32 @@ +from odoo import api, fields, models + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + total_weight = fields.Float( + compute="_compute_picking_info", + help="Technical field. Indicates total weight of pickings included.", + ) + move_line_count = fields.Integer( + compute="_compute_picking_info", + help="Technical field. Indicates number of move lines included.", + ) + + @api.depends( + "move_line_ids", "move_line_ids.product_qty", "move_line_ids.product_id.weight" + ) + def _compute_picking_info(self): + for item in self: + item.update( + { + "total_weight": item._calc_weight(), + "move_line_count": len(item.move_line_ids), + } + ) + + def _calc_weight(self): + weight = 0.0 + for move_line in self.mapped("move_line_ids"): + weight += move_line.product_qty * move_line.product_id.weight + return weight diff --git a/shopfloor/models/stock_picking_batch.py b/shopfloor/models/stock_picking_batch.py index c3f3173b03..8a34d26c09 100644 --- a/shopfloor/models/stock_picking_batch.py +++ b/shopfloor/models/stock_picking_batch.py @@ -1,4 +1,4 @@ -from odoo import fields, models +from odoo import api, fields, models class StockPickingBatch(models.Model): @@ -10,15 +10,34 @@ class StockPickingBatch(models.Model): help="Technical field. Indicates if a batch is destination is" " asked once for all lines or for every line.", ) + picking_count = fields.Integer( + compute="_compute_picking_info", + help="Technical field. Indicates number of pickings included.", + ) + move_line_count = fields.Integer( + compute="_compute_picking_info", + help="Technical field. Indicates number of move lines included.", + ) + total_weight = fields.Float( + compute="_compute_picking_info", + help="Technical field. Indicates total weight of pickings included.", + ) - def total_weight(self): - return self.calc_weight(self.picking_ids) - - def picking_weight(self, picking): - return self.calc_weight(picking) + @api.depends("picking_ids.total_weight", "picking_ids.move_line_ids") + def _compute_picking_info(self): + for item in self: + assigned_pickings = item.picking_ids.filtered( + lambda picking: picking.state == "assigned" + ) + item.update( + { + "picking_count": len(assigned_pickings.ids), + "move_line_count": len( + assigned_pickings.mapped("move_line_ids").ids + ), + "total_weight": item._calc_weight(assigned_pickings), + } + ) - def calc_weight(self, pickings): - weight = 0.0 - for move_line in pickings.mapped("move_line_ids"): - weight += move_line.product_qty * move_line.product_id.weight - return weight + def _calc_weight(self, pickings): + return sum(pickings.mapped("total_weight")) From d248d1e2111edc76b0bfc4b01f42b723f6a9bc41 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 6 May 2020 12:33:36 +0200 Subject: [PATCH 196/940] backend: get rid of obsolete picking_batch * picking_batch was used only by cluster_picking * introduce base_jsonify for data --- shopfloor/__manifest__.py | 1 + shopfloor/actions/data.py | 13 ++++ shopfloor/services/__init__.py | 3 - shopfloor/services/cluster_picking.py | 78 +++++++++++++++---- shopfloor/services/schema.py | 9 +++ shopfloor/tests/__init__.py | 2 +- ...batch.py => test_cluster_picking_batch.py} | 62 +++++++-------- 7 files changed, 116 insertions(+), 52 deletions(-) rename shopfloor/tests/{test_picking_batch.py => test_cluster_picking_batch.py} (68%) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index bfb02438c8..3e58a73793 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -15,6 +15,7 @@ "stock", "stock_picking_batch", "base_rest", + "base_jsonify", "auth_api_key", # https://github.com/OCA/stock-logistics-warehouse/pull/808 "stock_picking_completion_info", diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 9cab2186c6..79335a1636 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -78,3 +78,16 @@ def move_line(self, move_line): "location_src": self.location(move_line.location_id), "location_dest": self.location(move_line.location_dest_id), } + + def _jsonify(self, recordset, parser): + res = recordset.jsonify(parser) + if len(recordset.ids) == 1: + return res[0] + return res + + def picking_batch(self, record): + return self._jsonify(record, self._picking_batch_parser) + + @property + def _picking_batch_parser(self): + return ["id", "name", "picking_count", "move_line_count", "total_weight:weight"] diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 8908b1cb7c..18e7b56db6 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -5,15 +5,12 @@ # generic services from . import app -from . import location from . import menu -from . import pack from . import profile # process services from . import checkout from . import cluster_picking from . import delivery -from . import picking_batch from . import single_pack_putaway from . import single_pack_transfer diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 842e08d414..f75e962e9e 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -1,4 +1,5 @@ from odoo import _, fields +from odoo.osv import expression from odoo.tools.float_utils import float_compare from odoo.addons.base_rest.components.service import to_bool, to_int @@ -88,8 +89,7 @@ def find_batch(self): * confirm_start: when it could find a batch * start: when no batch is available """ - batch_service = self.component(usage="picking_batch") - batches = batch_service._search() + batches = self._batch_picking_search() selected = self._select_a_picking_batch(batches) if selected: return self._response_for_confirm_start(selected) @@ -105,9 +105,48 @@ def list_batch(self): Transitions: * manual_selection: to the selection screen """ - batch_service = self.component(usage="picking_batch") - batches = batch_service.search()["data"] - return self._response(next_state="manual_selection", data=batches) + data_struct = self.actions_for("data") + batches = self._batch_picking_search() + data = {"records": data_struct.picking_batch(batches), "size": len(batches)} + return self._response(next_state="manual_selection", data=data) + + def _batch_picking_base_search_domain(self): + return [ + "|", + "&", + ("user_id", "=", False), + ("state", "=", "draft"), + "&", + ("user_id", "=", self.env.user.id), + ("state", "in", ("draft", "in_progress")), + ] + + def _batch_picking_search(self, name_fragment=None, batch_ids=None): + domain = self._batch_picking_base_search_domain() + if name_fragment: + domain = expression.AND([domain, [("name", "ilike", name_fragment)]]) + if batch_ids: + domain = expression.AND([domain, [("id", "in", batch_ids)]]) + records = self.env["stock.picking.batch"].search(domain, order="id asc") + records = records.filtered( + # Include done/cancel because we want to be able to work on the + # batch even if some pickings are done/canceled. They'll should be + # ignored later. + lambda batch: all( + ( + # When the batch is already in progress, we do not care + # about state of the pickings, because we want to be able + # to recover it in any case, even if, for instance, a stock + # error changed a picking to unavailable after the user + # started to work on the batch. + batch.state == "in_progress" + or picking.state in ("assigned", "done", "cancel") + ) + and picking.picking_type_id == self.picking_type + for picking in batch.picking_ids + ) + ) + return records # TODO this may be used in other scenarios? if so, extract def _select_a_picking_batch(self, batches): @@ -142,14 +181,14 @@ def _response_for_no_batch_found(self): def _response_for_confirm_start(self, batch): pickings = [] + # TODO: use data.picking_batch for picking in batch.picking_ids: - p_weight = batch.picking_weight(picking) p_values = { "id": picking.id, "name": picking.name, "partner": None, "move_line_count": len(picking.move_line_ids), - "weight": p_weight, + "weight": picking.total_weight, "origin": picking.origin or "", } if picking.partner_id: @@ -160,7 +199,12 @@ def _response_for_confirm_start(self, batch): pickings.append(p_values) return self._response( next_state="confirm_start", - data={"id": batch.id, "name": batch.name, "pickings": pickings}, + data={ + "id": batch.id, + "name": batch.name, + "picking_count": batch.picking_count, + "pickings": pickings, + }, ) def _response_for_batch_cannot_be_selected(self): @@ -189,9 +233,8 @@ def select(self, picking_batch_id): concurrently for instance) * confirm_start: after the batch has been assigned to the user """ - batch_service = self.component(usage="picking_batch") - batch = batch_service._search(batch_ids=[picking_batch_id]) - selected = self._select_a_picking_batch(batch) + batches = self._batch_picking_search(batch_ids=[picking_batch_id]) + selected = self._select_a_picking_batch(batches) if selected: return self._response_for_confirm_start(selected) else: @@ -271,8 +314,7 @@ def _data_move_line(self, line): lot = line.lot_id package = line.package_id data = { - # TODO have common methods to return general info - # for each model + # TODO use `data` component "id": line.id, "quantity": line.product_uom_qty, "postponed": line.shopfloor_postponed, @@ -1522,5 +1564,11 @@ def _schema_for_completion_info(self): @property def _schema_for_batch_selection(self): - batch_validator = self.component(usage="picking_batch.validator.response") - return batch_validator.search()["data"]["schema"] + return { + "size": {"required": True, "type": "integer"}, + "records": { + "type": "list", + "required": True, + "schema": {"type": "dict", "schema": self.schemas().picking_batch()}, + }, + } diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index e4762d247b..6ed975b0b5 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -102,3 +102,12 @@ def packaging(self): "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, } + + def picking_batch(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "picking_count": {"required": True, "type": "integer"}, + "move_line_count": {"required": True, "type": "integer"}, + "weight": {"required": True, "nullable": True, "type": "float"}, + } diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index baa5b40e96..7994d4bf51 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -3,10 +3,10 @@ from . import test_openapi from . import test_profile from . import test_actions_data -from . import test_picking_batch from . import test_single_pack_putaway from . import test_single_pack_transfer from . import test_cluster_picking_base +from . import test_cluster_picking_batch from . import test_cluster_picking_select from . import test_cluster_picking_scan from . import test_cluster_picking_skip diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_cluster_picking_batch.py similarity index 68% rename from shopfloor/tests/test_picking_batch.py rename to shopfloor/tests/test_cluster_picking_batch.py index 0eb20edc82..114cd5354a 100644 --- a/shopfloor/tests/test_picking_batch.py +++ b/shopfloor/tests/test_cluster_picking_batch.py @@ -1,7 +1,7 @@ from .common import CommonCase, PickingBatchMixin -class BatchPickingCase(CommonCase, PickingBatchMixin): +class ClusterPickingBatchCase(CommonCase, PickingBatchMixin): @classmethod def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) @@ -41,14 +41,13 @@ def setUpClass(cls, *args, **kwargs): def setUp(self): super().setUp() with self.work_on_services(menu=self.menu, profile=self.profile) as work: - self.service = work.component(usage="picking_batch") + self.service = work.component(usage="cluster_picking") def test_search_empty(self): """No batch is available""" # Simulate the client asking the list of picking batch - response = self.service.dispatch("search") # none of the pickings are assigned, so we can't work on them - self.assert_response(response, data={"size": 0, "records": []}) + self.assertFalse(self.service._batch_picking_search()) def test_search(self): """Return only draft batches with assigned pickings """ @@ -68,33 +67,30 @@ def test_search(self): self.batch3.confirm_picking() # Simulate the client asking the list of picking batch - response = self.service.dispatch("search") - self.assert_response( - response, - data={ - "size": 3, - "records": [ - { - "id": self.batch1.id, - "name": self.batch1.name, - "picking_count": 1, - "move_line_count": 1, - "weight": 0.0, - }, - { - "id": self.batch2.id, - "name": self.batch2.name, - "picking_count": 1, - "move_line_count": 1, - "weight": 0.0, - }, - { - "id": self.batch3.id, - "name": self.batch3.name, - "picking_count": 1, - "move_line_count": 1, - "weight": 0.0, - }, - ], - }, + res = self.service._batch_picking_search() + self.assertRecordValues( + res, + [ + { + "id": self.batch1.id, + "name": self.batch1.name, + "picking_count": 1, + "move_line_count": 1, + "total_weight": 0.0, + }, + { + "id": self.batch2.id, + "name": self.batch2.name, + "picking_count": 1, + "move_line_count": 1, + "total_weight": 0.0, + }, + { + "id": self.batch3.id, + "name": self.batch3.name, + "picking_count": 1, + "move_line_count": 1, + "total_weight": 0.0, + }, + ], ) From fa06fe5d5b4b4bf51688fd0c0722dd7d72e80892 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 6 May 2020 16:12:05 +0200 Subject: [PATCH 197/940] backend: REF data action w/ jsonify * use jsonify * provide multiple + single converter * use it all around (except pack transfer scenario as it needs refactoring) --- shopfloor/actions/data.py | 195 ++++++++++++------ shopfloor/services/checkout.py | 34 +-- shopfloor/services/cluster_picking.py | 147 ++++--------- shopfloor/services/location.py | 101 --------- shopfloor/services/pack.py | 60 ------ shopfloor/services/schema.py | 13 +- shopfloor/tests/test_actions_data.py | 48 +++-- shopfloor/tests/test_checkout_base.py | 2 +- shopfloor/tests/test_checkout_list_package.py | 7 +- shopfloor/tests/test_cluster_picking_base.py | 8 +- .../tests/test_cluster_picking_select.py | 4 +- 11 files changed, 234 insertions(+), 385 deletions(-) delete mode 100644 shopfloor/services/location.py delete mode 100644 shopfloor/services/pack.py diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 79335a1636..e71c9a5db9 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -12,81 +12,142 @@ class DataAction(Component): _inherit = "shopfloor.process.action" _usage = "data" - def picking_summary(self, picking): - return { - "id": picking.id, - "name": picking.name, - "origin": picking.origin or "", - "note": picking.note or "", - "line_count": len(picking.move_line_ids), - "partner": {"id": picking.partner_id.id, "name": picking.partner_id.name} - if picking.partner_id - else None, - } - - def package(self, package, picking=None): + def _jsonify(self, recordset, parser, multi=False, **kw): + res = recordset.jsonify(parser) + if not multi: + return res[0] if res else None + return res + + def partner(self, record, **kw): + return self._jsonify(record, self._partner_parser, **kw) + + def partners(self, record, **kw): + return self.partner(record, multi=True) + + @property + def _partner_parser(self): + return ["id", "name"] + + def picking(self, record, **kw): + return self._jsonify(record, self._picking_parser, **kw) + + def pickings(self, record, **kw): + return self.picking(record, multi=True) + + @property + def _picking_parser(self): + return [ + "id", + "name", + "origin", + "note", + ("partner_id:partner", self._partner_parser), + "move_line_count", + "total_weight:weight", + ] + + def package(self, record, picking=None, **kw): """Return data for a stock.quant.package If a picking is given, it will include the number of lines of the package for the picking. """ - line_count = ( - len(picking.move_line_ids.filtered(lambda l: l.package_id == package)) - if picking - else 0 - ) - return { - "id": package.id, - "name": package.name, - # TODO - "weight": 0, - "line_count": line_count, - "packaging_name": package.product_packaging_id.name or "", - } - - def packaging(self, packaging): - return {"id": packaging.id, "name": packaging.name} - - def lot(self, lot): - return {"id": lot.id, "name": lot.name} - - def location(self, location): - return {"id": location.id, "name": location.name} - - def move_line(self, move_line): - return { - "id": move_line.id, - "qty_done": move_line.qty_done, - "quantity": move_line.product_uom_qty, - "product": { - "id": move_line.product_id.id, - "name": move_line.product_id.name, - "display_name": move_line.product_id.display_name, - "default_code": move_line.product_id.default_code or "", - }, - "lot": {"id": move_line.lot_id.id, "name": move_line.lot_id.name} - if move_line.lot_id - else None, - "package_src": self.package(move_line.package_id, move_line.picking_id) - if move_line.package_id - else None, - "package_dest": self.package( - move_line.result_package_id, move_line.picking_id + data = self._jsonify(record, self._package_parser, **kw) + # handle special cases + if data and picking: + # TODO: exclude canceled and done? + lines = picking.move_line_ids.filtered(lambda l: l.package_id == record) + data.update({"move_line_count": len(lines)}) + return data + + def packages(self, records, picking=None, **kw): + return [self.package(rec, picking=picking) for rec in records] + + @property + def _package_parser(self): + return [ + "id", + "name", + "pack_weight:weight", + ("product_packaging_id:packaging", self._packaging_parser), + ] + + def packaging(self, record, **kw): + return self._jsonify(record, self._packaging_parser, **kw) + + def packagings(self, record, **kw): + return self.packaging(record, multi=True) + + @property + def _packaging_parser(self): + return ["id", "name"] + + def lot(self, record, **kw): + return self._jsonify(record, self._lot_parser, **kw) + + def lots(self, record, **kw): + return self.lot(record, multi=True) + + @property + def _lot_parser(self): + return ["id", "name", "ref"] + + def location(self, record, **kw): + return self._jsonify(record, self._location_parser, **kw) + + def locations(self, record, **kw): + return self.location(record, multi=True) + + @property + def _location_parser(self): + return ["id", "name"] + + def move_line(self, record, **kw): + data = self._jsonify(record, self._move_line_parser) + if data: + data.update( + { + "package_src": self.package(record.package_id, record.picking_id), + "package_dest": self.package( + record.result_package_id, record.picking_id, + ), + } ) - if move_line.result_package_id - else None, - "location_src": self.location(move_line.location_id), - "location_dest": self.location(move_line.location_dest_id), - } + return data - def _jsonify(self, recordset, parser): - res = recordset.jsonify(parser) - if len(recordset.ids) == 1: - return res[0] - return res + def move_lines(self, records, **kw): + return [self.move_line(rec) for rec in records] + + @property + def _move_line_parser(self): + return [ + "id", + "qty_done", + "product_uom_qty:quantity", + ("product_id:product", self._product_parser), + ("lot_id:lot", self._lot_parser), + ("location_id:location_src", self._location_parser), + ("location_dest_id:location_dest", self._location_parser), + ] + + def product(self, record, **kw): + return self._jsonify(record, self._product_parser, **kw) + + def products(self, record, **kw): + return self.product(record, multi=True) + + @property + def _product_parser(self): + return ["id", "name", "display_name", "default_code"] + + def picking_batch(self, record, with_pickings=True, **kw): + parser = self._picking_batch_parser + if with_pickings: + parser.append(("picking_ids:pickings", self._picking_parser)) + return self._jsonify(record, parser, **kw) - def picking_batch(self, record): - return self._jsonify(record, self._picking_batch_parser) + def picking_batches(self, record, **kw): + return self.picking_batch(record, multi=True) @property def _picking_batch_parser(self): diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 27ca05f3ab..ba3c0f4225 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -154,11 +154,9 @@ def _response_for_select_document(self, message=None): def _data_for_stock_picking(self, picking, done=False): data_struct = self.actions_for("data") - data = data_struct.picking_summary(picking) + data = data_struct.picking(picking) line_picker = self._lines_checkout_done if done else self._lines_to_pack - data.update( - {"move_lines": [data_struct.move_line(ml) for ml in line_picker(picking)]} - ) + data.update({"move_lines": data_struct.move_lines(line_picker(picking))}) return data def _lines_checkout_done(self, picking): @@ -193,9 +191,7 @@ def _response_for_manual_selection(self, message=None): order=self._order_for_list_stock_picking(), ) data_struct = self.actions_for("data") - data = { - "pickings": [data_struct.picking_summary(picking) for picking in pickings] - } + data = {"pickings": data_struct.pickings(pickings)} return self._response(next_state="manual_selection", data=data, message=message) def select(self, picking_id): @@ -225,10 +221,8 @@ def _response_for_select_package(self, lines, message=None): return self._response( next_state="select_package", data={ - "selected_move_lines": [ - data_struct.move_line(line) for line in lines.sorted() - ], - "picking": data_struct.picking_summary(picking), + "selected_move_lines": data_struct.move_lines(lines.sorted()), + "picking": data_struct.picking(picking), }, message=message, ) @@ -743,20 +737,15 @@ def _response_for_select_dest_package(self, picking, move_lines, message=None): }, ) data_struct = self.actions_for("data") - picking_data = data_struct.picking_summary(picking) - packages_data = [ - data_struct.package(package, picking=picking) - for package in packages.sorted() - ] + picking_data = data_struct.picking(picking) + packages_data = data_struct.packages(packages.sorted(), picking=picking) data_struct = self.actions_for("data") return self._response( next_state="select_dest_package", data={ "picking": picking_data, "packages": packages_data, - "selected_move_lines": [ - data_struct.move_line(line) for line in move_lines.sorted() - ], + "selected_move_lines": data_struct.move_lines(move_lines.sorted()), }, message=message, ) @@ -864,12 +853,9 @@ def _response_for_change_packaging(self, picking, package, packaging_list): return self._response( next_state="change_packaging", data={ - "picking": data_struct.picking_summary(picking), + "picking": data_struct.picking(picking), "package": data_struct.package(package, picking=picking), - "packagings": [ - data_struct.packaging(packaging) - for packaging in packaging_list.sorted() - ], + "packagings": data_struct.packagings(packaging_list.sorted()), }, ) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index f75e962e9e..1c5b767f16 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -71,6 +71,10 @@ class ClusterPicking(Component): _usage = "cluster_picking" _description = __doc__ + @property + def data_struct(self): + return self.actions_for("data") + def find_batch(self): """Find a picking batch to work on and start it @@ -105,9 +109,11 @@ def list_batch(self): Transitions: * manual_selection: to the selection screen """ - data_struct = self.actions_for("data") batches = self._batch_picking_search() - data = {"records": data_struct.picking_batch(batches), "size": len(batches)} + data = { + "records": self.data_struct.picking_batches(batches), + "size": len(batches), + } return self._response(next_state="manual_selection", data=data) def _batch_picking_base_search_domain(self): @@ -142,7 +148,7 @@ def _batch_picking_search(self, name_fragment=None, batch_ids=None): batch.state == "in_progress" or picking.state in ("assigned", "done", "cancel") ) - and picking.picking_type_id == self.picking_type + and picking.picking_type_id in self.picking_types for picking in batch.picking_ids ) ) @@ -180,31 +186,9 @@ def _response_for_no_batch_found(self): ) def _response_for_confirm_start(self, batch): - pickings = [] - # TODO: use data.picking_batch - for picking in batch.picking_ids: - p_values = { - "id": picking.id, - "name": picking.name, - "partner": None, - "move_line_count": len(picking.move_line_ids), - "weight": picking.total_weight, - "origin": picking.origin or "", - } - if picking.partner_id: - p_values["partner"] = { - "id": picking.partner_id.id, - "name": picking.partner_id.name, - } - pickings.append(p_values) return self._response( next_state="confirm_start", - data={ - "id": batch.id, - "name": batch.name, - "picking_count": batch.picking_count, - "pickings": pickings, - }, + data=self.data_struct.picking_batch(batch, with_pickings=True), ) def _response_for_batch_cannot_be_selected(self): @@ -307,49 +291,16 @@ def _data_for_next_move_line(self, picking_batch): return self._data_move_line(remaining_lines[0]) def _data_move_line(self, line): - # TODO: use shopfloor.data.action.move_line picking = line.picking_id batch = picking.batch_id product = line.product_id - lot = line.lot_id - package = line.package_id - data = { - # TODO use `data` component - "id": line.id, - "quantity": line.product_uom_qty, - "postponed": line.shopfloor_postponed, - "picking": { - "id": picking.id, - "name": picking.name, - "origin": picking.origin or "", - "note": picking.note or "", - "partner": None, - }, - "batch": {"id": batch.id, "name": batch.name}, - "product": { - "id": product.id, - "name": product.name, - "display_name": product.display_name, - "default_code": product.default_code or "", - "qty_available": product.qty_available, - }, - "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or ""} - if lot - else None, - "location_src": {"id": line.location_id.id, "name": line.location_id.name}, - "location_dest": { - "id": line.location_dest_id.id, - "name": line.location_dest_id.name, - }, - "pack": {"id": package.id, "name": package.name} if package else None, - } - if picking.partner_id: - # TODO retrieve info always in the same way - # maybe using base_jsonify - data["picking"]["partner"] = { - "id": picking.partner_id.id, - "name": picking.partner_id.name, - } + data = self.data_struct.move_line(line) + # additional values + data.pop("package_dest", None) + data["batch"] = self.data_struct.picking_batch(batch) + data["picking"] = self.data_struct.picking(picking) + data["postponed"] = line.shopfloor_postponed + data["product"]["qty_available"] = product.qty_available return data def unassign(self, picking_batch_id): @@ -513,10 +464,9 @@ def _response_for_scan_destination(self, move_line, message=None): last_picked_line = self._last_picked_line(move_line.picking_id) if last_picked_line: # suggest pack to be used for the next line - data["destination_pack"] = { - "id": last_picked_line.result_package_id.id, - "name": last_picked_line.result_package_id.name, - } + data["package_dest"] = self.data_struct.package( + last_picked_line.result_package_id + ) return self._response(next_state="scan_destination", data=data, message=message) def scan_destination_pack(self, move_line_id, barcode, quantity): @@ -630,16 +580,11 @@ def _planned_qty_in_location_is_empty(self, product, location): return compare <= 0 def _response_for_zero_check(self, move_line): - return self._response( - next_state="zero_check", - data={ - "id": move_line.id, - "location_src": { - "id": move_line.location_id.id, - "name": move_line.location_id.name, - }, - }, - ) + data = { + "id": move_line.id, + "location_src": self.data_struct.location(move_line.location_id), + } + return self._response(next_state="zero_check", data=data) def _are_all_dest_location_same(self, batch): lines_to_unload = self._lines_to_unload(batch) @@ -674,29 +619,25 @@ def _data_for_unload_all(self, batch): # all the lines destinations are the same here, it looks # only for the first one first_line = fields.first(lines) - return { - "id": batch.id, - "name": batch.name, - "location_dest": { - "id": first_line.location_dest_id.id, - "name": first_line.location_dest_id.name, - }, - } + data = self.data_struct.picking_batch(batch) + data.update( + {"location_dest": self.data_struct.location(first_line.location_dest_id)} + ) + return data def _data_for_unload_single(self, batch, package): line = fields.first( package.planned_move_line_ids.filtered(self._filter_for_unload) ) - return { - # TODO disambiguate "id" everywhere? (id -> picking_batch_id) - "id": batch.id, - "name": batch.name, - "package": {"id": package.id, "name": package.name}, - "location_dest": { - "id": line.location_dest_id.id, - "name": line.location_dest_id.name, - }, - } + # TODO disambiguate "id" everywhere? (id -> picking_batch_id) + data = self.data_struct.picking_batch(batch) + data.update( + { + "package": self.data_struct.package(package), + "location_dest": self.data_struct.location(line.location_dest_id), + } + ) + return data def _response_for_unload_all(self, batch, message=None): return self._response( @@ -1411,8 +1352,8 @@ def _schema_for_single_line_details(self): "schema": { "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, - "origin": {"type": "string", "nullable": False, "required": True}, - "note": {"type": "string", "nullable": False, "required": True}, + "origin": {"type": "string", "nullable": True, "required": True}, + "note": {"type": "string", "nullable": True, "required": True}, "partner": { "type": "dict", "required": False, @@ -1464,7 +1405,7 @@ def _schema_for_single_line_details(self): "schema": { "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, - "ref": {"type": "string", "nullable": False, "required": True}, + "ref": {"type": "string", "nullable": True, "required": True}, }, }, # TODO share parts of the schema? @@ -1482,7 +1423,7 @@ def _schema_for_single_line_details(self): "name": {"type": "string", "nullable": False, "required": True}, }, }, - "pack": { + "package_src": { "type": "dict", "required": False, "nullable": True, @@ -1491,7 +1432,7 @@ def _schema_for_single_line_details(self): "name": {"type": "string", "nullable": False, "required": True}, }, }, - "destination_pack": { + "package_dest": { "type": "dict", "required": False, "nullable": True, diff --git a/shopfloor/services/location.py b/shopfloor/services/location.py deleted file mode 100644 index 7bd237f4bd..0000000000 --- a/shopfloor/services/location.py +++ /dev/null @@ -1,101 +0,0 @@ -from odoo.osv import expression - -from odoo.addons.base_rest.components.service import to_int -from odoo.addons.component.core import Component - - -class ShopfloorLocation(Component): - """Expose Stock Locations data for the current warehouse.""" - - _inherit = "base.shopfloor.service" - _name = "shopfloor.location" - _usage = "location" - _expose_model = "stock.location" - _description = __doc__ - - def search(self, name_fragment=None): - """List available locations for current user""" - domain = self._get_base_search_domain() - if name_fragment: - domain = expression.AND( - [ - domain, - [ - "|", - ("name", "ilike", name_fragment), - ("barcode", "ilike", name_fragment), - ], - ] - ) - records = self.env[self._expose_model].search(domain) - return self._response( - data={"size": len(records), "records": self._to_json(records)} - ) - - def _get_base_search_domain(self): - # TODO add filter on warehouse of the current profile - return super()._get_base_search_domain() - - def _convert_one_record(self, record): - return { - "id": record.id, - "name": record.name, - "complete_name": record.complete_name, - "barcode": record.barcode or "", - } - - -class ShopfloorLocationValidator(Component): - """Validators for the Location endpoints""" - - _inherit = "base.shopfloor.validator" - _name = "shopfloor.location.validator" - _usage = "location.validator" - - def search(self): - return { - "name_fragment": {"type": "string", "nullable": True, "required": False} - } - - -class ShopfloorLocationValidatorResponse(Component): - """Validators for the Location endpoints responses""" - - _inherit = "base.shopfloor.validator.response" - _name = "shopfloor.location.validator.response" - _usage = "location.validator.response" - - def search(self): - return self._response_schema( - { - "size": {"coerce": to_int, "required": True, "type": "integer"}, - "records": { - "type": "list", - "schema": { - "type": "dict", - "schema": { - "id": { - "coerce": to_int, - "required": True, - "type": "integer", - }, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, - "complete_name": { - "type": "string", - "nullable": False, - "required": True, - }, - "barcode": { - "type": "string", - "nullable": False, - "required": False, - }, - }, - }, - }, - } - ) diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py deleted file mode 100644 index bf4d3ea2f3..0000000000 --- a/shopfloor/services/pack.py +++ /dev/null @@ -1,60 +0,0 @@ -from odoo.addons.base_rest.components.service import to_int -from odoo.addons.component.core import Component - - -class ShopfloorPack(Component): - """Expose data about Stock Quant Packages""" - - _inherit = "base.shopfloor.service" - _name = "shopfloor.pack" - _usage = "pack" - _description = __doc__ - - def get_by_name(self, pack_name): - """Get pack information""" - search = self.actions_for("search") - package = search.package_from_scan(pack_name) - return self._response(data=self._to_json(package)[:1]) - - def _convert_one_record(self, record): - return { - "id": record.id, - "name": record.name, - "location": {"id": record.location_id.id, "name": record.location_id.name}, - } - - -class ShopfloorPackValidator(Component): - """Validators for the Pack endpoints""" - - _inherit = "base.shopfloor.validator" - _name = "shopfloor.pack.validator" - _usage = "pack.validator" - - def get_by_name(self): - return {"pack_name": {"type": "string", "nullable": False, "required": True}} - - -class ShopfloorPackValidatorResponse(Component): - """Validators for the Pack endpoints responses""" - - _inherit = "base.shopfloor.validator.response" - _name = "shopfloor.pack.validator.response" - _usage = "pack.validator.response" - - @property - def _record_schema(self): - return { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "location": { - "type": "dict", - "schema": { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - } - - def get_by_name(self): - return self._response_schema(self._record_schema) diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 6ed975b0b5..2335aad540 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -20,7 +20,8 @@ def picking(self): "name": {"type": "string", "nullable": False, "required": True}, "origin": {"type": "string", "nullable": True, "required": True}, "note": {"type": "string", "nullable": True, "required": True}, - "line_count": {"type": "integer", "nullable": True, "required": True}, + "move_line_count": {"type": "integer", "nullable": True, "required": True}, + "weight": {"required": True, "nullable": True, "type": "float"}, "partner": { "type": "dict", "nullable": True, @@ -81,14 +82,20 @@ def package(self): "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, "weight": {"required": True, "nullable": True, "type": "float"}, - "line_count": {"required": True, "nullable": True, "type": "integer"}, - "packaging_name": {"required": True, "nullable": True, "type": "string"}, + "move_line_count": {"required": True, "nullable": True, "type": "integer"}, + "packaging": { + "type": "dict", + "required": True, + "nullable": True, + "schema": self.packaging(), + }, } def lot(self): return { "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, + "ref": {"type": "string", "nullable": True, "required": False}, } def location(self): diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index c8f2207b9e..8adf3ba9bb 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -68,11 +68,15 @@ def test_data_location(self): def test_data_lot(self): lot = self.env["stock.production.lot"].create( - {"product_id": self.product_b.id, "company_id": self.env.company.id} + { + "product_id": self.product_b.id, + "company_id": self.env.company.id, + "ref": "#FOO", + } ) data = self.data.lot(lot) self.assert_schema(self.schema.lot(), data) - expected = {"id": lot.id, "name": lot.name} + expected = {"id": lot.id, "name": lot.name, "ref": "#FOO"} self.assertDictEqual(data, expected) def test_data_package(self): @@ -83,23 +87,23 @@ def test_data_package(self): expected = { "id": package.id, "name": package.name, - "line_count": 1, - "packaging_name": self.packaging.name, - # TODO + "move_line_count": 1, + "packaging": self.data.packaging(package.product_packaging_id), "weight": 0, } self.assertDictEqual(data, expected) - def test_data_picking_summary(self): + def test_data_picking(self): self.picking.write({"origin": "created by test", "note": "read me"}) - data = self.data.picking_summary(self.picking) + data = self.data.picking(self.picking) self.assert_schema(self.schema.picking(), data) expected = { "id": self.picking.id, - "line_count": 4, + "move_line_count": 4, "name": self.picking.name, "note": "read me", "origin": "created by test", + "weight": 110.0, "partner": {"id": self.customer.id, "name": self.customer.name}, } self.assertDictEqual(data, expected) @@ -126,16 +130,16 @@ def test_data_move_line_package(self): "package_src": { "id": move_line.package_id.id, "name": move_line.package_id.name, - "line_count": 1, - "packaging_name": "", + "move_line_count": 1, + "packaging": None, # TODO "weight": 0, }, "package_dest": { "id": result_package.id, "name": result_package.name, - "line_count": 0, - "packaging_name": self.packaging.name, + "move_line_count": 0, + "packaging": self.data.packaging(self.packaging), # TODO "weight": 0, }, @@ -164,7 +168,11 @@ def test_data_move_line_lot(self): "display_name": "[B] Product B", "default_code": "B", }, - "lot": {"id": move_line.lot_id.id, "name": move_line.lot_id.name}, + "lot": { + "id": move_line.lot_id.id, + "name": move_line.lot_id.name, + "ref": None, + }, "package_src": None, "package_dest": None, "location_src": { @@ -193,20 +201,24 @@ def test_data_move_line_package_lot(self): "display_name": "[C] Product C", "default_code": "C", }, - "lot": {"id": move_line.lot_id.id, "name": move_line.lot_id.name}, + "lot": { + "id": move_line.lot_id.id, + "name": move_line.lot_id.name, + "ref": None, + }, "package_src": { "id": move_line.package_id.id, "name": move_line.package_id.name, - "line_count": 1, - "packaging_name": "", + "move_line_count": 1, + "packaging": None, # TODO "weight": 0, }, "package_dest": { "id": move_line.result_package_id.id, "name": move_line.result_package_id.name, - "line_count": 1, - "packaging_name": "", + "move_line_count": 1, + "packaging": None, # TODO "weight": 0, }, diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index 16a2123148..ce6a969d6b 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -21,7 +21,7 @@ def _stock_picking_data(self, picking, **kw): # we test the methods that structure data in test_actions_data.py def _picking_summary_data(self, picking): - return self.service.actions_for("data").picking_summary(picking) + return self.service.actions_for("data").picking(picking) def _move_line_data(self, move_line): return self.service.actions_for("data").move_line(move_line) diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py index a17bcb9d91..78af3be96c 100644 --- a/shopfloor/tests/test_checkout_list_package.py +++ b/shopfloor/tests/test_checkout_list_package.py @@ -13,9 +13,10 @@ def _assert_response_select_dest_package( "picking": { "id": picking.id, "name": picking.name, - "note": "", - "origin": "", - "line_count": len(picking.move_line_ids), + "note": None, + "origin": None, + "weight": 110.0, + "move_line_count": len(picking.move_line_ids), "partner": {"id": self.customer.id, "name": self.customer.name}, }, "packages": [ diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py index aaa3be424a..7d8333bbff 100644 --- a/shopfloor/tests/test_cluster_picking_base.py +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -56,7 +56,7 @@ def _line_data(self, move_line, qty=None): "picking": { "id": picking.id, "name": picking.name, - "note": "", + "note": None, "origin": picking.origin, "partner": {"id": self.customer.id, "name": self.customer.name}, }, @@ -68,10 +68,12 @@ def _line_data(self, move_line, qty=None): "name": move_line.product_id.name, "qty_available": move_line.product_id.qty_available, }, - "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or ""} + "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or None} if lot else None, - "pack": {"id": package.id, "name": package.name} if package else None, + "package_src": {"id": package.id, "name": package.name} + if package + else None, } @classmethod diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py index b844873408..00ef30dbb2 100644 --- a/shopfloor/tests/test_cluster_picking_select.py +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -380,7 +380,7 @@ def test_confirm_start_ok(self): "picking": { "id": picking.id, "name": picking.name, - "note": "", + "note": None, "origin": picking.origin, "partner": {"id": self.customer.id, "name": self.customer.name}, }, @@ -393,7 +393,7 @@ def test_confirm_start_ok(self): "qty_available": first_move_line.product_id.qty_available, }, "lot": None, - "pack": {"id": package.id, "name": package.name}, + "package_src": {"id": package.id, "name": package.name}, }, next_state="start_line", ) From 9c8d2bc17d9dd2426a65d4f4ac1e58f9883a8729 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 7 May 2020 09:09:17 +0200 Subject: [PATCH 198/940] Apply suggestions from code review --- shopfloor/models/stock_picking.py | 2 +- shopfloor/models/stock_picking_batch.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py index d2a7e60042..d266f38cce 100644 --- a/shopfloor/models/stock_picking.py +++ b/shopfloor/models/stock_picking.py @@ -6,7 +6,7 @@ class StockPicking(models.Model): total_weight = fields.Float( compute="_compute_picking_info", - help="Technical field. Indicates total weight of pickings included.", + help="Technical field. Indicates total weight of transfers included.", ) move_line_count = fields.Integer( compute="_compute_picking_info", diff --git a/shopfloor/models/stock_picking_batch.py b/shopfloor/models/stock_picking_batch.py index 8a34d26c09..e1f2093b60 100644 --- a/shopfloor/models/stock_picking_batch.py +++ b/shopfloor/models/stock_picking_batch.py @@ -12,7 +12,7 @@ class StockPickingBatch(models.Model): ) picking_count = fields.Integer( compute="_compute_picking_info", - help="Technical field. Indicates number of pickings included.", + help="Technical field. Indicates number of transfers included.", ) move_line_count = fields.Integer( compute="_compute_picking_info", @@ -20,7 +20,7 @@ class StockPickingBatch(models.Model): ) total_weight = fields.Float( compute="_compute_picking_info", - help="Technical field. Indicates total weight of pickings included.", + help="Technical field. Indicates total weight of transfers included.", ) @api.depends("picking_ids.total_weight", "picking_ids.move_line_ids") From c53b5891b57c5ae1d565eed9692297b2d15ce98f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 8 May 2020 09:45:03 +0200 Subject: [PATCH 199/940] cluster picking: refactor using _response_for_ methods --- shopfloor/actions/message.py | 6 + shopfloor/services/cluster_picking.py | 285 ++++++++++++-------------- 2 files changed, 142 insertions(+), 149 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index e035a3fdaa..f7c56f8552 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -206,3 +206,9 @@ def lot_multiple_packages_scan_package(self): "This lot is part of multiple packages, please scan a package." ), } + + def batch_transfer_complete(self): + return { + "message_type": "success", + "message": _("Batch Transfer complete"), + } diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 1c5b767f16..c08d51abbf 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -75,6 +75,84 @@ class ClusterPicking(Component): def data_struct(self): return self.actions_for("data") + @property + def msg_store(self): + return self.actions_for("message") + + def _response_for_start(self, message=None): + return self._response(next_state="start", message=message,) + + def _response_for_confirm_start(self, batch): + return self._response( + next_state="confirm_start", + data=self.data_struct.picking_batch(batch, with_pickings=True), + ) + + def _response_for_manual_selection(self, batches, message=None): + data = { + "records": self.data_struct.picking_batches(batches), + "size": len(batches), + } + return self._response(next_state="manual_selection", data=data, message=message) + + def _response_for_start_line(self, move_line, message=None): + return self._response( + next_state="start_line", + data=self._data_move_line(move_line), + message=message, + ) + + def _response_for_scan_destination(self, move_line, message=None): + data = self._data_move_line(move_line) + last_picked_line = self._last_picked_line(move_line.picking_id) + if last_picked_line: + # suggest pack to be used for the next line + data["package_dest"] = self.data_struct.package( + last_picked_line.result_package_id + ) + return self._response(next_state="scan_destination", data=data, message=message) + + def _response_for_zero_check(self, move_line): + data = { + "id": move_line.id, + "location_src": self.data_struct.location(move_line.location_id), + } + return self._response(next_state="zero_check", data=data) + + def _response_for_unload_all(self, batch, message=None): + return self._response( + next_state="unload_all", + data=self._data_for_unload_all(batch), + message=message, + ) + + def _response_for_confirm_unload_all(self, batch, message=None): + return self._response( + next_state="confirm_unload_all", + data=self._data_for_unload_all(batch), + message=message, + ) + + def _response_for_unload_single(self, batch, package, message=None): + return self._response( + next_state="unload_single", + data=self._data_for_unload_single(batch, package), + message=message, + ) + + def _response_for_unload_set_destination(self, batch, package, message=None): + return self._response( + next_state="unload_set_destination", + data=self._data_for_unload_single(batch, package), + message=message, + ) + + def _response_for_confirm_unload_set_destination(self, batch, package): + return self._response( + next_state="confirm_unload_set_destination", + data=self._data_for_unload_single(batch, package), + ) + def find_batch(self): """Find a picking batch to work on and start it @@ -98,7 +176,14 @@ def find_batch(self): if selected: return self._response_for_confirm_start(selected) else: - return self._response_for_no_batch_found() + return self._response_for_start( + message={ + "message_type": "info", + "message": _( + "No more work to do, please create a new batch transfer" + ), + }, + ) def list_batch(self): """List picking batch on which user can work @@ -110,11 +195,7 @@ def list_batch(self): * manual_selection: to the selection screen """ batches = self._batch_picking_search() - data = { - "records": self.data_struct.picking_batches(batches), - "size": len(batches), - } - return self._response(next_state="manual_selection", data=data) + return self._response_for_manual_selection(batches) def _batch_picking_base_search_domain(self): return [ @@ -176,30 +257,6 @@ def _select_a_picking_batch(self, batches): return batch return self.env["stock.picking.batch"] - def _response_for_no_batch_found(self): - return self._response( - next_state="start", - message={ - "message_type": "info", - "message": _("No more work to do, please create a new batch transfer"), - }, - ) - - def _response_for_confirm_start(self, batch): - return self._response( - next_state="confirm_start", - data=self.data_struct.picking_batch(batch, with_pickings=True), - ) - - def _response_for_batch_cannot_be_selected(self): - return self._response( - base_response=self.list_batch(), - message={ - "message_type": "warning", - "message": _("This batch cannot be selected."), - }, - ) - def select(self, picking_batch_id): """Manually select a picking batch @@ -222,7 +279,13 @@ def select(self, picking_batch_id): if selected: return self._response_for_confirm_start(selected) else: - return self._response_for_batch_cannot_be_selected() + return self._response( + base_response=self.list_batch(), + message={ + "message_type": "warning", + "message": _("This batch cannot be selected."), + }, + ) def confirm_start(self, picking_batch_id): """User confirms they start a batch @@ -283,8 +346,7 @@ def _next_line_for_pick(self, picking_batch): return fields.first(remaining_lines) def _response_batch_does_not_exist(self): - message = self.actions_for("message") - return self._response(next_state="start", message=message.record_not_found()) + return self._response_for_start(message=self.msg_store.record_not_found()) def _data_for_next_move_line(self, picking_batch): remaining_lines = self._lines_to_pick(picking_batch) @@ -312,7 +374,7 @@ def unassign(self, picking_batch_id): batch = self.env["stock.picking.batch"].browse(picking_batch_id) if batch.exists(): batch.write({"state": "draft", "user_id": False}) - return self._response(next_state="start") + return self._response_for_start() def scan_line(self, move_line_id, barcode): """Scan a location, a pack, a product or a lots @@ -337,11 +399,10 @@ def scan_line(self, move_line_id, barcode): pack meanwhile (race condition). * scan_destination: if the barcode matches. """ - message = self.actions_for("message") move_line = self.env["stock.move.line"].browse(move_line_id) if not move_line.exists(): - return self._response( - next_state="start", message=message.unrecoverable_error() + return self._response_for_start( + message=self.msg_store.unrecoverable_error() ) search = self.actions_for("search") @@ -366,17 +427,16 @@ def scan_line(self, move_line_id, barcode): return self._scan_line_by_location(picking, move_line, location) return self._response_for_start_line( - move_line, message=message.barcode_not_found() + move_line, message=self.msg_store.barcode_not_found() ) def _scan_line_by_package(self, picking, move_line, package): return self._response_for_scan_destination(move_line) def _scan_line_by_product(self, picking, move_line, product): - message = self.actions_for("message") if move_line.product_id.tracking in ("lot", "serial"): return self._response_for_start_line( - move_line, message=message.scan_lot_on_product_tracked_by_lot() + move_line, message=self.msg_store.scan_lot_on_product_tracked_by_lot() ) # if we scanned a product and it's part of several packages, we can't be @@ -391,12 +451,12 @@ def _scan_line_by_product(self, picking, move_line, product): # package. if packages and len({l.package_id for l in other_product_lines}) > 1: return self._response_for_start_line( - move_line, message=message.product_multiple_packages_scan_package() + move_line, + message=self.msg_store.product_multiple_packages_scan_package(), ) return self._response_for_scan_destination(move_line) def _scan_line_by_lot(self, picking, move_line, lot): - message = self.actions_for("message") # if we scanned a lot and it's part of several packages, we can't be # sure the user scanned the correct one, in such case, ask to scan a package other_lot_lines = picking.move_line_ids.filtered(lambda l: l.lot_id == lot) @@ -407,12 +467,11 @@ def _scan_line_by_lot(self, picking, move_line, lot): # package. if packages and len({l.package_id for l in other_lot_lines}) > 1: return self._response_for_start_line( - move_line, message=message.lot_multiple_packages_scan_package() + move_line, message=self.msg_store.lot_multiple_packages_scan_package() ) return self._response_for_scan_destination(move_line) def _scan_line_by_location(self, picking, move_line, location): - message = self.actions_for("message") # When a user scan a location, we accept only when we knows that # they scanned the good thing, so if in the location we have # several lots (on a package or a product), several packages, @@ -435,40 +494,27 @@ def _scan_line_by_location(self, picking, move_line, location): if len(lots) > 1: return self._response_for_start_line( move_line, - message=message.several_lots_in_location(move_line.location_id), + message=self.msg_store.several_lots_in_location(move_line.location_id), ) if len(packages | products) > 1: if move_line.package_id: return self._response_for_start_line( move_line, - message=message.several_packs_in_location(move_line.location_id), + message=self.msg_store.several_packs_in_location( + move_line.location_id + ), ) else: return self._response_for_start_line( move_line, - message=message.several_products_in_location(move_line.location_id), + message=self.msg_store.several_products_in_location( + move_line.location_id + ), ) return self._response_for_scan_destination(move_line) - def _response_for_start_line(self, move_line, message=None): - return self._response( - next_state="start_line", - data=self._data_move_line(move_line), - message=message, - ) - - def _response_for_scan_destination(self, move_line, message=None): - data = self._data_move_line(move_line) - last_picked_line = self._last_picked_line(move_line.picking_id) - if last_picked_line: - # suggest pack to be used for the next line - data["package_dest"] = self.data_struct.package( - last_picked_line.result_package_id - ) - return self._response(next_state="scan_destination", data=data, message=message) - def scan_destination_pack(self, move_line_id, barcode, quantity): """Scan the destination package (bin) for a move line @@ -489,11 +535,10 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): have the same destination. * start_line: to pick the next line if any. """ - message = self.actions_for("message") move_line = self.env["stock.move.line"].browse(move_line_id) if not move_line.exists(): - return self._response( - next_state="start", message=message.unrecoverable_error() + return self._response_for_start( + message=self.msg_store.unrecoverable_error() ) rounding = move_line.product_uom_id.rounding compare = float_compare( @@ -521,11 +566,10 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): move_line.product_uom_qty = quantity search = self.actions_for("search") - message = self.actions_for("message") bin_package = search.package_from_scan(barcode) if not bin_package: return self._response_for_scan_destination( - move_line, message=message.bin_not_found_for_barcode(barcode) + move_line, message=self.msg_store.bin_not_found_for_barcode(barcode) ) # the scanned package can contain only move lines of the same picking @@ -553,7 +597,7 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): return self._pick_next_line( batch, - message=message.x_units_put_in_package( + message=self.msg_store.x_units_put_in_package( move_line.qty_done, move_line.product_id, move_line.result_package_id ), ) @@ -579,13 +623,6 @@ def _planned_qty_in_location_is_empty(self, product, location): compare = float_compare(planned, 0, precision_rounding=rounding) return compare <= 0 - def _response_for_zero_check(self, move_line): - data = { - "id": move_line.id, - "location_src": self.data_struct.location(move_line.location_id), - } - return self._response(next_state="zero_check", data=data) - def _are_all_dest_location_same(self, batch): lines_to_unload = self._lines_to_unload(batch) return len(lines_to_unload.mapped("location_dest_id")) == 1 @@ -612,7 +649,7 @@ def prepare_unload(self, picking_batch_id): else: # the lines have different destinations batch.cluster_picking_unload_all = False - return self._response_for_unload_single(batch) + return self._unload_next_package(batch) def _data_for_unload_all(self, batch): lines = self._lines_to_unload(batch) @@ -639,20 +676,6 @@ def _data_for_unload_single(self, batch, package): ) return data - def _response_for_unload_all(self, batch, message=None): - return self._response( - next_state="unload_all", - data=self._data_for_unload_all(batch), - message=message, - ) - - def _response_for_unload_all_need_confirm(self, batch, message=None): - return self._response( - next_state="confirm_unload_all", - data=self._data_for_unload_all(batch), - message=message, - ) - def _filter_for_unload(self, line): return ( line.state == "assigned" @@ -673,15 +696,6 @@ def _next_bin_package_for_unload_single(self, batch): packages = self._bin_packages_to_unload(batch) return fields.first(packages) - def _response_for_unload_single(self, batch): - next_package = self._next_bin_package_for_unload_single(batch) - if not next_package: - return self._unload_end(batch) - return self._response( - next_state="unload_single", - data=self._data_for_unload_single(batch, next_package), - ) - def is_zero(self, move_line_id, zero): """Confirm or not if the source location of a move has zero qty @@ -696,11 +710,10 @@ def is_zero(self, move_line_id, zero): * unload_single: if all lines have a destination package and different destination """ - message = self.actions_for("message") move_line = self.env["stock.move.line"].browse(move_line_id) if not move_line.exists(): - return self._response( - next_state="start", message=message.unrecoverable_error() + return self._response_for_start( + message=self.msg_store.unrecoverable_error() ) if not zero: @@ -714,7 +727,7 @@ def is_zero(self, move_line_id, zero): batch = move_line.picking_id.batch_id return self._pick_next_line( batch, - message=message.x_units_put_in_package( + message=self.msg_store.x_units_put_in_package( move_line.qty_done, move_line.product_id, move_line.result_package_id ), ) @@ -733,9 +746,8 @@ def skip_line(self, move_line_id): """ move_line = self.env["stock.move.line"].browse(move_line_id) if not move_line.exists(): - message = self.actions_for("message") - return self._response( - next_state="start", message=message.unrecoverable_error() + return self._response_for_start( + message=self.msg_store.unrecoverable_error() ) # flag as postponed move_line.shopfloor_postponed = True @@ -812,8 +824,6 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): if not batch.exists(): return self._response_batch_does_not_exist() - message = self.actions_for("message") - # In case /set_destination_all was called and the destinations were # in fact no the same... restart the unloading step over if not self._are_all_dest_location_same(batch): @@ -828,18 +838,18 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): scanned_location = self.actions_for("search").location_from_scan(barcode) if not scanned_location: return self._response_for_unload_all( - batch, message=message.no_location_found() + batch, message=self.msg_store.no_location_found() ) if not scanned_location.is_sublocation_of( picking_type.default_location_dest_id ): return self._response_for_unload_all( - batch, message=message.dest_location_not_allowed() + batch, message=self.msg_store.dest_location_not_allowed() ) if not scanned_location.is_sublocation_of(first_line.location_dest_id): if not confirmation: - return self._response_for_unload_all_need_confirm(batch) + return self._response_for_confirm_unload_all(batch) self._unload_set_destination_on_lines(lines, scanned_location) return self._unload_end(batch) @@ -861,7 +871,9 @@ def _unload_end(self, batch): # do not use the 'done()' method because it does many things we # don't care about batch.state = "done" - return self._response_batch_complete() + return self._response_for_start( + message=self.msg_store.batch_transfer_complete() + ) next_line = self._next_line_for_pick(batch) if next_line: @@ -872,16 +884,9 @@ def _unload_end(self, batch): # produce backorders) batch.mapped("picking_ids").action_done() batch.state = "done" - return self._response_batch_complete() - - def _response_batch_complete(self): - return self._response( - next_state="start", - message={ - "message_type": "success", - "message": _("Batch Transfer complete"), - }, - ) + return self._response_for_start( + message=self.msg_store.batch_transfer_complete() + ) def unload_split(self, picking_batch_id): """Indicates that now the batch must be treated line per line @@ -903,7 +908,7 @@ def unload_split(self, picking_batch_id): batch.cluster_picking_unload_all = False - return self._response_for_unload_single(batch) + return self._unload_next_package(batch) # TODO we shouldn't need this endpoint if we implement the "completion # info" screen as a kind of generic info box instead of a state @@ -937,26 +942,13 @@ def unload_scan_pack(self, picking_batch_id, package_id, barcode): if not package.exists(): return self._unload_next_package(batch) if package.name != barcode: - return self._response( - next_state="unload_single", - data=self._data_for_unload_single(batch, package), + return self._response_for_unload_single( + batch, + package, message={"message_type": "error", "message": _("Wrong bin")}, ) return self._response_for_unload_set_destination(batch, package) - def _response_for_unload_set_destination(self, batch, package, message=None): - return self._response( - next_state="unload_set_destination", - data=self._data_for_unload_single(batch, package), - message=message, - ) - - def _response_for_confirm_unload_set_destination(self, batch, package): - return self._response( - next_state="confirm_unload_set_destination", - data=self._data_for_unload_single(batch, package), - ) - def unload_scan_destination( self, picking_batch_id, package_id, barcode, confirmation=False ): @@ -979,8 +971,6 @@ def unload_scan_destination( to handle the closing of the batch to create backorders. """ - message = self.actions_for("message") - batch = self.env["stock.picking.batch"].browse(picking_batch_id) if not batch.exists(): return self._response_batch_does_not_exist() @@ -1001,13 +991,13 @@ def unload_scan_destination( scanned_location = self.actions_for("search").location_from_scan(barcode) if not scanned_location: return self._response_for_unload_set_destination( - batch, package, message=message.no_location_found() + batch, package, message=self.msg_store.no_location_found() ) if not scanned_location.is_sublocation_of( picking_type.default_location_dest_id ): return self._response_for_unload_set_destination( - batch, package, message=message.dest_location_not_allowed() + batch, package, message=self.msg_store.dest_location_not_allowed() ) if not scanned_location.is_sublocation_of(first_line.location_dest_id): @@ -1021,10 +1011,7 @@ def unload_scan_destination( def _unload_next_package(self, batch): next_package = self._next_bin_package_for_unload_single(batch) if next_package: - return self._response( - next_state="unload_single", - data=self._data_for_unload_single(batch, next_package), - ) + return self._response_for_unload_single(batch, next_package) return self._unload_end(batch) From 63a9bb14096c1c35e33174ba883106fe49f90d12 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 9 Mar 2020 12:49:16 +0100 Subject: [PATCH 200/940] backend: add new 'popup' option in rest messages to handle completion info The 'completion info' screen is a screen which can be used in several workflows and always has: a message about completion of pickings (the next picking in the chain is ready), and an "OK" button. The OK button goes to the next state (beginning of the workflow, next line, ...). One option for this screen would be to have a state on the client and transition like all the other states, but it means we have then to have and endpoint called after the "OK" button to know what is the next endpoint. Since this screen is so simple - a message and an OK button - having a reusable structure for this seems better. We cannot use the existing "message" structure, because an endpoint can return a message + a popup (for instance "X units put in location Y" + completion info popup). The popup goes alongside the next state to reach, so it acts more as a modal than as a screen. --- shopfloor/actions/__init__.py | 2 + shopfloor/actions/completion_info.py | 41 +++++++++++ shopfloor/models/stock_picking_batch.py | 6 -- shopfloor/services/cluster_picking.py | 72 ++++++++----------- shopfloor/services/service.py | 10 ++- shopfloor/services/single_pack_transfer.py | 28 ++++---- shopfloor/services/validator.py | 7 +- shopfloor/tests/common.py | 7 +- .../tests/test_cluster_picking_unload.py | 66 +++++++++++++---- shopfloor/tests/test_single_pack_transfer.py | 19 ++++- 10 files changed, 174 insertions(+), 84 deletions(-) create mode 100644 shopfloor/actions/completion_info.py diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index b98bf56e07..a5971aad7c 100644 --- a/shopfloor/actions/__init__.py +++ b/shopfloor/actions/__init__.py @@ -18,6 +18,8 @@ """ from . import base_action from . import data +from . import completion_info + from . import message from . import pack_transfer_validate from . import search diff --git a/shopfloor/actions/completion_info.py b/shopfloor/actions/completion_info.py new file mode 100644 index 0000000000..16cab4a0cf --- /dev/null +++ b/shopfloor/actions/completion_info.py @@ -0,0 +1,41 @@ +from odoo import _ + +from odoo.addons.component.core import Component + + +class CompletionInfo(Component): + """Provide methods for completion info of pickings + + They are based on the module "stock_picking_completion_info" from + OCA/stock-logistics-warehouse. + """ + + _name = "shopfloor.completion.info.action" + _inherit = "shopfloor.process.action" + _usage = "completion.info" + + def popup(self, move_lines): + """Return a popup if move lines make chained pickings ready + + Return None in case no popup should be displayed. + """ + pickings = move_lines.mapped("picking_id").filtered( + lambda p: p.picking_type_id.display_completion_info + and p.completion_info == "next_picking_ready" + ) + if not pickings: + return None + next_pickings = pickings.mapped("move_lines.move_dest_ids.picking_id").filtered( + lambda p: p.state == "assigned" + ) + if not next_pickings: + return None + return { + "body": _( + "Last operation of transfer {}. " + "Next operation ({}) is ready to proceed." + ).format( + ", ".join(pickings.mapped("name")), + ", ".join(next_pickings.mapped("name")), + ) + } diff --git a/shopfloor/models/stock_picking_batch.py b/shopfloor/models/stock_picking_batch.py index e1f2093b60..aff3ef2ecf 100644 --- a/shopfloor/models/stock_picking_batch.py +++ b/shopfloor/models/stock_picking_batch.py @@ -4,12 +4,6 @@ class StockPickingBatch(models.Model): _inherit = "stock.picking.batch" - cluster_picking_unload_all = fields.Boolean( - default=False, - copy=False, - help="Technical field. Indicates if a batch is destination is" - " asked once for all lines or for every line.", - ) picking_count = fields.Integer( compute="_compute_picking_info", help="Technical field. Indicates number of transfers included.", diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index c08d51abbf..58abeccaee 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -79,8 +79,8 @@ def data_struct(self): def msg_store(self): return self.actions_for("message") - def _response_for_start(self, message=None): - return self._response(next_state="start", message=message,) + def _response_for_start(self, message=None, popup=None): + return self._response(next_state="start", message=message, popup=popup) def _response_for_confirm_start(self, batch): return self._response( @@ -95,11 +95,12 @@ def _response_for_manual_selection(self, batches, message=None): } return self._response(next_state="manual_selection", data=data, message=message) - def _response_for_start_line(self, move_line, message=None): + def _response_for_start_line(self, move_line, message=None, popup=None): return self._response( next_state="start_line", data=self._data_move_line(move_line), message=message, + popup=popup, ) def _response_for_scan_destination(self, move_line, message=None): @@ -133,11 +134,12 @@ def _response_for_confirm_unload_all(self, batch, message=None): message=message, ) - def _response_for_unload_single(self, batch, package, message=None): + def _response_for_unload_single(self, batch, package, message=None, popup=None): return self._response( next_state="unload_single", data=self._data_for_unload_single(batch, package), message=message, + popup=popup, ) def _response_for_unload_set_destination(self, batch, package, message=None): @@ -631,24 +633,20 @@ def prepare_unload(self, picking_batch_id): """Initiate the unloading phase of the scenario If the destination of all the move lines still to unload is the same, - it sets the flag ``cluster_picking_unload_all`` to True on - ``stock.batch.picking``. Everytime this method is called, it resets the flag according to the condition above. Transitions: - * unload_all: when ``cluster_picking_unload_all`` is True - * unload_single: when ``cluster_picking_unload_all`` is False + * unload_all: when all lines go to the same destination + * unload_single: when lines have different destinations """ batch = self.env["stock.picking.batch"].browse(picking_batch_id) if not batch.exists(): return self._response_batch_does_not_exist() if self._are_all_dest_location_same(batch): - batch.cluster_picking_unload_all = True return self._response_for_unload_all(batch) else: # the lines have different destinations - batch.cluster_picking_unload_all = False return self._unload_next_package(batch) def _data_for_unload_all(self, batch): @@ -852,7 +850,9 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): return self._response_for_confirm_unload_all(batch) self._unload_set_destination_on_lines(lines, scanned_location) - return self._unload_end(batch) + completion_info = self.actions_for("completion.info") + completion_info_popup = completion_info.popup(lines) + return self._unload_end(batch, completion_info_popup=completion_info_popup) def _unload_set_destination_on_lines(self, lines, location): lines.write({"shopfloor_unloaded": True, "location_dest_id": location.id}) @@ -866,18 +866,19 @@ def _unload_set_destination_on_lines(self, lines, location): if all(l.shopfloor_unloaded for l in picking_lines): picking.action_done() - def _unload_end(self, batch): + def _unload_end(self, batch, completion_info_popup=None): if all(picking.state == "done" for picking in batch.picking_ids): # do not use the 'done()' method because it does many things we # don't care about batch.state = "done" return self._response_for_start( - message=self.msg_store.batch_transfer_complete() + message=self.msg_store.batch_transfer_complete(), + popup=completion_info_popup, ) next_line = self._next_line_for_pick(batch) if next_line: - return self._response_for_start_line(next_line) + return self._response_for_start_line(next_line, popup=completion_info_popup) else: # TODO add tests for this (for instance a picking is not 'done' # because a move was unassigned, we want to validate the batch to @@ -885,7 +886,8 @@ def _unload_end(self, batch): batch.mapped("picking_ids").action_done() batch.state = "done" return self._response_for_start( - message=self.msg_store.batch_transfer_complete() + message=self.msg_store.batch_transfer_complete(), + popup=completion_info_popup, ) def unload_split(self, picking_batch_id): @@ -893,9 +895,6 @@ def unload_split(self, picking_batch_id): Even if the move lines to unload all have the same destination. - It sets the flag ``stock_picking_batch.cluster_picking_unload_all`` to - False. - Note: if we go back to the first phase of picking and start a new phase of unloading, the flag is reevaluated to the initial condition. @@ -906,25 +905,8 @@ def unload_split(self, picking_batch_id): if not batch.exists(): return self._response_batch_does_not_exist() - batch.cluster_picking_unload_all = False - return self._unload_next_package(batch) - # TODO we shouldn't need this endpoint if we implement the "completion - # info" screen as a kind of generic info box instead of a state - def unload_router(self, picking_batch_id): - """Called after the info screen, route to the next state - - No side effect in Odoo. - - Transitions: - * unload_single: if the batch still has packs to unload - * start_line: if the batch still has lines to pick - * start: if the batch is done. In this case, this method *has* - to handle the closing of the batch to create backorders. - """ - return self._response() - def unload_scan_pack(self, picking_batch_id, package_id, barcode): """Check that the operator scans the correct package (bin) on unload @@ -963,9 +945,6 @@ def unload_scan_destination( * confirm_unload_set_destination: the destination is valid but not the expected, ask a confirmation. This state has to call again the endpoint with confirmation=True - * show_completion_info: the completion info of the picking is - "next_picking_ready", it will show an info box to the user, the js - client should then call /unload_router to know the next state * start_line: if the batch still has lines to pick * start: if the batch is done. In this case, this method *has* to handle the closing of the batch to create backorders. @@ -1006,13 +985,20 @@ def unload_scan_destination( self._unload_set_destination_on_lines(lines, scanned_location) - return self._unload_next_package(batch) + completion_info = self.actions_for("completion.info") + completion_info_popup = completion_info.popup(lines) - def _unload_next_package(self, batch): + return self._unload_next_package( + batch, completion_info_popup=completion_info_popup + ) + + def _unload_next_package(self, batch, completion_info_popup=None): next_package = self._next_bin_package_for_unload_single(batch) if next_package: - return self._response_for_unload_single(batch, next_package) - return self._unload_end(batch) + return self._response_for_unload_single( + batch, next_package, popup=completion_info_popup + ) + return self._unload_end(batch, completion_info_popup=completion_info_popup) class ShopfloorClusterPickingValidator(Component): @@ -1137,7 +1123,6 @@ def _states(self): "unload_single": self._schema_for_unload_single, "unload_set_destination": self._schema_for_unload_single, "confirm_unload_set_destination": self._schema_for_unload_single, - "show_completion_info": self._schema_for_completion_info, } def find_batch(self): @@ -1276,7 +1261,6 @@ def unload_scan_destination(self): "unload_single", "unload_set_destination", "confirm_unload_set_destination", - "show_completion_info", "start", "start_line", } diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index ba4b867ddb..b1c8d4f512 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -78,7 +78,9 @@ def _get_output_validator(self, method_name): ) return validator_component._get_validator(method_name) - def _response(self, base_response=None, data=None, next_state=None, message=None): + def _response( + self, base_response=None, data=None, next_state=None, message=None, popup=None + ): """Base "envelope" for the responses All the keys are optional. @@ -92,6 +94,9 @@ def _response(self, base_response=None, data=None, next_state=None, message=None application must reach :param message: dictionary for the message to show in the client application (see ``_response_schema`` for the keys) + :param popup: dictionary for a popup to show in the client application + (see ``_response_schema`` for the keys). The popup is displayed before + reaching the next state. """ if base_response: response = base_response.copy() @@ -118,6 +123,9 @@ def _response(self, base_response=None, data=None, next_state=None, message=None if message: response["message"] = message + if popup: + response["popup"] = popup + return response def _get_openapi_default_parameters(self): diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 2deccf5fc9..a226995420 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -1,8 +1,6 @@ from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component -# TODO add completion info screen - class SinglePackTransfer(Component): """Methods for the Single Pack Transfer Process""" @@ -151,13 +149,12 @@ def _response_for_location_need_confirm(self, move_line, pack, to_location): data=self._data_after_package_scanned(move_line, pack), ) - def _response_for_validate_success(self, last=False): + def _response_for_validate_success(self, completion_info_popup=None): message = self.actions_for("message") - next_state = "start" - if last: - next_state = "show_completion_info" return self._response( - next_state=next_state, message=message.confirm_pack_moved() + next_state="start", + message=message.confirm_pack_moved(), + popup=completion_info_popup, ) def validate(self, package_level_id, location_barcode, confirmation=False): @@ -193,8 +190,13 @@ def validate(self, package_level_id, location_barcode, confirmation=False): ) pack_transfer.set_destination_and_done(move, scanned_location) - last = move.picking_id.completion_info == "next_picking_ready" - return self._response_for_validate_success(last=last) + + completion_info = self.actions_for("completion.info") + completion_info_popup = completion_info.popup(package.move_line_ids) + + return self._response_for_validate_success( + completion_info_popup=completion_info_popup + ) def cancel(self, package_level_id): package = self.env["stock.package_level"].browse(package_level_id) @@ -260,7 +262,6 @@ def _states(self): "confirm_start": self._schema_for_location, "scan_location": self._schema_for_location, "confirm_location": self._schema_for_location, - "show_completion_info": {}, } def start(self): @@ -271,12 +272,7 @@ def cancel(self): def validate(self): return self._response_schema( - next_states={ - "scan_location", - "start", - "confirm_location", - "show_completion_info", - } + next_states={"scan_location", "start", "confirm_location"} ) @property diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py index db6d3bb3df..353cfa0b10 100644 --- a/shopfloor/services/validator.py +++ b/shopfloor/services/validator.py @@ -96,7 +96,12 @@ def _response_schema(self, data_schema=None, next_states=None): }, "message": {"type": "string", "required": True}, }, - } + }, + "popup": { + "type": "dict", + "required": False, + "schema": {"body": {"type": "string", "required": True}}, + }, } if not data_schema: data_schema = {} diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 3342045245..e1483551ef 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -70,6 +70,7 @@ def setUpClass(cls): def setUpClassVars(cls): stock_location = cls.env.ref("stock.stock_location_stock") cls.stock_location = stock_location + cls.customer_location = cls.env.ref("stock.stock_location_customers") cls.dispatch_location = cls.env.ref("stock.location_dispatch_zone") cls.dispatch_location.barcode = "DISPATCH" cls.packing_location = cls.env.ref("stock.location_pack_zone") @@ -129,7 +130,9 @@ def setUpClassBaseData(cls): {"name": "Box", "product_id": cls.product_d.id, "barcode": "ProductDBox"} ) - def assert_response(self, response, next_state=None, message=None, data=None): + def assert_response( + self, response, next_state=None, message=None, data=None, popup=None + ): """Assert a response from the webservice The data and message dictionaries can use ``self.ANY`` to accept any @@ -138,6 +141,8 @@ def assert_response(self, response, next_state=None, message=None, data=None): expected = {} if message: expected["message"] = message + if popup: + expected["popup"] = popup if next_state: expected.update( {"next_state": next_state, "data": {next_state: data or {}}} diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index 34de5253ad..337be573ae 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -5,6 +5,11 @@ class ClusterPickingUnloadingCommonCase(ClusterPickingCommonCase): @classmethod def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) + + # activate the computation of this field, so we have a chance to + # transition to the 'show completion info' popup. + cls.picking_type.display_completion_info = True + cls.batch = cls._create_picking_batch( [ [ @@ -56,7 +61,6 @@ def test_prepare_unload_all_same_dest(self): response = self.service.dispatch( "prepare_unload", params={"picking_batch_id": self.batch.id} ) - self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": True}]) self.assert_response( response, next_state="unload_all", @@ -80,7 +84,6 @@ def test_prepare_unload_different_dest(self): response = self.service.dispatch( "prepare_unload", params={"picking_batch_id": self.batch.id} ) - self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}]) first_line = move_lines[0] self.assert_response( response, @@ -105,13 +108,6 @@ class ClusterPickingSetDestinationAllCase(ClusterPickingUnloadingCommonCase): available line of a picking is unloaded, the picking is set to 'done'. """ - @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - # this is what the /prepare_endpoint method would have set as all the - # destinations are the same: - cls.batch.cluster_picking_unload_all = True - def test_set_destination_all_ok(self): """Set destination on all lines for the full batch and end the process""" move_lines = self.batch.mapped("picking_ids.move_line_ids") @@ -241,7 +237,6 @@ def test_set_destination_all_but_different_dest(self): "barcode": self.packing_location.barcode, }, ) - self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}]) self.assert_response( response, next_state="unload_single", @@ -383,7 +378,6 @@ def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) # this is what the /prepare_endpoint method would have set as all the # destinations are the same: - cls.batch.cluster_picking_unload_all = True def test_unload_split_ok(self): """Call /unload_split and continue to unload single""" @@ -397,7 +391,6 @@ def test_unload_split_ok(self): response = self.service.dispatch( "unload_split", params={"picking_batch_id": self.batch.id} ) - self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}]) self.assert_response( # the remaining move line still needs to be picked response, @@ -427,7 +420,6 @@ class ClusterPickingUnloadScanPackCase(ClusterPickingUnloadingCommonCase): @classmethod def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) - cls.batch.cluster_picking_unload_all = False cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids") cls._set_dest_package_and_done(cls.move_lines, cls.bin1) cls.move_lines[:2].write({"location_dest_id": cls.packing_a_location.id}) @@ -496,7 +488,6 @@ class ClusterPickingUnloadScanDestinationCase(ClusterPickingUnloadingCommonCase) @classmethod def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) - cls.batch.cluster_picking_unload_all = False cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids") cls.bin1_lines = cls.move_lines[:1] cls.bin2_lines = cls.move_lines[1:] @@ -798,3 +789,50 @@ def test_unload_scan_destination_with_confirmation(self): ], ) self.assert_response(response, next_state="unload_single", data=self.ANY) + + def test_unload_scan_destination_completion_info(self): + """/unload_scan_destination that make chained picking ready""" + picking = self.one_line_picking + dest_location = picking.move_line_ids.location_dest_id + self.picking_type.display_completion_info = True + + # create a chained picking after the current one + next_picking = picking.copy( + { + "picking_type_id": self.wh.out_type_id.id, + "location_id": picking.location_dest_id.id, + "location_dest_id": self.customer_location.id, + } + ) + next_picking.move_lines.write( + {"move_orig_ids": [(6, 0, picking.move_lines.ids)]} + ) + next_picking.action_confirm() + + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": dest_location.barcode, + }, + ) + + self.assert_response( + response, + next_state="unload_single", + popup={ + "body": "Last operation of transfer {}. Next operation " + "({}) is ready to proceed.".format(picking.name, next_picking.name) + }, + data={ + "id": self.batch.id, + "name": self.batch.name, + # the line of bin1 is unloaded, next one will be bin2 + "package": {"id": self.bin2.id, "name": self.bin2.name}, + "location_dest": { + "id": self.bin2_lines[0].location_dest_id.id, + "name": self.bin2_lines[0].location_dest_id.name, + }, + }, + ) diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index 7cb8ff577d..88c78b4c45 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -396,6 +396,19 @@ def test_validate_completion_info(self): # transition to the 'show completion info' screen. self.picking_type.display_completion_info = True + # create a chained picking after the current one + next_picking = self.picking.copy( + { + "picking_type_id": self.wh.out_type_id.id, + "location_id": self.picking.location_dest_id.id, + "location_dest_id": self.customer_location.id, + } + ) + next_picking.move_lines.write( + {"move_orig_ids": [(6, 0, self.picking.move_lines.ids)]} + ) + next_picking.action_confirm() + # now, call the service to proceed with validation of the # movement response = self.service.dispatch( @@ -408,7 +421,11 @@ def test_validate_completion_info(self): self.assert_response( response, - next_state="show_completion_info", + next_state="start", + popup={ + "body": "Last operation of transfer {}. Next operation " + "({}) is ready to proceed.".format(self.picking.name, next_picking.name) + }, message={ "message_type": "success", "message": "The pack has been moved, you can scan a new pack.", From 57e18361cce9893dbb180932e2c24262c1811b48 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 9 Mar 2020 14:20:24 +0100 Subject: [PATCH 201/940] Remove dead code --- shopfloor/services/cluster_picking.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 58abeccaee..94cab7bbee 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -350,10 +350,6 @@ def _next_line_for_pick(self, picking_batch): def _response_batch_does_not_exist(self): return self._response_for_start(message=self.msg_store.record_not_found()) - def _data_for_next_move_line(self, picking_batch): - remaining_lines = self._lines_to_pick(picking_batch) - return self._data_move_line(remaining_lines[0]) - def _data_move_line(self, line): picking = line.picking_id batch = picking.batch_id From d6e09a1fb0e17dbc3d2690dddafe15ed614be74b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 7 May 2020 09:59:05 +0200 Subject: [PATCH 202/940] pack transfer: use _response_for_ pattern As done in newer scenarii, use one _response_for_* method per state. --- shopfloor/actions/pack_transfer_validate.py | 1 + shopfloor/services/single_pack_putaway.py | 3 + shopfloor/services/single_pack_transfer.py | 208 +++++++++----------- 3 files changed, 100 insertions(+), 112 deletions(-) diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py index 7de53656cd..7b80f0aae9 100644 --- a/shopfloor/actions/pack_transfer_validate.py +++ b/shopfloor/actions/pack_transfer_validate.py @@ -1,6 +1,7 @@ from odoo.addons.component.core import Component +# TODO remove # TODO think BETTER about how we want to share the common methods / workflows class PackTransferValidateAction(Component): """Pack Transfer shared business logic diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 9b312ea2ee..0cd8c7747b 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -3,6 +3,9 @@ from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component +# NOTE a lot of code is duplicated with SinglePackTransfer, but +# this service will be replaced + class SinglePackPutaway(Component): """Methods for the Single Pack Put-Away Process""" diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index a226995420..8c029fc589 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -10,38 +10,9 @@ class SinglePackTransfer(Component): _usage = "single_pack_transfer" _description = __doc__ - # TODO get rid of these methods now that we have a component - # for the messages? could help for extensibility though...? - def _response_for_empty_location(self, location): - message = self.actions_for("message") - return self._response( - next_state="start", message=message.no_pack_in_location(location) - ) - - def _response_for_several_packages(self, location): - message = self.actions_for("message") - return self._response( - next_state="start", message=message.several_packs_in_location(location) - ) - - def _response_for_package_not_found(self, barcode): - message = self.actions_for("message") - return self._response( - next_state="start", message=message.package_not_found_for_barcode(barcode) - ) - - def _response_for_forbidden_package(self, barcode, picking_types): - message = self.actions_for("message") - return self._response( - next_state="start", - message=message.package_not_allowed_in_src_location(barcode, picking_types), - ) - - def _response_for_operation_not_found(self, pack): - message = self.actions_for("message") - return self._response( - next_state="start", message=message.no_pending_operation_for_pack(pack) - ) + @property + def msg_store(self): + return self.actions_for("message") def _data_after_package_scanned(self, move_line, pack): move = move_line.move_id @@ -57,20 +28,33 @@ def _data_after_package_scanned(self, move_line, pack): "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, } - def _response_for_start_to_confirm(self, move_line, pack): - message = self.actions_for("message") + def _response_for_start(self, message=None, popup=None): + return self._response(next_state="start", message=message, popup=popup) + + def _response_for_confirm_start(self, move_line, pack): return self._response( next_state="confirm_start", - message=message.already_running_ask_confirmation(), + message=self.msg_store.already_running_ask_confirmation(), data=self._data_after_package_scanned(move_line, pack), ) - def _response_for_start_success(self, move_line, pack): + def _response_for_scan_location(self, move_line, pack, message=None): return self._response( next_state="scan_location", data=self._data_after_package_scanned(move_line, pack), ) + def _response_for_confirm_location(self, move_line, pack, message=None): + message = self.actions_for("message") + return self._response( + next_state="confirm_location", + data=self._data_after_package_scanned(move_line, pack), + message=message, + ) + + def _response_for_show_completion_info(self, message=None): + return self._response(next_state="show_completion_info", message=message) + def start(self, barcode): search = self.actions_for("search") picking_types = self.picking_types @@ -82,20 +66,30 @@ def start(self, barcode): [("location_id", "=", location.id)] ) if not pack: - return self._response_for_empty_location(location) + return self._response_for_start( + message=self.msg_store.no_pack_in_location(location) + ) if len(pack) > 1: - return self._response_for_several_packages(location) + return self._response_for_start( + message=self.msg_store.several_packs_in_location(location) + ) if not pack: pack = search.package_from_scan(barcode) if not pack: - return self._response_for_package_not_found(barcode) + return self._response_for_start( + self.msg_store.package_not_found_for_barcode(barcode) + ) if not pack.location_id.is_sublocation_of( picking_types.mapped("default_location_src_id") ): - return self._response_for_forbidden_package(barcode, picking_types) + return self._response_for_start( + message=self.msg_store.package_not_allowed_in_src_location( + barcode, picking_types + ) + ) existing_operations = self.env["stock.move.line"].search( [ @@ -105,119 +99,109 @@ def start(self, barcode): ] ) if not existing_operations: - return self._response_for_operation_not_found(pack) + return self._response_for_start( + message=self.msg_store.no_pending_operation_for_pack(pack) + ) # TODO can we have more than one move line? if existing_operations[0].package_level_id.is_done: - return self._response_for_start_to_confirm(existing_operations, pack) + return self._response_for_confirm_start(existing_operations, pack) existing_operations[0].package_level_id.is_done = True - return self._response_for_start_success(existing_operations[0], pack) + return self._response_for_scan_location(existing_operations[0], pack) - def _response_for_package_level_not_found(self): - message = self.actions_for("message") - return self._response(next_state="start", message=message.operation_not_found()) + def _is_move_state_valid(self, move): + return move.state != "cancel" - def _response_for_move_canceled_elsewhere(self): - message = self.actions_for("message") - return self._response( - next_state="start", message=message.operation_has_been_canceled_elsewhere() - ) - - def _response_for_location_not_found(self, move_line, pack): - message = self.actions_for("message") - return self._response( - next_state="scan_location", - message=message.no_location_found(), - data=self._data_after_package_scanned(move_line, pack), - ) - - def _response_for_forbidden_location(self, move_line, pack): - message = self.actions_for("message") - return self._response( - next_state="scan_location", - message=message.dest_location_not_allowed(), - data=self._data_after_package_scanned(move_line, pack), - ) - - def _response_for_location_need_confirm(self, move_line, pack, to_location): - message = self.actions_for("message") - return self._response( - next_state="confirm_location", - message=message.confirm_location_changed( - move_line.location_dest_id, to_location - ), - data=self._data_after_package_scanned(move_line, pack), + def _is_dest_location_valid(self, move, scanned_location): + """Forbid a dest location to be used""" + return scanned_location.is_sublocation_of( + move.picking_id.picking_type_id.default_location_dest_id ) - def _response_for_validate_success(self, completion_info_popup=None): - message = self.actions_for("message") - return self._response( - next_state="start", - message=message.confirm_pack_moved(), - popup=completion_info_popup, - ) + def _is_dest_location_to_confirm(self, move, scanned_location): + """Destination that could be used but need confirmation""" + move_dest_location = move.move_line_ids[0].location_dest_id + return not scanned_location.is_sublocation_of(move_dest_location) def validate(self, package_level_id, location_barcode, confirmation=False): """Validate the transfer""" - # TODO this method is duplicated in putaway - pack_transfer = self.actions_for("pack.transfer.validate") search = self.actions_for("search") + message = self.actions_for("message") package = self.env["stock.package_level"].browse(package_level_id) if not package.exists(): - return self._response_for_package_level_not_found() + return self._response_for_start(message=message.operation_not_found()) move_line = package.move_line_ids[0] move = move_line.move_id - if not pack_transfer.is_move_state_valid(move): - return self._response_for_move_canceled_elsewhere() + if not self._is_move_state_valid(move): + return self._response_for_start( + message=self.msg_store.operation_has_been_canceled_elsewhere() + ) scanned_location = search.location_from_scan(location_barcode) if not scanned_location: - return self._response_for_location_not_found(move_line, package.package_id) - if not pack_transfer.is_dest_location_valid(move, scanned_location): - return self._response_for_forbidden_location(move_line, package.package_id) + return self._response_for_scan_location( + move_line, + package.package_id, + message=self.msg_store.no_location_found(), + ) + if not self._is_dest_location_valid(move, scanned_location): + return self._response_for_scan_location( + move_line, + package.package_id, + message=self.msg_store.dest_location_not_allowed(), + ) - if pack_transfer.is_dest_location_to_confirm(move, scanned_location): + if self._is_dest_location_to_confirm(move, scanned_location): if confirmation: # If the destination of the move would be incoherent # (move line outside of it), we change the moves' destination if not scanned_location.is_sublocation_of(move.location_dest_id): move.location_dest_id = scanned_location.id else: - return self._response_for_location_need_confirm( - move_line, package.package_id, scanned_location + return self._response_for_confirm_location( + move_line, + package.package_id, + message=self.msg_store.confirm_location_changed( + move_line.location_dest_id, scanned_location + ), ) - pack_transfer.set_destination_and_done(move, scanned_location) + self._set_destination_and_done(move, scanned_location) + return self._router_validate_success(package) - completion_info = self.actions_for("completion.info") - completion_info_popup = completion_info.popup(package.move_line_ids) + def _is_last_move(self, move): + return move.picking_id.completion_info == "next_picking_ready" - return self._response_for_validate_success( - completion_info_popup=completion_info_popup - ) + def _router_validate_success(self, package_level): + move = package_level.move_line_ids.move_id + + message = self.msg_store.confirm_pack_moved() + + completion_info_popup = None + if self._is_last_move(move): + completion_info = self.actions_for("completion.info") + completion_info_popup = completion_info.popup(package_level.move_line_ids) + return self._response_for_start(message=message, popup=completion_info_popup) + + def _set_destination_and_done(self, move, scanned_location): + move.move_line_ids[0].location_dest_id = scanned_location.id + move._action_done() def cancel(self, package_level_id): + message = self.actions_for("message") package = self.env["stock.package_level"].browse(package_level_id) if not package.exists(): - return self._response_for_package_level_not_found() + return self._response_for_start(message=message.operation_not_found()) # package.move_ids may be empty, it seems move = package.move_line_ids.move_id if move.state == "done": - return self._response_for_move_already_processed() + return self._response_for_start(message=self.msg_store.already_done()) package.is_done = False - return self._response_for_confirm_cancel() - - def _response_for_move_already_processed(self): - message = self.actions_for("message") - return self._response(next_state="start", message=message.already_done()) - - def _response_for_confirm_cancel(self): - message = self.actions_for("message") - return self._response( - next_state="start", message=message.confirm_canceled_scan_next_pack() + return self._response_for_start( + message=self.msg_store.confirm_canceled_scan_next_pack() ) From 2201b67643ac5b38691a896594221c2beadd88d9 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 7 May 2020 15:20:39 +0200 Subject: [PATCH 203/940] pack transfer: refactor, use generic data structures * use the generic data structures and schema * review the method parameters, variable names --- shopfloor/services/schema.py | 10 ++ shopfloor/services/single_pack_transfer.py | 154 ++++++++----------- shopfloor/tests/test_single_pack_transfer.py | 53 ++++--- 3 files changed, 99 insertions(+), 118 deletions(-) diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 2335aad540..762aa6d4c8 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -118,3 +118,13 @@ def picking_batch(self): "move_line_count": {"required": True, "type": "integer"}, "weight": {"required": True, "nullable": True, "type": "float"}, } + + def package_level(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "location_src": {"type": "dict", "schema": self.location()}, + "location_dest": {"type": "dict", "schema": self.location()}, + "product": {"type": "dict", "schema": self.product()}, + "picking": {"type": "dict", "schema": self.picking()}, + } diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 8c029fc589..196298a846 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -14,41 +14,43 @@ class SinglePackTransfer(Component): def msg_store(self): return self.actions_for("message") - def _data_after_package_scanned(self, move_line, pack): - move = move_line.move_id + @property + def data_struct(self): + return self.actions_for("data") + + def _data_after_package_scanned(self, package_level): + move_line = package_level.move_line_ids[0] + package = package_level.package_id return { - "id": move_line.package_level_id.id, - "name": pack.name, - "location_src": {"id": pack.location_id.id, "name": pack.location_id.name}, - "location_dest": { - "id": move_line.location_dest_id.id, - "name": move_line.location_dest_id.name, - }, - "product": {"id": move.product_id.id, "name": move.product_id.name}, - "picking": {"id": move.picking_id.id, "name": move.picking_id.name}, + "id": package_level.id, + "name": package.name, + "location_src": self.data_struct.location(move_line.location_id), + "location_dest": self.data_struct.location(package_level.location_dest_id), + "product": self.data_struct.product(move_line.product_id), + "picking": self.data_struct.picking(move_line.picking_id), } def _response_for_start(self, message=None, popup=None): return self._response(next_state="start", message=message, popup=popup) - def _response_for_confirm_start(self, move_line, pack): + def _response_for_confirm_start(self, package_level, message=None): return self._response( next_state="confirm_start", - message=self.msg_store.already_running_ask_confirmation(), - data=self._data_after_package_scanned(move_line, pack), + data=self._data_after_package_scanned(package_level), + message=message, ) - def _response_for_scan_location(self, move_line, pack, message=None): + def _response_for_scan_location(self, package_level, message=None): return self._response( next_state="scan_location", - data=self._data_after_package_scanned(move_line, pack), + data=self._data_after_package_scanned(package_level), + message=message, ) - def _response_for_confirm_location(self, move_line, pack, message=None): - message = self.actions_for("message") + def _response_for_confirm_location(self, package_level, message=None): return self._response( next_state="confirm_location", - data=self._data_after_package_scanned(move_line, pack), + data=self._data_after_package_scanned(package_level), message=message, ) @@ -60,29 +62,29 @@ def start(self, barcode): picking_types = self.picking_types location = search.location_from_scan(barcode) - pack = self.env["stock.quant.package"] + package = self.env["stock.quant.package"] if location: - pack = self.env["stock.quant.package"].search( + package = self.env["stock.quant.package"].search( [("location_id", "=", location.id)] ) - if not pack: + if not package: return self._response_for_start( message=self.msg_store.no_pack_in_location(location) ) - if len(pack) > 1: + if len(package) > 1: return self._response_for_start( message=self.msg_store.several_packs_in_location(location) ) - if not pack: - pack = search.package_from_scan(barcode) + if not package: + package = search.package_from_scan(barcode) - if not pack: + if not package: return self._response_for_start( self.msg_store.package_not_found_for_barcode(barcode) ) - if not pack.location_id.is_sublocation_of( + if not package.location_id.is_sublocation_of( picking_types.mapped("default_location_src_id") ): return self._response_for_start( @@ -91,23 +93,25 @@ def start(self, barcode): ) ) - existing_operations = self.env["stock.move.line"].search( + move_lines = self.env["stock.move.line"].search( [ - ("package_id", "=", pack.id), - ("state", "!=", "done"), + ("package_id", "=", package.id), + ("state", "not in", ("cancel", "done")), ("picking_id.picking_type_id", "in", picking_types.ids), ] ) - if not existing_operations: + if not move_lines: return self._response_for_start( - message=self.msg_store.no_pending_operation_for_pack(pack) + message=self.msg_store.no_pending_operation_for_pack(package) + ) + if move_lines[0].package_level_id.is_done: + return self._response_for_confirm_start( + move_lines[0].package_level_id, + message=self.msg_store.already_running_ask_confirmation(), ) - # TODO can we have more than one move line? - if existing_operations[0].package_level_id.is_done: - return self._response_for_confirm_start(existing_operations, pack) - existing_operations[0].package_level_id.is_done = True - return self._response_for_scan_location(existing_operations[0], pack) + move_lines[0].package_level_id.is_done = True + return self._response_for_scan_location(move_lines[0].package_level_id) def _is_move_state_valid(self, move): return move.state != "cancel" @@ -126,13 +130,16 @@ def _is_dest_location_to_confirm(self, move, scanned_location): def validate(self, package_level_id, location_barcode, confirmation=False): """Validate the transfer""" search = self.actions_for("search") - message = self.actions_for("message") - package = self.env["stock.package_level"].browse(package_level_id) - if not package.exists(): - return self._response_for_start(message=message.operation_not_found()) + package_level = self.env["stock.package_level"].browse(package_level_id) + if not package_level.exists(): + return self._response_for_start( + message=self.msg_store.operation_not_found() + ) - move_line = package.move_line_ids[0] + # if we have more than one move, we should assume they go to the same + # place + move_line = package_level.move_line_ids[0] move = move_line.move_id if not self._is_move_state_valid(move): return self._response_for_start( @@ -142,15 +149,12 @@ def validate(self, package_level_id, location_barcode, confirmation=False): scanned_location = search.location_from_scan(location_barcode) if not scanned_location: return self._response_for_scan_location( - move_line, - package.package_id, - message=self.msg_store.no_location_found(), + package_level, message=self.msg_store.no_location_found() ) + if not self._is_dest_location_valid(move, scanned_location): return self._response_for_scan_location( - move_line, - package.package_id, - message=self.msg_store.dest_location_not_allowed(), + package_level, message=self.msg_store.dest_location_not_allowed() ) if self._is_dest_location_to_confirm(move, scanned_location): @@ -161,15 +165,14 @@ def validate(self, package_level_id, location_barcode, confirmation=False): move.location_dest_id = scanned_location.id else: return self._response_for_confirm_location( - move_line, - package.package_id, + package_level, message=self.msg_store.confirm_location_changed( move_line.location_dest_id, scanned_location ), ) self._set_destination_and_done(move, scanned_location) - return self._router_validate_success(package) + return self._router_validate_success(package_level) def _is_last_move(self, move): return move.picking_id.completion_info == "next_picking_ready" @@ -191,15 +194,15 @@ def _set_destination_and_done(self, move, scanned_location): def cancel(self, package_level_id): message = self.actions_for("message") - package = self.env["stock.package_level"].browse(package_level_id) - if not package.exists(): + package_level = self.env["stock.package_level"].browse(package_level_id) + if not package_level.exists(): return self._response_for_start(message=message.operation_not_found()) # package.move_ids may be empty, it seems - move = package.move_line_ids.move_id + move = package_level.move_line_ids.move_id if move.state == "done": return self._response_for_start(message=self.msg_store.already_done()) - package.is_done = False + package_level.is_done = False return self._response_for_start( message=self.msg_store.confirm_canceled_scan_next_pack() ) @@ -243,9 +246,9 @@ def _states(self): """ return { "start": {}, - "confirm_start": self._schema_for_location, - "scan_location": self._schema_for_location, - "confirm_location": self._schema_for_location, + "confirm_start": self._schema_for_package_level_details, + "scan_location": self._schema_for_package_level_details, + "confirm_location": self._schema_for_package_level_details, } def start(self): @@ -260,36 +263,5 @@ def validate(self): ) @property - def _schema_for_location(self): - return { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "location_src": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - "location_dest": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - "product": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - "picking": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - } + def _schema_for_package_level_details(self): + return self.schemas().package_level() diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index 88c78b4c45..348baf34ae 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -7,9 +7,6 @@ class SinglePackTransferCase(CommonCase): @classmethod def setUpClass(cls, *args, **kwargs): super().setUpClass(*args, **kwargs) - cls.product_a = cls.env["product.product"].create( - {"name": "Product A", "type": "product"} - ) cls.pack_a = cls.env["stock.quant.package"].create( {"location_id": cls.stock_location.id} ) @@ -60,6 +57,29 @@ def _simulate_started(self): package_level.is_done = True return package_level + def _response_package_level_data(self, package_level): + return { + "id": package_level.id, + "name": package_level.package_id.name, + "location_src": {"id": self.shelf1.id, "name": self.shelf1.name}, + "location_dest": {"id": self.shelf2.id, "name": self.shelf2.name}, + "picking": { + "id": self.picking.id, + "name": self.picking.name, + "note": None, + "origin": None, + "partner": None, + "move_line_count": len(self.picking.move_line_ids), + "weight": 2.0, + }, + "product": { + "id": self.product_a.id, + "name": self.product_a.name, + "default_code": self.product_a.default_code, + "display_name": self.product_a.display_name, + }, + } + def test_start(self): """Test the happy path for single pack transfer /start endpoint @@ -92,14 +112,7 @@ def test_start(self): self.assert_response( response, next_state="scan_location", - data={ - "id": self.ANY, - "name": package_level.package_id.name, - "location_src": {"id": self.shelf1.id, "name": self.shelf1.name}, - "location_dest": {"id": self.shelf2.id, "name": self.shelf2.name}, - "picking": {"id": self.picking.id, "name": self.picking.name}, - "product": {"id": self.product_a.id, "name": self.product_a.name}, - }, + data=self._response_package_level_data(package_level), ) def test_start_no_operation(self): @@ -315,14 +328,7 @@ def test_start_already_started(self): "message": "Operation's already running." " Would you like to take it over?", }, - data={ - "id": self.ANY, - "name": package_level.package_id.name, - "location_src": {"id": self.shelf1.id, "name": self.shelf1.name}, - "location_dest": {"id": self.shelf2.id, "name": self.shelf2.name}, - "picking": {"id": self.picking.id, "name": self.picking.name}, - "product": {"id": self.product_a.id, "name": self.product_a.name}, - }, + data=self._response_package_level_data(package_level), ) def test_validate(self): @@ -556,14 +562,7 @@ def test_validate_location_to_confirm(self): response, next_state="confirm_location", message=message, - data={ - "id": self.ANY, - "name": package_level.package_id.name, - "location_src": {"id": self.shelf1.id, "name": self.shelf1.name}, - "location_dest": {"id": self.shelf2.id, "name": self.shelf2.name}, - "picking": {"id": self.picking.id, "name": self.picking.name}, - "product": {"id": self.product_a.id, "name": self.product_a.name}, - }, + data=self._response_package_level_data(package_level), ) def test_validate_location_with_confirm(self): From f864cac6ba615279fd674ee49f90fe8dfff7eb6b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 7 May 2020 18:09:21 +0200 Subject: [PATCH 204/940] backend: add scan_anything endpoint --- shopfloor/actions/search.py | 2 +- shopfloor/services/__init__.py | 1 + shopfloor/services/checkout.py | 2 +- shopfloor/services/scan_anything.py | 115 ++++++++++++++++++++++++++ shopfloor/services/schema.py | 2 +- shopfloor/tests/test_actions_data.py | 4 +- shopfloor/tests/test_scan_anything.py | 74 +++++++++++++++++ 7 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 shopfloor/services/scan_anything.py create mode 100644 shopfloor/tests/test_scan_anything.py diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py index a547abbbc6..9b2beca215 100644 --- a/shopfloor/actions/search.py +++ b/shopfloor/actions/search.py @@ -18,7 +18,7 @@ def location_from_scan(self, barcode): def package_from_scan(self, barcode): return self.env["stock.quant.package"].search([("name", "=", barcode)]) - def stock_picking_from_scan(self, barcode): + def picking_from_scan(self, barcode): return self.env["stock.picking"].search([("name", "=", barcode)]) def product_from_scan(self, barcode): diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 18e7b56db6..7dee66d21c 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -7,6 +7,7 @@ from . import app from . import menu from . import profile +from . import scan_anything # process services from . import checkout diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index ba3c0f4225..cc0b18363e 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -58,7 +58,7 @@ def scan_document(self, barcode): """ search = self.actions_for("search") message = self.actions_for("message") - picking = search.stock_picking_from_scan(barcode) + picking = search.picking_from_scan(barcode) if not picking: location = search.location_from_scan(barcode) if location: diff --git a/shopfloor/services/scan_anything.py b/shopfloor/services/scan_anything.py new file mode 100644 index 0000000000..410445fdb3 --- /dev/null +++ b/shopfloor/services/scan_anything.py @@ -0,0 +1,115 @@ +from odoo import _ + +from odoo.addons.component.core import Component + + +class ShopfloorScanAnything(Component): + """Endpoints to scan any record. + + Supported types of records (models): + + * location (stock.location) + * package (stock.quant.package) + * product (product.product) + * lot (stock.production.lot) + * transfer (stock.picking) + + NOTE for swagger docs: using `anyof_schema` for `record` response key + does not work in swagger UI. Hence, you won't see any detail. + + Issue: https://github.com/swagger-api/swagger-ui/issues/3803 + PR: https://github.com/swagger-api/swagger-ui/pull/5530 + """ + + _inherit = "base.shopfloor.service" + _name = "shopfloor.scan.anything" + _usage = "scan_anything" + _description = __doc__ + + def scan(self, identifier): + # TODO: shall we add constrains by profile etc? + data = {} + tried = [] + record = None + for rec_type, finder, converter, __ in self._scan_handlers(): + tried.append(rec_type) + record = finder(identifier) + if record: + data.update( + { + "identifier": identifier, + "record": converter(record), + "type": rec_type, + } + ) + break + if not record: + return self._response_for_not_found(tried) + return self._response_for_found(data) + + def _response_for_found(self, data): + return self._response(data=data) + + def _response_for_not_found(self, tried): + message = { + "message": _( + "Record not found.\n" "We've tried with the following types: {}" + ).format(", ".join(tried)), + "message_type": "error", + } + return self._response(message=message) + + def _scan_handlers(self): + search = self.actions_for("search") + data = self.actions_for("data") + schema = self.component(usage="schema") + return ( + ("location", search.location_from_scan, data.location, schema.location), + ("package", search.package_from_scan, data.package, schema.package), + ("product", search.product_from_scan, data.product, schema.product), + ("lot", search.lot_from_scan, data.lot, schema.lot), + ("transfer", search.picking_from_scan, data.picking, schema.picking), + ) + + +class ShopfloorScanAnythingValidator(Component): + """Validators for the Application endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.scan_anything.validator" + _usage = "scan_anything.validator" + + def scan(self): + return { + "identifier": {"type": "string", "nullable": False, "required": True}, + } + + +class ShopfloorScanAnythingValidatorResponse(Component): + """Validators for the scan anything endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.scan_anything.validator.response" + _usage = "scan_anything.validator.response" + + def scan(self): + scan_service = self.component(usage="scan_anything") + allowed_types = [x[0] for x in scan_service._scan_handlers()] + allowed_schemas = [x[-1]() for x in scan_service._scan_handlers()] + data_schema = { + "identifier": {"type": "string", "nullable": True, "required": False}, + "type": { + "type": "string", + "nullable": True, + "required": False, + "allowed": allowed_types, + }, + "record": { + "type": "dict", + "required": False, + "nullable": True, + "anyof_schema": allowed_schemas, + "dependencies": ["identifier", "type"], + }, + } + return self._response_schema(data_schema) diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 762aa6d4c8..4bdd4a9fda 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -82,7 +82,7 @@ def package(self): "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, "weight": {"required": True, "nullable": True, "type": "float"}, - "move_line_count": {"required": True, "nullable": True, "type": "integer"}, + "move_line_count": {"required": False, "nullable": True, "type": "integer"}, "packaging": { "type": "dict", "required": True, diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 8adf3ba9bb..274f28289b 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -11,7 +11,7 @@ _logger.debug("Can not import cerberus") -class ActionsDataCase(CommonCase): +class ActionsDataCaseBase(CommonCase): @classmethod def setUpClass(cls): super().setUpClass() @@ -42,6 +42,8 @@ def setUpClass(cls): cls._fill_stock_for_moves(cls.move_d) cls.picking.action_assign() + +class ActionsDataCase(ActionsDataCaseBase): def setUp(self): super().setUp() with self.work_on_actions() as work: diff --git a/shopfloor/tests/test_scan_anything.py b/shopfloor/tests/test_scan_anything.py new file mode 100644 index 0000000000..782b73cad6 --- /dev/null +++ b/shopfloor/tests/test_scan_anything.py @@ -0,0 +1,74 @@ +from .test_actions_data import ActionsDataCaseBase + + +class ScanAnythingCase(ActionsDataCaseBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.package = cls.move_a.move_line_ids.package_id + cls.lot = cls.env["stock.production.lot"].create( + {"product_id": cls.product_b.id, "company_id": cls.env.company.id} + ) + + def setUp(self): + super().setUp() + with self.work_on_actions() as work: + self.data = work.component(usage="data") + with self.work_on_services() as work: + self.service = work.component(usage="scan_anything") + + def _test_response_ok(self, rec_type, data, identifier): + params = {"identifier": identifier} + response = self.service.dispatch("scan", params=params) + self.assert_response( + response, data={"type": rec_type, "identifier": identifier, "record": data}, + ) + + def _test_response_ko(self, identifier, tried=None): + tried = tried or [x[0] for x in self.service._scan_handlers()] + params = {"identifier": identifier} + response = self.service.dispatch("scan", params=params) + message = response["message"] + self.assertEqual(message["message_type"], "error") + self.assertIn("Record not found", message["message"]) + for rec_type in tried: + self.assertIn(rec_type, message["message"]) + + def test_scan_product(self): + record = self.product_b + record.barcode = "PROD-B" + rec_type = "product" + identifier = record.barcode + data = self.data.product(record) + self._test_response_ok(rec_type, data, identifier) + + def test_scan_location(self): + record = self.stock_location + rec_type = "location" + identifier = record.barcode + data = self.data.location(record) + self._test_response_ok(rec_type, data, identifier) + + def test_scan_package(self): + record = self.package + rec_type = "package" + identifier = record.name + data = self.data.package(record) + self._test_response_ok(rec_type, data, identifier) + + def test_scan_lot(self): + record = self.lot + rec_type = "lot" + identifier = record.name + data = self.data.lot(record) + self._test_response_ok(rec_type, data, identifier) + + def test_scan_transfer(self): + record = self.picking + rec_type = "transfer" + identifier = record.name + data = self.data.picking(record) + self._test_response_ok(rec_type, data, identifier) + + def test_scan_error(self): + self._test_response_ko("404-NOTFOUND") From e55e81bc83c618febc85e1d0fa6e141ed4466f8c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 8 May 2020 10:20:36 +0200 Subject: [PATCH 205/940] backend: fix non-process services api-docs Some headers are required only for specific services. Use a specific base component + flags to handle this properly --- shopfloor/services/app.py | 3 + shopfloor/services/checkout.py | 2 +- shopfloor/services/cluster_picking.py | 2 +- shopfloor/services/delivery.py | 2 +- shopfloor/services/service.py | 102 ++++++++++++--------- shopfloor/services/single_pack_putaway.py | 2 +- shopfloor/services/single_pack_transfer.py | 2 +- 7 files changed, 67 insertions(+), 48 deletions(-) diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py index 0a87f0b6e3..d9dcab6ef9 100644 --- a/shopfloor/services/app.py +++ b/shopfloor/services/app.py @@ -8,6 +8,9 @@ class ShopfloorApp(Component): _name = "shopfloor.app" _usage = "app" _description = __doc__ + # TODO this is required only for `menu` and not for `user_config` + # Maybe we should split them. + _requires_header_profile = True def user_config(self): profiles_comp = self.component("profile") diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index cc0b18363e..e5afa78e24 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -29,7 +29,7 @@ class Checkout(Component): Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP """ - _inherit = "base.shopfloor.service" + _inherit = "base.shopfloor.process" _name = "shopfloor.checkout" _usage = "checkout" _description = __doc__ diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 94cab7bbee..f3ba03b31a 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -66,7 +66,7 @@ class ClusterPicking(Component): Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP """ - _inherit = "base.shopfloor.service" + _inherit = "base.shopfloor.process" _name = "shopfloor.cluster.picking" _usage = "cluster_picking" _description = __doc__ diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 3a33be7b3f..257910746f 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -25,7 +25,7 @@ class Delivery(Component): Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP """ - _inherit = "base.shopfloor.service" + _inherit = "base.shopfloor.process" _name = "shopfloor.delivery" _usage = "delivery" _description = __doc__ diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index b1c8d4f512..66a7702f43 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -23,23 +23,6 @@ class BaseShopfloorService(AbstractComponent): _actions_collection_name = "shopfloor.action" _expose_model = None - @property - def picking_types(self): - """Return picking types for the menu and profile""" - # TODO make this a lazy property or computed field avoid running the - # filter every time? - picking_types = self.work.menu.picking_type_ids.filtered( - lambda pt: not pt.warehouse_id - or pt.warehouse_id == self.work.profile.warehouse_id - ) - if not picking_types: - raise exceptions.UserError( - _("No operation types configured on menu {} for warehouse {}.").format( - self.work.menu.name, self.work.profile.warehouse_id.display_name - ) - ) - return picking_types - def _get(self, _id): domain = expression.normalize_domain(self._get_base_search_domain()) domain = expression.AND([domain, [("id", "=", _id)]]) @@ -128,31 +111,33 @@ def _response( return response + _requires_header_menu = False + _requires_header_profile = False + def _get_openapi_default_parameters(self): defaults = super()._get_openapi_default_parameters() demo_api_key = self.env.ref("shopfloor.api_key_demo", raise_if_not_found=False) - - # Try to first the first menu that implements the current service. - # Not all usages have a process, in that case, we'll set the first - # menu found - menu = self.env["shopfloor.menu"].search( - [("scenario", "=", self._usage)], limit=1 - ) - if not menu: - menu = self.env["shopfloor.menu"].search([], limit=1) - profile = self.env["shopfloor.profile"].search([], limit=1) - - defaults.extend( - [ - { - "name": "API-KEY", - "in": "header", - "description": "API key for Authorization", - "required": True, - "schema": {"type": "string"}, - "style": "simple", - "value": demo_api_key.key if demo_api_key else "", - }, + service_params = [ + { + "name": "API-KEY", + "in": "header", + "description": "API key for Authorization", + "required": True, + "schema": {"type": "string"}, + "style": "simple", + "value": demo_api_key.key if demo_api_key else "", + }, + ] + if self._requires_header_menu: + # Try to first the first menu that implements the current service. + # Not all usages have a process, in that case, we'll set the first + # menu found + menu = self.env["shopfloor.menu"].search( + [("scenario", "=", self._usage)], limit=1 + ) + if not menu: + menu = self.env["shopfloor.menu"].search([], limit=1) + service_params.append( { "name": "SERVICE_CTX_MENU_ID", "in": "header", @@ -161,7 +146,11 @@ def _get_openapi_default_parameters(self): "schema": {"type": "integer"}, "style": "simple", "value": menu.id, - }, + } + ) + if self._requires_header_profile: + profile = self.env["shopfloor.profile"].search([], limit=1) + service_params.append( { "name": "SERVICE_CTX_PROFILE_ID", "in": "header", @@ -170,9 +159,9 @@ def _get_openapi_default_parameters(self): "schema": {"type": "integer"}, "style": "simple", "value": profile.id, - }, - ] - ) + } + ), + defaults.extend(service_params) return defaults @property @@ -201,3 +190,30 @@ def _is_public_api_method(self, method_name): if method_name == "actions_for": return False return super()._is_public_api_method(method_name) + + +class BaseShopfloorProcess(AbstractComponent): + """Base class for process rest service""" + + _inherit = "base.shopfloor.service" + _name = "base.shopfloor.process" + + _requires_header_menu = True + _requires_header_profile = True + + @property + def picking_types(self): + """Return picking types for the menu and profile""" + # TODO make this a lazy property or computed field avoid running the + # filter every time? + picking_types = self.work.menu.picking_type_ids.filtered( + lambda pt: not pt.warehouse_id + or pt.warehouse_id == self.work.profile.warehouse_id + ) + if not picking_types: + raise exceptions.UserError( + _("No operation types configured on menu {} for warehouse {}.").format( + self.work.menu.name, self.work.profile.warehouse_id.display_name + ) + ) + return picking_types diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 0cd8c7747b..3473b215ce 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -10,7 +10,7 @@ class SinglePackPutaway(Component): """Methods for the Single Pack Put-Away Process""" - _inherit = "base.shopfloor.service" + _inherit = "base.shopfloor.process" _name = "shopfloor.single.pack.putaway" _usage = "single_pack_putaway" _description = __doc__ diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 196298a846..fc7e9465fe 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -5,7 +5,7 @@ class SinglePackTransfer(Component): """Methods for the Single Pack Transfer Process""" - _inherit = "base.shopfloor.service" + _inherit = "base.shopfloor.process" _name = "shopfloor.single.pack.transfer" _usage = "single_pack_transfer" _description = __doc__ From 3a241fd46d3654d1b39954f38e5c962f52d76f73 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 11 May 2020 07:55:02 +0200 Subject: [PATCH 206/940] backend: add data_detail component --- shopfloor/__manifest__.py | 12 + shopfloor/actions/__init__.py | 2 +- shopfloor/actions/data.py | 25 +- shopfloor/actions/data_detail.py | 145 ++++++++ shopfloor/controllers/main.py | 4 + shopfloor/models/stock_location.py | 16 + shopfloor/models/stock_quant_package.py | 16 +- shopfloor/services/__init__.py | 1 + shopfloor/services/cluster_picking.py | 4 +- shopfloor/services/scan_anything.py | 44 ++- shopfloor/services/schema_detail.py | 131 +++++++ shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_actions_data.py | 8 +- shopfloor/tests/test_actions_data_detail.py | 385 ++++++++++++++++++++ shopfloor/tests/test_scan_anything.py | 24 +- 15 files changed, 777 insertions(+), 41 deletions(-) create mode 100644 shopfloor/actions/data_detail.py create mode 100644 shopfloor/services/schema_detail.py create mode 100644 shopfloor/tests/test_actions_data_detail.py diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 3e58a73793..f9e2462919 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -23,6 +23,18 @@ "stock_quant_package_dimension", # https://github.com/OCA/stock-logistics-workflow/pull/607 "stock_quant_package_product_packaging", + # TODO: used for manuf info on prod detail. + # This must be an optional dep + "product_manufacturer", + # TODO: used for prod lot expire detail info. + # This must be an optional dep + "product_expiry", + # TODO: used for package.package_storage_type_id detail info. + # This must be an optional dep + "stock_storage_type", + # TODO: used for picking.carrier_id detail info. + # This must be an optional dep + "delivery", ], "data": [ "security/ir.model.access.csv", diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index a5971aad7c..58c70c29f3 100644 --- a/shopfloor/actions/__init__.py +++ b/shopfloor/actions/__init__.py @@ -18,8 +18,8 @@ """ from . import base_action from . import data +from . import data_detail from . import completion_info - from . import message from . import pack_transfer_validate from . import search diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index e71c9a5db9..578f2ab5d7 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -28,6 +28,18 @@ def partners(self, record, **kw): def _partner_parser(self): return ["id", "name"] + def location(self, record, **kw): + return self._jsonify( + record.with_context(location=record.id), self._location_parser, **kw + ) + + def locations(self, record, **kw): + return self.location(record, multi=True) + + @property + def _location_parser(self): + return ["id", "name"] + def picking(self, record, **kw): return self._jsonify(record, self._picking_parser, **kw) @@ -92,21 +104,14 @@ def lots(self, record, **kw): def _lot_parser(self): return ["id", "name", "ref"] - def location(self, record, **kw): - return self._jsonify(record, self._location_parser, **kw) - - def locations(self, record, **kw): - return self.location(record, multi=True) - - @property - def _location_parser(self): - return ["id", "name"] - def move_line(self, record, **kw): + record = record.with_context(location=record.location_id.id) data = self._jsonify(record, self._move_line_parser) if data: data.update( { + # cannot use sub-parser here + # because result might depend on picking "package_src": self.package(record.package_id, record.picking_id), "package_dest": self.package( record.result_package_id, record.picking_id, diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py new file mode 100644 index 0000000000..bb260db544 --- /dev/null +++ b/shopfloor/actions/data_detail.py @@ -0,0 +1,145 @@ +from odoo.tools.float_utils import float_round + +from odoo.addons.component.core import Component + + +class DataDetailAction(Component): + """Provide extra data on top of data action. + """ + + _name = "shopfloor.data.detail.action" + _inherit = "shopfloor.data.action" + _usage = "data_detail" + + def _select_value_to_label(self, rec, fname): + return rec._fields[fname].convert_to_export(rec[fname], rec) + + def location_detail(self, record, **kw): + return self._jsonify( + record.with_context(location=record.id), self._location_detail_parser, **kw + ) + + def locations_detail(self, record, **kw): + return self.location_detail(record, multi=True) + + @property + def _location_detail_parser(self): + return self._location_parser + [ + "complete_name", + ( + "reserved_move_line_ids:reserved_move_lines", + lambda record, fname: self.move_lines(record[fname]), + ), + ] + + def picking_detail(self, record, **kw): + return self._jsonify(record, self._picking_detail_parser, **kw) + + def pickings_detail(self, record, **kw): + return self.picking_detail(record, multi=True) + + @property + def _picking_detail_parser(self): + return self._picking_parser + [ + ("priority", self._select_value_to_label), + "scheduled_date", + ("picking_type_id:operation_type", ["id", "name"]), + ("carrier_id:carrier", ["id", "name"]), + ( + "move_line_ids:lines", + lambda record, fname: self.move_lines(record[fname]), + ), + ] + + def package_detail(self, record, picking=None, **kw): + # Define a new method to not overload the base one which is used in many places + data = self.package(record, picking=picking, **kw) + data.update(self._jsonify(record, self._package_detail_parser, **kw)) + return data + + def packages_detail(self, records, picking=None, **kw): + return [self.package_detail(rec, picking=picking) for rec in records] + + @property + def _package_detail_parser(self): + return [ + ( + "reserved_move_line_ids:pickings", + lambda record, fname: self.pickings(record[fname].mapped("picking_id")), + ), + ( + "reserved_move_line_ids:lines", + lambda record, fname: self.move_lines(record[fname]), + ), + ("package_storage_type_id:storage_type", ["id", "name"]), + ] + + def lot_detail(self, record, **kw): + # Define a new method to not overload the base one which is used in many places + return self._jsonify(record, self._lot_detail_parser, **kw) + + def lots_detail(self, record, **kw): + return self.lot_detail(record, multi=True) + + @property + def _lot_detail_parser(self): + return self._lot_parser + [ + "removal_date", + "life_date:expire_date", + ( + "product_id:product", + lambda record, fname: self.product_detail(record[fname]), + ), + ] + + def product_detail(self, record, **kw): + # Defined new method to not overload the base one used in many places + data = self._jsonify(record, self._product_detail_parser, **kw) + suppliers = self.env["product.supplierinfo"].search( + [("product_id", "=", record.id)] + ) + data["suppliers"] = suppliers.jsonify(self._product_supplierinfo_parser) + return data + + def products_detail(self, record, **kw): + return self.product_detail(record, multi=True) + + @property + def _product_parser(self): + return super()._product_parser + [ + "qty_available", + ("free_qty:qty_reserved", self._product_reserved_qty_subparser), + ] + + def _product_reserved_qty_subparser(self, rec, field_name): + # free_qty = qty_available - reserved_quantity + return float_round( + rec.qty_available - rec[field_name], precision_rounding=rec.uom_id.rounding + ) + + @property + def _product_detail_parser(self): + return self._product_parser + [ + ("image_128:image", self._product_image_url), + ( + "product_tmpl_id:manufacturer", + lambda rec, fname: self._jsonify( + rec.product_tmpl_id.manufacturer, ["id", "name"] + ), + ), + ] + + def _product_image_url(self, record, field_name): + if not record[field_name]: + return None + return "/web/image/product.product/{}/{}".format(record.id, field_name) + + @property + def _product_supplierinfo_parser(self): + return [ + # supplier.name == partner :/ + ("id", lambda rec, fname: rec.name.id), + ("name", lambda rec, fname: rec.name.name), + "product_name", + "product_code", + ] diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py index c5330310e9..3f04afa240 100644 --- a/shopfloor/controllers/main.py +++ b/shopfloor/controllers/main.py @@ -16,9 +16,13 @@ class ShopfloorController(main.RestController): _root_path = "/shopfloor/" _collection_name = "shopfloor.service" _default_auth = "api_key" + # TODO: this should come from registered services. + # We would need to change how their ctx is initialized tho + # because ATM the ctx is computed before lookup. _service_headers_rules = { # no special header required for config "app/user_config": (), + "scan_anything/scan": (), # profile header is required to get menu items # fmt: off # NOTE: turn off formatting here is mandatory diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py index 6c9ce5962a..cb3e8b8856 100644 --- a/shopfloor/models/stock_location.py +++ b/shopfloor/models/stock_location.py @@ -7,6 +7,9 @@ class StockLocation(models.Model): source_move_line_ids = fields.One2many( comodel_name="stock.move.line", inverse_name="location_id", readonly=True ) + reserved_move_line_ids = fields.One2many( + comodel_name="stock.move.line", compute="_compute_reserved_move_lines", + ) def is_sublocation_of(self, others): """Return True if self is a sublocation of at least one other""" @@ -16,3 +19,16 @@ def is_sublocation_of(self, others): [("id", "child_of", others.ids), ("id", "=", self.id)] ) ) + + def _get_reserved_move_lines(self): + return self.env["stock.move.line"].search( + [ + ("location_id", "=", self.id), + ("product_uom_qty", ">", 0), + ("state", "not in", ("done", "cancel")), + ] + ) + + def _compute_reserved_move_lines(self): + for rec in self: + rec.update({"reserved_move_line_ids": rec._get_reserved_move_lines()}) diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py index 4716a95bb1..41547158cf 100644 --- a/shopfloor/models/stock_quant_package.py +++ b/shopfloor/models/stock_quant_package.py @@ -1,4 +1,4 @@ -from odoo import fields, models +from odoo import api, fields, models class StockQuantPackage(models.Model): @@ -17,6 +17,20 @@ class StockQuantPackage(models.Model): readonly=True, help="Technical field. Move lines for which destination is this package.", ) + # TODO: review other fields + reserved_move_line_ids = fields.One2many( + comodel_name="stock.move.line", compute="_compute_reserved_move_lines", + ) + + def _get_reserved_move_lines(self): + return self.env["stock.move.line"].search( + [("package_id", "=", self.id), ("state", "not in", ("done", "cancel"))] + ) + + @api.depends("move_line_ids.state") + def _compute_reserved_move_lines(self): + for rec in self: + rec.update({"reserved_move_line_ids": rec._get_reserved_move_lines()}) # TODO: we should refactor this like diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 7dee66d21c..dccb05c842 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -2,6 +2,7 @@ from . import service from . import validator from . import schema +from . import schema_detail # generic services from . import app diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index f3ba03b31a..849faa01da 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -360,7 +360,9 @@ def _data_move_line(self, line): data["batch"] = self.data_struct.picking_batch(batch) data["picking"] = self.data_struct.picking(picking) data["postponed"] = line.shopfloor_postponed - data["product"]["qty_available"] = product.qty_available + data["product"]["qty_available"] = product.with_context( + location=line.location_id.id + ).qty_available return data def unassign(self, picking_batch_id): diff --git a/shopfloor/services/scan_anything.py b/shopfloor/services/scan_anything.py index 410445fdb3..9cfe8be26d 100644 --- a/shopfloor/services/scan_anything.py +++ b/shopfloor/services/scan_anything.py @@ -60,15 +60,45 @@ def _response_for_not_found(self, tried): return self._response(message=message) def _scan_handlers(self): + """Return a tuple of tuples describing handlers for scan requests. + + Tuple schema: + + 0. record type + 1. finder + 2. json detail converter + 3. detail schema validator + + """ search = self.actions_for("search") - data = self.actions_for("data") - schema = self.component(usage="schema") + data = self.actions_for("data_detail") + schema = self.component(usage="schema_detail") return ( - ("location", search.location_from_scan, data.location, schema.location), - ("package", search.package_from_scan, data.package, schema.package), - ("product", search.product_from_scan, data.product, schema.product), - ("lot", search.lot_from_scan, data.lot, schema.lot), - ("transfer", search.picking_from_scan, data.picking, schema.picking), + ( + "location", + search.location_from_scan, + data.location_detail, + schema.location_detail, + ), + ( + "package", + search.package_from_scan, + data.package_detail, + schema.package_detail, + ), + ( + "product", + search.product_from_scan, + data.product_detail, + schema.product_detail, + ), + ("lot", search.lot_from_scan, data.lot_detail, schema.lot_detail), + ( + "transfer", + search.picking_from_scan, + data.picking_detail, + schema.picking_detail, + ), ) diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py new file mode 100644 index 0000000000..435b108d5e --- /dev/null +++ b/shopfloor/services/schema_detail.py @@ -0,0 +1,131 @@ +from odoo.addons.component.core import Component + + +class ShopfloorSchemaDetailResponse(Component): + """Provide methods to share schema structures + + The methods should be used in Service Components, so we try to + have similar schema structures across scenarios. + """ + + _inherit = "base.shopfloor.schemas" + _name = "base.shopfloor.schemas.detail" + _usage = "schema_detail" + + def _schema_list_of(self, schema, **kw): + return { + "type": "list", + "nullable": True, + "required": False, + "schema": {"type": "dict", "schema": schema}, + } + + def _simple_record(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + } + + def _schema_dict_of(self, schema, **kw): + schema = { + "type": "dict", + "nullable": True, + "required": False, + "schema": schema, + } + schema.update(kw) + return schema + + def location_detail(self): + schema = self.location() + schema.update( + { + "complete_name": { + "type": "string", + "nullable": False, + "required": True, + }, + "reserved_move_lines": self._schema_list_of(self.move_line()), + } + ) + return schema + + def picking_detail(self): + schema = self.picking() + schema.update( + { + "priority": {"type": "string", "nullable": True, "required": False}, + "scheduled_date": { + "type": "string", + "nullable": False, + "required": True, + }, + "operation_type": self._schema_dict_of(self._simple_record()), + "carrier": self._schema_dict_of(self._simple_record()), + "lines": self._schema_list_of(self.move_line()), + } + ) + return schema + + def package_detail(self): + schema = self.package() + schema.update( + { + "pickings": self._schema_list_of(self.picking()), + "storage_type": self._schema_dict_of(self._simple_record()), + "lines": self._schema_list_of(self.move_line()), + } + ) + return schema + + def lot_detail(self): + schema = self.lot() + schema.update( + { + "removal_date": {"type": "string", "nullable": True, "required": False}, + "expire_date": {"type": "string", "nullable": True, "required": False}, + "product": self._schema_dict_of(self.product_detail()), + # TODO: packaging + } + ) + return schema + + def product(self): + schema = super().product() + schema.update( + { + "qty_available": {"type": "float", "required": True}, + "qty_reserved": {"type": "float", "required": True}, + } + ) + return schema + + def product_detail(self): + schema = self.product() + schema.update( + { + "image": {"type": "string", "nullable": True, "required": False}, + "manufacturer": self._schema_dict_of(self._simple_record()), + "suppliers": self._schema_list_of(self.product_supplierinfo()), + } + ) + return schema + + def product_supplierinfo(self): + schema = self._simple_record() + schema.update( + { + "product_name": {"type": "string", "nullable": True, "required": False}, + "product_code": {"type": "string", "nullable": True, "required": False}, + } + ) + return schema + + # TODO + # def packaging_detail(self): + # schema = self.packaging() + # schema.update( + # { + # } + # ) + # return schema diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 7994d4bf51..f6c6713ffd 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -3,6 +3,7 @@ from . import test_openapi from . import test_profile from . import test_actions_data +from . import test_actions_data_detail from . import test_single_pack_putaway from . import test_single_pack_transfer from . import test_cluster_picking_base diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 274f28289b..ca3678d3cb 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -42,6 +42,10 @@ def setUpClass(cls): cls._fill_stock_for_moves(cls.move_d) cls.picking.action_assign() + def assert_schema(self, schema, data): + validator = Validator(schema) + self.assertTrue(validator.validate(data), validator.errors) + class ActionsDataCase(ActionsDataCaseBase): def setUp(self): @@ -51,10 +55,6 @@ def setUp(self): with self.work_on_services() as work: self.schema = work.component(usage="schema") - def assert_schema(self, schema, data): - validator = Validator(schema) - self.assertTrue(validator.validate(data), validator.errors) - def test_data_packaging(self): data = self.data.packaging(self.packaging) self.assert_schema(self.schema.packaging(), data) diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py new file mode 100644 index 0000000000..f643419670 --- /dev/null +++ b/shopfloor/tests/test_actions_data_detail.py @@ -0,0 +1,385 @@ +import base64 +import io + +from PIL import Image + +from odoo.tools.float_utils import float_round + +from .test_actions_data import ActionsDataCaseBase + + +def fake_colored_image(color="#4169E1", size=(800, 500)): + with io.BytesIO() as img_file: + Image.new("RGB", size, color).save(img_file, "JPEG") + img_file.seek(0) + return base64.b64encode(img_file.read()) + + +class ActionsDataDetailCaseBase(ActionsDataCaseBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.package = cls.move_a.move_line_ids.package_id + cls.lot = cls.env["stock.production.lot"].create( + {"product_id": cls.product_b.id, "company_id": cls.env.company.id} + ) + cls.storage_type_pallet = cls.env.ref( + "stock_storage_type.package_storage_type_pallets" + ) + + def setUp(self): + super().setUp() + with self.work_on_actions() as work: + self.data = work.component(usage="data_detail") + with self.work_on_services() as work: + self.schema = work.component(usage="schema_detail") + + +class ActionsDataDetailCase(ActionsDataDetailCaseBase): + def test_data_location(self): + location = self.stock_location + data = self.data.location_detail(location) + self.assert_schema(self.schema.location_detail(), data) + move_lines = self.env["stock.move.line"].search( + [ + ("location_id", "=", location.id), + ("product_qty", ">", 0), + ("state", "not in", ("done", "cancel")), + ] + ) + expected = { + "id": location.id, + "name": location.name, + "complete_name": location.complete_name, + "reserved_move_lines": self.data.move_lines(move_lines), + } + self.assertDictEqual(data, expected) + + def test_data_packaging(self): + data = self.data.packaging(self.packaging) + self.assert_schema(self.schema.packaging(), data) + expected = {"id": self.packaging.id, "name": self.packaging.name} + self.assertDictEqual(data, expected) + + def test_data_lot(self): + lot = self.env["stock.production.lot"].create( + { + "product_id": self.product_b.id, + "company_id": self.env.company.id, + "ref": "#FOO", + "removal_date": "2020-05-20", + "life_date": "2020-05-31", + } + ) + data = self.data.lot_detail(lot) + self.assert_schema(self.schema.lot_detail(), data) + + expected = { + "id": lot.id, + "name": lot.name, + "ref": "#FOO", + "product": self.data.product_detail(self.product_b), + } + # ignore time and TZ, we don't care here + self.assertEqual(data.pop("removal_date").split("T")[0], "2020-05-20") + self.assertEqual(data.pop("expire_date").split("T")[0], "2020-05-31") + self.assertDictEqual(data, expected) + + def test_data_package(self): + package = self.move_a.move_line_ids.package_id + package.product_packaging_id = self.packaging.id + package.package_storage_type_id = self.storage_type_pallet + # package.invalidate_cache() + data = self.data.package_detail(package, picking=self.picking) + self.assert_schema(self.schema.package_detail(), data) + + lines = self.env["stock.move.line"].search( + [("package_id", "=", package.id), ("state", "not in", ("done", "cancel"))] + ) + pickings = lines.mapped("picking_id") + expected = { + "id": package.id, + "name": package.name, + "move_line_count": 1, + "packaging": self.data.packaging(package.product_packaging_id), + "weight": 0, + "pickings": self.data.pickings(pickings), + "lines": self.data.move_lines(lines), + "storage_type": { + "id": self.storage_type_pallet.id, + "name": self.storage_type_pallet.name, + }, + } + self.assertDictEqual(data, expected) + + def test_data_picking(self): + picking = self.picking + carrier = picking.carrier_id.search([])[0] + picking.write( + { + "origin": "created by test", + "note": "read me", + "priority": "3", + "carrier_id": carrier.id, + } + ) + picking.move_lines.write({"date_expected": "2020-05-13"}) + data = self.data.picking_detail(picking) + self.assert_schema(self.schema.picking_detail(), data) + expected = { + "id": picking.id, + "move_line_count": 4, + "name": picking.name, + "note": "read me", + "origin": "created by test", + "weight": 110.0, + "partner": {"id": self.customer.id, "name": self.customer.name}, + "priority": "Very Urgent", + "operation_type": { + "id": picking.picking_type_id.id, + "name": picking.picking_type_id.name, + }, + "carrier": {"id": carrier.id, "name": carrier.name}, + "lines": self.data.move_lines(picking.move_line_ids), + } + self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-05-13") + self.maxDiff = None + self.assertDictEqual(data, expected) + + def test_data_move_line_package(self): + move_line = self.move_a.move_line_ids + result_package = self.env["stock.quant.package"].create( + {"product_packaging_id": self.packaging.id} + ) + move_line.write({"qty_done": 3.0, "result_package_id": result_package.id}) + data = self.data.move_line(move_line) + self.assert_schema(self.schema.move_line(), data) + product = self.product_a.with_context(location=move_line.location_id.id) + qty_available = product.qty_available + qty_reserved = float_round( + product.qty_available - product.free_qty, + precision_rounding=product.uom_id.rounding, + ) + expected = { + "id": move_line.id, + "qty_done": 3.0, + "quantity": move_line.product_uom_qty, + "product": { + "id": self.product_a.id, + "name": "Product A", + "display_name": "[A] Product A", + "default_code": "A", + "qty_available": qty_available, + "qty_reserved": qty_reserved, + }, + "lot": None, + "package_src": { + "id": move_line.package_id.id, + "name": move_line.package_id.name, + "move_line_count": 1, + "packaging": None, + "weight": 0.0, + }, + "package_dest": { + "id": result_package.id, + "name": result_package.name, + "move_line_count": 0, + "packaging": self.data.packaging(self.packaging), + "weight": 0.0, + }, + "location_src": { + "id": move_line.location_id.id, + "name": move_line.location_id.name, + }, + "location_dest": { + "id": move_line.location_dest_id.id, + "name": move_line.location_dest_id.name, + }, + } + self.assertDictEqual(data, expected) + + def test_data_move_line_lot(self): + move_line = self.move_b.move_line_ids + data = self.data.move_line(move_line) + self.assert_schema(self.schema.move_line(), data) + product = self.product_b.with_context(location=move_line.location_id.id) + qty_available = product.qty_available + qty_reserved = float_round( + product.qty_available - product.free_qty, + precision_rounding=product.uom_id.rounding, + ) + expected = { + "id": move_line.id, + "qty_done": 0.0, + "quantity": move_line.product_uom_qty, + "product": { + "id": self.product_b.id, + "name": "Product B", + "display_name": "[B] Product B", + "default_code": "B", + "qty_available": qty_available, + "qty_reserved": qty_reserved, + }, + "lot": { + "id": move_line.lot_id.id, + "name": move_line.lot_id.name, + "ref": None, + }, + "package_src": None, + "package_dest": None, + "location_src": { + "id": move_line.location_id.id, + "name": move_line.location_id.name, + }, + "location_dest": { + "id": move_line.location_dest_id.id, + "name": move_line.location_dest_id.name, + }, + } + self.assertDictEqual(data, expected) + + def test_data_move_line_package_lot(self): + self.maxDiff = None + move_line = self.move_c.move_line_ids + data = self.data.move_line(move_line) + self.assert_schema(self.schema.move_line(), data) + product = self.product_c.with_context(location=move_line.location_id.id) + qty_available = product.qty_available + qty_reserved = float_round( + product.qty_available - product.free_qty, + precision_rounding=product.uom_id.rounding, + ) + expected = { + "id": move_line.id, + "qty_done": 0.0, + "quantity": move_line.product_uom_qty, + "product": { + "id": self.product_c.id, + "name": "Product C", + "display_name": "[C] Product C", + "default_code": "C", + "qty_available": qty_available, + "qty_reserved": qty_reserved, + }, + "lot": { + "id": move_line.lot_id.id, + "name": move_line.lot_id.name, + "ref": None, + }, + "package_src": { + "id": move_line.package_id.id, + "name": move_line.package_id.name, + "move_line_count": 1, + "packaging": None, + "weight": 0.0, + }, + "package_dest": { + "id": move_line.result_package_id.id, + "name": move_line.result_package_id.name, + "move_line_count": 1, + "packaging": None, + "weight": 0.0, + }, + "location_src": { + "id": move_line.location_id.id, + "name": move_line.location_id.name, + }, + "location_dest": { + "id": move_line.location_dest_id.id, + "name": move_line.location_dest_id.name, + }, + } + self.assertDictEqual(data, expected) + + def test_data_move_line_raw(self): + move_line = self.move_d.move_line_ids + data = self.data.move_line(move_line) + self.assert_schema(self.schema.move_line(), data) + product = self.product_d.with_context(location=move_line.location_id.id) + qty_available = product.qty_available + qty_reserved = float_round( + product.qty_available - product.free_qty, + precision_rounding=product.uom_id.rounding, + ) + expected = { + "id": move_line.id, + "qty_done": 0.0, + "quantity": move_line.product_uom_qty, + "product": { + "id": self.product_d.id, + "name": "Product D", + "display_name": "[D] Product D", + "default_code": "D", + "qty_available": qty_available, + "qty_reserved": qty_reserved, + }, + "lot": None, + "package_src": None, + "package_dest": None, + "location_src": { + "id": move_line.location_id.id, + "name": move_line.location_id.name, + }, + "location_dest": { + "id": move_line.location_dest_id.id, + "name": move_line.location_dest_id.name, + }, + } + self.assertDictEqual(data, expected) + + def test_product(self): + move_line = self.move_b.move_line_ids + product = move_line.product_id.with_context(location=move_line.location_id.id) + manuf = self.env["res.partner"].create({"name": "Manuf 1"}) + product.write( + { + "image_128": fake_colored_image(size=(128, 128)), + "manufacturer": manuf.id, + } + ) + vendor_a = self.env["res.partner"].create({"name": "Supplier A"}) + vendor_b = self.env["res.partner"].create({"name": "Supplier B"}) + self.env["product.supplierinfo"].create( + { + "name": vendor_a.id, + "product_tmpl_id": product.product_tmpl_id.id, + "product_id": product.id, + "product_code": "SUPP1", + } + ) + self.env["product.supplierinfo"].create( + { + "name": vendor_b.id, + "product_tmpl_id": product.product_tmpl_id.id, + "product_id": product.id, + "product_code": "SUPP2", + } + ) + data = self.data.product_detail(product) + self.assert_schema(self.schema.product_detail(), data) + qty_available = product.qty_available + qty_reserved = float_round( + product.qty_available - product.free_qty, + precision_rounding=product.uom_id.rounding, + ) + expected = { + "id": product.id, + "name": "Product B", + "display_name": "[B] Product B", + "default_code": "B", + "qty_available": qty_available, + "qty_reserved": qty_reserved, + "image": "/web/image/product.product/{}/image_128".format(product.id), + "manufacturer": {"id": manuf.id, "name": manuf.name}, + "suppliers": [ + { + "id": v.name.id, + "name": v.name.name, + "product_name": None, + "product_code": v.product_code, + } + for v in product.seller_ids + ], + } + self.maxDiff = None + self.assertDictEqual(data, expected) diff --git a/shopfloor/tests/test_scan_anything.py b/shopfloor/tests/test_scan_anything.py index 782b73cad6..c3af31cab5 100644 --- a/shopfloor/tests/test_scan_anything.py +++ b/shopfloor/tests/test_scan_anything.py @@ -1,19 +1,9 @@ -from .test_actions_data import ActionsDataCaseBase +from .test_actions_data_detail import ActionsDataDetailCaseBase -class ScanAnythingCase(ActionsDataCaseBase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.package = cls.move_a.move_line_ids.package_id - cls.lot = cls.env["stock.production.lot"].create( - {"product_id": cls.product_b.id, "company_id": cls.env.company.id} - ) - +class ScanAnythingCase(ActionsDataDetailCaseBase): def setUp(self): super().setUp() - with self.work_on_actions() as work: - self.data = work.component(usage="data") with self.work_on_services() as work: self.service = work.component(usage="scan_anything") @@ -39,35 +29,35 @@ def test_scan_product(self): record.barcode = "PROD-B" rec_type = "product" identifier = record.barcode - data = self.data.product(record) + data = self.data.product_detail(record) self._test_response_ok(rec_type, data, identifier) def test_scan_location(self): record = self.stock_location rec_type = "location" identifier = record.barcode - data = self.data.location(record) + data = self.data.location_detail(record) self._test_response_ok(rec_type, data, identifier) def test_scan_package(self): record = self.package rec_type = "package" identifier = record.name - data = self.data.package(record) + data = self.data.package_detail(record) self._test_response_ok(rec_type, data, identifier) def test_scan_lot(self): record = self.lot rec_type = "lot" identifier = record.name - data = self.data.lot(record) + data = self.data.lot_detail(record) self._test_response_ok(rec_type, data, identifier) def test_scan_transfer(self): record = self.picking rec_type = "transfer" identifier = record.name - data = self.data.picking(record) + data = self.data.picking_detail(record) self._test_response_ok(rec_type, data, identifier) def test_scan_error(self): From 21fea246b375366c852fe30b4b68b7d0106b600a Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 13 May 2020 11:22:45 +0200 Subject: [PATCH 207/940] backend: location and product must expose barcode --- shopfloor/actions/data.py | 4 +- shopfloor/services/schema.py | 2 + shopfloor/tests/common.py | 5 + shopfloor/tests/test_actions_data.py | 90 ++++------ shopfloor/tests/test_actions_data_detail.py | 178 +++++++------------- 5 files changed, 103 insertions(+), 176 deletions(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 578f2ab5d7..977550d2ac 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -38,7 +38,7 @@ def locations(self, record, **kw): @property def _location_parser(self): - return ["id", "name"] + return ["id", "name", "barcode"] def picking(self, record, **kw): return self._jsonify(record, self._picking_parser, **kw) @@ -143,7 +143,7 @@ def products(self, record, **kw): @property def _product_parser(self): - return ["id", "name", "display_name", "default_code"] + return ["id", "name", "display_name", "default_code", "barcode"] def picking_batch(self, record, with_pickings=True, **kw): parser = self._picking_batch_parser diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 4bdd4a9fda..224db160ad 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -75,6 +75,7 @@ def product(self): "name": {"type": "string", "nullable": False, "required": True}, "display_name": {"type": "string", "nullable": False, "required": True}, "default_code": {"type": "string", "nullable": False, "required": True}, + "barcode": {"type": "string", "nullable": True, "required": False}, } def package(self): @@ -102,6 +103,7 @@ def location(self): return { "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, + "barcode": {"type": "string", "nullable": True, "required": False}, } def packaging(self): diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index e1483551ef..f4ba02ca4c 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -71,12 +71,17 @@ def setUpClassVars(cls): stock_location = cls.env.ref("stock.stock_location_stock") cls.stock_location = stock_location cls.customer_location = cls.env.ref("stock.stock_location_customers") + cls.customer_location.barcode = "CUSTOMERS" cls.dispatch_location = cls.env.ref("stock.location_dispatch_zone") cls.dispatch_location.barcode = "DISPATCH" cls.packing_location = cls.env.ref("stock.location_pack_zone") + cls.packing_location.barcode = "PACKING" cls.input_location = cls.env.ref("stock.stock_location_company") + cls.input_location.barcode = "INPUT" cls.shelf1 = cls.env.ref("stock.stock_location_components") + cls.shelf1.barcode = "SHELF1" cls.shelf2 = cls.env.ref("stock.stock_location_14") + cls.shelf2.barcode = "SHELF2" cls.customer = cls.env["res.partner"].create({"name": "Customer"}) @classmethod diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index ca3678d3cb..b25c08cd0b 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -46,6 +46,22 @@ def assert_schema(self, schema, data): validator = Validator(schema) self.assertTrue(validator.validate(data), validator.errors) + def _expected_location(self, record, **kw): + return { + "id": record.id, + "name": record.name, + "barcode": record.barcode, + } + + def _expected_product(self, record, **kw): + return { + "id": record.id, + "name": record.name, + "display_name": record.display_name, + "default_code": record.default_code, + "barcode": record.barcode, + } + class ActionsDataCase(ActionsDataCaseBase): def setUp(self): @@ -65,7 +81,11 @@ def test_data_location(self): location = self.stock_location data = self.data.location(location) self.assert_schema(self.schema.location(), data) - expected = {"id": location.id, "name": location.name} + expected = { + "id": location.id, + "name": location.name, + "barcode": location.barcode, + } self.assertDictEqual(data, expected) def test_data_lot(self): @@ -122,12 +142,7 @@ def test_data_move_line_package(self): "id": move_line.id, "qty_done": 3.0, "quantity": move_line.product_uom_qty, - "product": { - "id": self.product_a.id, - "name": "Product A", - "display_name": "[A] Product A", - "default_code": "A", - }, + "product": self._expected_product(self.product_a), "lot": None, "package_src": { "id": move_line.package_id.id, @@ -145,14 +160,8 @@ def test_data_move_line_package(self): # TODO "weight": 0, }, - "location_src": { - "id": move_line.location_id.id, - "name": move_line.location_id.name, - }, - "location_dest": { - "id": move_line.location_dest_id.id, - "name": move_line.location_dest_id.name, - }, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), } self.assertDictEqual(data, expected) @@ -164,12 +173,7 @@ def test_data_move_line_lot(self): "id": move_line.id, "qty_done": 0.0, "quantity": move_line.product_uom_qty, - "product": { - "id": self.product_b.id, - "name": "Product B", - "display_name": "[B] Product B", - "default_code": "B", - }, + "product": self._expected_product(self.product_b), "lot": { "id": move_line.lot_id.id, "name": move_line.lot_id.name, @@ -177,14 +181,8 @@ def test_data_move_line_lot(self): }, "package_src": None, "package_dest": None, - "location_src": { - "id": move_line.location_id.id, - "name": move_line.location_id.name, - }, - "location_dest": { - "id": move_line.location_dest_id.id, - "name": move_line.location_dest_id.name, - }, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), } self.assertDictEqual(data, expected) @@ -197,12 +195,7 @@ def test_data_move_line_package_lot(self): "id": move_line.id, "qty_done": 0.0, "quantity": move_line.product_uom_qty, - "product": { - "id": self.product_c.id, - "name": "Product C", - "display_name": "[C] Product C", - "default_code": "C", - }, + "product": self._expected_product(self.product_c), "lot": { "id": move_line.lot_id.id, "name": move_line.lot_id.name, @@ -224,14 +217,8 @@ def test_data_move_line_package_lot(self): # TODO "weight": 0, }, - "location_src": { - "id": move_line.location_id.id, - "name": move_line.location_id.name, - }, - "location_dest": { - "id": move_line.location_dest_id.id, - "name": move_line.location_dest_id.name, - }, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), } self.assertDictEqual(data, expected) @@ -243,22 +230,11 @@ def test_data_move_line_raw(self): "id": move_line.id, "qty_done": 0.0, "quantity": move_line.product_uom_qty, - "product": { - "id": self.product_d.id, - "name": "Product D", - "display_name": "[D] Product D", - "default_code": "D", - }, + "product": self._expected_product(self.product_d), "lot": None, "package_src": None, "package_dest": None, - "location_src": { - "id": move_line.location_id.id, - "name": move_line.location_id.name, - }, - "location_dest": { - "id": move_line.location_dest_id.id, - "name": move_line.location_dest_id.name, - }, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), } self.assertDictEqual(data, expected) diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index f643419670..def85a49d1 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -34,6 +34,50 @@ def setUp(self): with self.work_on_services() as work: self.schema = work.component(usage="schema_detail") + def _expected_location_detail(self, record, **kw): + return dict( + **self._expected_location(record), + **{ + "complete_name": record.complete_name, + "reserved_move_lines": self.data.move_lines(kw.get("move_lines", [])), + } + ) + + def _expected_product_detail(self, record, **kw): + qty_available = record.qty_available + qty_reserved = float_round( + record.qty_available - record.free_qty, + precision_rounding=record.uom_id.rounding, + ) + detail = { + "qty_available": qty_available, + "qty_reserved": qty_reserved, + } + if kw.get("full"): + detail.update( + { + "image": "/web/image/product.product/{}/image_128".format(record.id) + if record.image_128 + else None, + "manufacturer": { + "id": record.manufacturer.id, + "name": record.manufacturer.name, + } + if record.manufacturer + else None, + "suppliers": [ + { + "id": v.name.id, + "name": v.name.name, + "product_name": None, + "product_code": v.product_code, + } + for v in record.seller_ids + ], + } + ) + return dict(**self._expected_product(record), **detail) + class ActionsDataDetailCase(ActionsDataDetailCaseBase): def test_data_location(self): @@ -47,13 +91,9 @@ def test_data_location(self): ("state", "not in", ("done", "cancel")), ] ) - expected = { - "id": location.id, - "name": location.name, - "complete_name": location.complete_name, - "reserved_move_lines": self.data.move_lines(move_lines), - } - self.assertDictEqual(data, expected) + self.assertDictEqual( + data, self._expected_location_detail(location, move_lines=move_lines) + ) def test_data_packaging(self): data = self.data.packaging(self.packaging) @@ -78,7 +118,7 @@ def test_data_lot(self): "id": lot.id, "name": lot.name, "ref": "#FOO", - "product": self.data.product_detail(self.product_b), + "product": self._expected_product_detail(self.product_b, full=True), } # ignore time and TZ, we don't care here self.assertEqual(data.pop("removal_date").split("T")[0], "2020-05-20") @@ -155,23 +195,11 @@ def test_data_move_line_package(self): data = self.data.move_line(move_line) self.assert_schema(self.schema.move_line(), data) product = self.product_a.with_context(location=move_line.location_id.id) - qty_available = product.qty_available - qty_reserved = float_round( - product.qty_available - product.free_qty, - precision_rounding=product.uom_id.rounding, - ) expected = { "id": move_line.id, "qty_done": 3.0, "quantity": move_line.product_uom_qty, - "product": { - "id": self.product_a.id, - "name": "Product A", - "display_name": "[A] Product A", - "default_code": "A", - "qty_available": qty_available, - "qty_reserved": qty_reserved, - }, + "product": self._expected_product_detail(product), "lot": None, "package_src": { "id": move_line.package_id.id, @@ -187,14 +215,8 @@ def test_data_move_line_package(self): "packaging": self.data.packaging(self.packaging), "weight": 0.0, }, - "location_src": { - "id": move_line.location_id.id, - "name": move_line.location_id.name, - }, - "location_dest": { - "id": move_line.location_dest_id.id, - "name": move_line.location_dest_id.name, - }, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), } self.assertDictEqual(data, expected) @@ -203,23 +225,11 @@ def test_data_move_line_lot(self): data = self.data.move_line(move_line) self.assert_schema(self.schema.move_line(), data) product = self.product_b.with_context(location=move_line.location_id.id) - qty_available = product.qty_available - qty_reserved = float_round( - product.qty_available - product.free_qty, - precision_rounding=product.uom_id.rounding, - ) expected = { "id": move_line.id, "qty_done": 0.0, "quantity": move_line.product_uom_qty, - "product": { - "id": self.product_b.id, - "name": "Product B", - "display_name": "[B] Product B", - "default_code": "B", - "qty_available": qty_available, - "qty_reserved": qty_reserved, - }, + "product": self._expected_product_detail(product), "lot": { "id": move_line.lot_id.id, "name": move_line.lot_id.name, @@ -227,14 +237,8 @@ def test_data_move_line_lot(self): }, "package_src": None, "package_dest": None, - "location_src": { - "id": move_line.location_id.id, - "name": move_line.location_id.name, - }, - "location_dest": { - "id": move_line.location_dest_id.id, - "name": move_line.location_dest_id.name, - }, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), } self.assertDictEqual(data, expected) @@ -244,23 +248,11 @@ def test_data_move_line_package_lot(self): data = self.data.move_line(move_line) self.assert_schema(self.schema.move_line(), data) product = self.product_c.with_context(location=move_line.location_id.id) - qty_available = product.qty_available - qty_reserved = float_round( - product.qty_available - product.free_qty, - precision_rounding=product.uom_id.rounding, - ) expected = { "id": move_line.id, "qty_done": 0.0, "quantity": move_line.product_uom_qty, - "product": { - "id": self.product_c.id, - "name": "Product C", - "display_name": "[C] Product C", - "default_code": "C", - "qty_available": qty_available, - "qty_reserved": qty_reserved, - }, + "product": self._expected_product_detail(product), "lot": { "id": move_line.lot_id.id, "name": move_line.lot_id.name, @@ -280,14 +272,8 @@ def test_data_move_line_package_lot(self): "packaging": None, "weight": 0.0, }, - "location_src": { - "id": move_line.location_id.id, - "name": move_line.location_id.name, - }, - "location_dest": { - "id": move_line.location_dest_id.id, - "name": move_line.location_dest_id.name, - }, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), } self.assertDictEqual(data, expected) @@ -296,34 +282,16 @@ def test_data_move_line_raw(self): data = self.data.move_line(move_line) self.assert_schema(self.schema.move_line(), data) product = self.product_d.with_context(location=move_line.location_id.id) - qty_available = product.qty_available - qty_reserved = float_round( - product.qty_available - product.free_qty, - precision_rounding=product.uom_id.rounding, - ) expected = { "id": move_line.id, "qty_done": 0.0, "quantity": move_line.product_uom_qty, - "product": { - "id": self.product_d.id, - "name": "Product D", - "display_name": "[D] Product D", - "default_code": "D", - "qty_available": qty_available, - "qty_reserved": qty_reserved, - }, + "product": self._expected_product_detail(product), "lot": None, "package_src": None, "package_dest": None, - "location_src": { - "id": move_line.location_id.id, - "name": move_line.location_id.name, - }, - "location_dest": { - "id": move_line.location_dest_id.id, - "name": move_line.location_dest_id.name, - }, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), } self.assertDictEqual(data, expected) @@ -357,29 +325,5 @@ def test_product(self): ) data = self.data.product_detail(product) self.assert_schema(self.schema.product_detail(), data) - qty_available = product.qty_available - qty_reserved = float_round( - product.qty_available - product.free_qty, - precision_rounding=product.uom_id.rounding, - ) - expected = { - "id": product.id, - "name": "Product B", - "display_name": "[B] Product B", - "default_code": "B", - "qty_available": qty_available, - "qty_reserved": qty_reserved, - "image": "/web/image/product.product/{}/image_128".format(product.id), - "manufacturer": {"id": manuf.id, "name": manuf.name}, - "suppliers": [ - { - "id": v.name.id, - "name": v.name.name, - "product_name": None, - "product_code": v.product_code, - } - for v in product.seller_ids - ], - } - self.maxDiff = None + expected = self._expected_product_detail(product, full=True) self.assertDictEqual(data, expected) From 729f23c46db5f33f4a5c4e49f3618815949ea8bb Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 13 May 2020 12:03:39 +0200 Subject: [PATCH 208/940] backend: package detail move_lines explicit --- shopfloor/actions/data_detail.py | 2 +- shopfloor/services/schema_detail.py | 2 +- shopfloor/tests/test_actions_data_detail.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py index bb260db544..2cd588e7f6 100644 --- a/shopfloor/actions/data_detail.py +++ b/shopfloor/actions/data_detail.py @@ -68,7 +68,7 @@ def _package_detail_parser(self): lambda record, fname: self.pickings(record[fname].mapped("picking_id")), ), ( - "reserved_move_line_ids:lines", + "reserved_move_line_ids:move_lines", lambda record, fname: self.move_lines(record[fname]), ), ("package_storage_type_id:storage_type", ["id", "name"]), diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py index 435b108d5e..b28f069e7e 100644 --- a/shopfloor/services/schema_detail.py +++ b/shopfloor/services/schema_detail.py @@ -73,7 +73,7 @@ def package_detail(self): { "pickings": self._schema_list_of(self.picking()), "storage_type": self._schema_dict_of(self._simple_record()), - "lines": self._schema_list_of(self.move_line()), + "move_lines": self._schema_list_of(self.move_line()), } ) return schema diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index def85a49d1..cbd2e71bcd 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -144,7 +144,7 @@ def test_data_package(self): "packaging": self.data.packaging(package.product_packaging_id), "weight": 0, "pickings": self.data.pickings(pickings), - "lines": self.data.move_lines(lines), + "move_lines": self.data.move_lines(lines), "storage_type": { "id": self.storage_type_pallet.id, "name": self.storage_type_pallet.name, From fd19bdbd813a47b7f0959e14c25d692ae00f1eb2 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 15 May 2020 08:00:11 +0200 Subject: [PATCH 209/940] Improve method verifying if location is a sublocation We don't need to do a query as we can find it using the parent path stored on the records. --- shopfloor/models/stock_location.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py index cb3e8b8856..a1f1614a87 100644 --- a/shopfloor/models/stock_location.py +++ b/shopfloor/models/stock_location.py @@ -14,11 +14,9 @@ class StockLocation(models.Model): def is_sublocation_of(self, others): """Return True if self is a sublocation of at least one other""" self.ensure_one() - return bool( - self.env["stock.location"].search_count( - [("id", "child_of", others.ids), ("id", "=", self.id)] - ) - ) + # Efficient way to verify that the current location is + # below one of the other location without using SQL. + return any(self.parent_path.startswith(other.parent_path) for other in others) def _get_reserved_move_lines(self): return self.env["stock.move.line"].search( From d80072cb0ea9c521fff2a7c8b1e45757400bbde3 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 15 May 2020 13:03:49 +0200 Subject: [PATCH 210/940] backend: rename message.message to message.body --- shopfloor/actions/message.py | 72 +++++++++---------- shopfloor/services/checkout.py | 36 ++++------ shopfloor/services/cluster_picking.py | 12 ++-- shopfloor/services/scan_anything.py | 2 +- shopfloor/services/single_pack_putaway.py | 2 +- shopfloor/services/validator.py | 2 +- shopfloor/tests/common.py | 2 + shopfloor/tests/test_checkout_cancel_line.py | 4 +- .../tests/test_checkout_change_packaging.py | 8 +-- shopfloor/tests/test_checkout_done.py | 10 +-- shopfloor/tests/test_checkout_list_package.py | 11 ++- shopfloor/tests/test_checkout_new_package.py | 2 +- shopfloor/tests/test_checkout_no_package.py | 2 +- shopfloor/tests/test_checkout_scan.py | 10 +-- shopfloor/tests/test_checkout_scan_line.py | 18 ++--- .../test_checkout_scan_package_action.py | 12 ++-- shopfloor/tests/test_checkout_select.py | 2 +- shopfloor/tests/test_checkout_select_line.py | 4 +- shopfloor/tests/test_checkout_set_qty.py | 10 +-- shopfloor/tests/test_cluster_picking_scan.py | 32 ++++----- .../tests/test_cluster_picking_select.py | 10 +-- .../tests/test_cluster_picking_unload.py | 16 ++--- shopfloor/tests/test_scan_anything.py | 4 +- shopfloor/tests/test_single_pack_putaway.py | 31 ++++---- shopfloor/tests/test_single_pack_transfer.py | 34 +++++---- 25 files changed, 164 insertions(+), 184 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index f7c56f8552..7a6f099c84 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -20,28 +20,28 @@ class MessageAction(Component): def no_picking_type(self): return { "message_type": "error", - "message": _("No operation type found for this menu and profile."), + "body": _("No operation type found for this menu and profile."), } def several_picking_types(self): return { "message_type": "error", - "message": _("Several operation types found for this menu and profile."), + "body": _("Several operation types found for this menu and profile."), } def package_not_found_for_barcode(self, barcode): return { "message_type": "error", - "message": _("The package %s doesn't exist") % barcode, + "body": _("The package %s doesn't exist") % barcode, } def bin_not_found_for_barcode(self, barcode): - return {"message_type": "error", "message": _("Bin %s doesn't exist") % barcode} + return {"message_type": "error", "body": _("Bin %s doesn't exist") % barcode} def package_not_allowed_in_src_location(self, barcode, picking_types): return { "message_type": "error", - "message": _("You cannot work on a package (%s) outside of locations: %s") + "body": _("You cannot work on a package (%s) outside of locations: %s") % ( barcode, ", ".join(picking_types.mapped("default_location_src_id.name")), @@ -51,94 +51,92 @@ def package_not_allowed_in_src_location(self, barcode, picking_types): def already_running_ask_confirmation(self): return { "message_type": "warning", - "message": _( - "Operation's already running. Would you like to take it over?" - ), + "body": _("Operation's already running. Would you like to take it over?"), } def scan_destination(self): - return {"message_type": "info", "message": _("Scan the destination location")} + return {"message_type": "info", "body": _("Scan the destination location")} def scan_lot_on_product_tracked_by_lot(self): return { "message_type": "warning", - "message": _("Product tracked by lot, please scan one."), + "body": _("Product tracked by lot, please scan one."), } def operation_not_found(self): return { "message_type": "error", - "message": _("This operation does not exist anymore."), + "body": _("This operation does not exist anymore."), } def stock_picking_not_found(self): return { "message_type": "error", - "message": _("This transfer does not exist anymore."), + "body": _("This transfer does not exist anymore."), } def record_not_found(self): return { "message_type": "error", - "message": _("The record you were working on does not exist anymore."), + "body": _("The record you were working on does not exist anymore."), } def barcode_not_found(self): - return {"message_type": "error", "message": _("Barcode not found")} + return {"message_type": "error", "body": _("Barcode not found")} def operation_has_been_canceled_elsewhere(self): return { "message_type": "warning", - "message": _("Restart the operation, someone has canceled it."), + "body": _("Restart the operation, someone has canceled it."), } def no_location_found(self): return { "message_type": "error", - "message": _("No location found for this barcode."), + "body": _("No location found for this barcode."), } def location_not_allowed(self): - return {"message_type": "error", "message": _("Location not allowed here.")} + return {"message_type": "error", "body": _("Location not allowed here.")} def dest_location_not_allowed(self): - return {"message_type": "error", "message": _("You cannot place it here")} + return {"message_type": "error", "body": _("You cannot place it here")} def need_confirmation(self): - return {"message_type": "warning", "message": _("Are you sure?")} + return {"message_type": "warning", "body": _("Are you sure?")} def confirm_location_changed(self, from_location, to_location): return { "message_type": "warning", - "message": _("Confirm location change from %s to %s?") + "body": _("Confirm location change from %s to %s?") % (from_location.name, to_location.name), } def confirm_pack_moved(self): return { "message_type": "success", - "message": _("The pack has been moved, you can scan a new pack."), + "body": _("The pack has been moved, you can scan a new pack."), } def already_done(self): - return {"message_type": "info", "message": _("Operation already processed.")} + return {"message_type": "info", "body": _("Operation already processed.")} def confirm_canceled_scan_next_pack(self): return { "message_type": "info", - "message": _("Canceled, you can scan a new pack."), + "body": _("Canceled, you can scan a new pack."), } def no_pack_in_location(self, location): return { "message_type": "error", - "message": _("Location %s doesn't contain any package." % location.name), + "body": _("Location %s doesn't contain any package." % location.name), } def several_packs_in_location(self, location): return { "message_type": "warning", - "message": _( + "body": _( "Several packages found in %s, please scan a package." % location.name ), } @@ -146,15 +144,13 @@ def several_packs_in_location(self, location): def several_lots_in_location(self, location): return { "message_type": "warning", - "message": _( - "Several lots found in %s, please scan a lot." % location.name - ), + "body": _("Several lots found in %s, please scan a lot." % location.name), } def several_products_in_location(self, location): return { "message_type": "warning", - "message": _( + "body": _( "Several products found in %s, please scan a product." % location.name ), } @@ -162,19 +158,19 @@ def several_products_in_location(self, location): def no_pending_operation_for_pack(self, pack): return { "message_type": "error", - "message": _("No pending operation for package %s." % pack.name), + "body": _("No pending operation for package %s." % pack.name), } def unrecoverable_error(self): return { "message_type": "error", - "message": _("Unrecoverable error, please restart."), + "body": _("Unrecoverable error, please restart."), } def x_units_put_in_package(self, qty, product, package): return { "message_type": "success", - "message": _("{} {} put in {}").format( + "body": _("{} {} put in {}").format( qty, product.display_name, package.name ), } @@ -182,19 +178,19 @@ def x_units_put_in_package(self, qty, product, package): def cannot_move_something_in_picking_type(self): return { "message_type": "error", - "message": _("You cannot move this using this menu."), + "body": _("You cannot move this using this menu."), } def stock_picking_not_available(self, picking): return { "message_type": "error", - "message": _("Transfer {} is not available.").format(picking.name), + "body": _("Transfer {} is not available.").format(picking.name), } def product_multiple_packages_scan_package(self): return { "message_type": "warning", - "message": _( + "body": _( "This product is part of multiple packages, please scan a package." ), } @@ -202,13 +198,11 @@ def product_multiple_packages_scan_package(self): def lot_multiple_packages_scan_package(self): return { "message_type": "warning", - "message": _( - "This lot is part of multiple packages, please scan a package." - ), + "body": _("This lot is part of multiple packages, please scan a package."), } def batch_transfer_complete(self): return { "message_type": "success", - "message": _("Batch Transfer complete"), + "body": _("Batch Transfer complete"), } diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index e5afa78e24..536e2465a3 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -76,7 +76,7 @@ def scan_document(self, barcode): return self._response_for_select_document( message={ "message_type": "error", - "message": _( + "body": _( "Several transfers found, please scan a package" " or select a transfer manually." ), @@ -293,7 +293,7 @@ def _select_lines_from_package(self, picking, selection_lines, package): picking, message={ "message_type": "error", - "message": _("Package {} is not in the current transfer.").format( + "body": _("Package {} is not in the current transfer.").format( package.name ), }, @@ -314,7 +314,7 @@ def _select_lines_from_product(self, picking, selection_lines, product): picking, message={ "message_type": "error", - "message": _("Product is not in the current transfer."), + "body": _("Product is not in the current transfer."), }, ) @@ -346,7 +346,7 @@ def _select_lines_from_lot(self, picking, selection_lines, lot): picking, message={ "message_type": "error", - "message": _("Lot is not in the current transfer."), + "body": _("Lot is not in the current transfer."), }, ) @@ -447,7 +447,7 @@ def _change_line_qty( if qty_done > move_line.product_uom_qty: qty_done = move_line.product_uom_qty message = { - "message": _( + "body": _( "Not allowed to pack more than the quantity, " "the value has been changed to the maximum." ), @@ -455,7 +455,7 @@ def _change_line_qty( } if qty_done < 0: message = { - "message": _("Negative quantity not allowed."), + "body": _("Negative quantity not allowed."), "message_type": "error", } else: @@ -558,9 +558,7 @@ def _put_lines_in_package(self, picking, selected_lines, package): selected_lines, message={ "message_type": "error", - "message": _("Not a valid destination package").format( - package.name - ), + "body": _("Not a valid destination package").format(package.name), }, ) return self._put_lines_in_allowed_package(picking, selected_lines, package) @@ -575,7 +573,7 @@ def _put_lines_in_allowed_package(self, picking, selected_lines, package): picking, message={ "message_type": "info", - "message": _("Product(s) packed in {}").format(package.name), + "body": _("Product(s) packed in {}").format(package.name), }, ) @@ -703,7 +701,7 @@ def no_package(self, picking_id, selected_line_ids): picking, message={ "message_type": "info", - "message": _("Product(s) processed as raw product(s)"), + "body": _("Product(s) processed as raw product(s)"), }, ) @@ -733,7 +731,7 @@ def _response_for_select_dest_package(self, picking, move_lines, message=None): move_lines, message={ "message_type": "warning", - "message": _("No valid package to select."), + "body": _("No valid package to select."), }, ) data_struct = self.actions_for("data") @@ -759,9 +757,7 @@ def _set_dest_package_from_selection(self, picking, selected_lines, package): selected_lines, message={ "message_type": "error", - "message": _("Not a valid destination package").format( - package.name - ), + "body": _("Not a valid destination package").format(package.name), }, ) return self._put_lines_in_allowed_package(picking, selected_lines, package) @@ -883,7 +879,7 @@ def set_packaging(self, picking_id, package_id, packaging_id): picking, message={ "message_type": "success", - "message": _("Packaging changed on package {}").format(package.name), + "body": _("Packaging changed on package {}").format(package.name), }, ) @@ -955,7 +951,7 @@ def done(self, picking_id, confirmation=False): need_confirm=True, message={ "message_type": "warning", - "message": _( + "body": _( "Not all lines have been processed with full quantity. " "Do you confirm partial operation?" ), @@ -967,16 +963,14 @@ def done(self, picking_id, confirmation=False): need_confirm=True, message={ "message_type": "warning", - "message": _( - "Remaining raw product not packed, proceed anyway?" - ), + "body": _("Remaining raw product not packed, proceed anyway?"), }, ) picking.action_done() return self._response_for_select_document( message={ "message_type": "success", - "message": _("Transfer {} done").format(picking.name), + "body": _("Transfer {} done").format(picking.name), } ) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 849faa01da..edc7f6844e 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -181,9 +181,7 @@ def find_batch(self): return self._response_for_start( message={ "message_type": "info", - "message": _( - "No more work to do, please create a new batch transfer" - ), + "body": _("No more work to do, please create a new batch transfer"), }, ) @@ -285,7 +283,7 @@ def select(self, picking_batch_id): base_response=self.list_batch(), message={ "message_type": "warning", - "message": _("This batch cannot be selected."), + "body": _("This batch cannot be selected."), }, ) @@ -551,7 +549,7 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): move_line, message={ "message_type": "error", - "message": _("You must not pick more than {} units.").format( + "body": _("You must not pick more than {} units.").format( move_line.product_uom_qty ), }, @@ -581,7 +579,7 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): move_line, message={ "message_type": "error", - "message": _( + "body": _( "The destination bin {} is not empty, please take another." ).format(bin_package.name), }, @@ -925,7 +923,7 @@ def unload_scan_pack(self, picking_batch_id, package_id, barcode): return self._response_for_unload_single( batch, package, - message={"message_type": "error", "message": _("Wrong bin")}, + message={"message_type": "error", "body": _("Wrong bin")}, ) return self._response_for_unload_set_destination(batch, package) diff --git a/shopfloor/services/scan_anything.py b/shopfloor/services/scan_anything.py index 9cfe8be26d..38371070fd 100644 --- a/shopfloor/services/scan_anything.py +++ b/shopfloor/services/scan_anything.py @@ -52,7 +52,7 @@ def _response_for_found(self, data): def _response_for_not_found(self, tried): message = { - "message": _( + "body": _( "Record not found.\n" "We've tried with the following types: {}" ).format(", ".join(tried)), "message_type": "error", diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 3473b215ce..3345a2f48e 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -45,7 +45,7 @@ def _response_for_forbidden_start(self, existing_operations): next_state="start", message={ "message_type": "error", - "message": _( + "body": _( "An operation exists in %s %s. " "You cannot process it with this shopfloor scenario." ) diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py index 353cfa0b10..dd9a2d1fb3 100644 --- a/shopfloor/services/validator.py +++ b/shopfloor/services/validator.py @@ -94,7 +94,7 @@ def _response_schema(self, data_schema=None, next_states=None): "required": True, "allowed": ["info", "warning", "error", "success"], }, - "message": {"type": "string", "required": True}, + "body": {"type": "string", "required": True}, }, }, "popup": { diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index f4ba02ca4c..966207b53f 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -31,6 +31,8 @@ class CommonCase(SavepointCase, ComponentMixin): ANY = AnyObject() + maxDiff = None + @contextmanager def work_on_services(self, **params): params = params or {} diff --git a/shopfloor/tests/test_checkout_cancel_line.py b/shopfloor/tests/test_checkout_cancel_line.py index 5f0589c73c..2dce5bca77 100644 --- a/shopfloor/tests/test_checkout_cancel_line.py +++ b/shopfloor/tests/test_checkout_cancel_line.py @@ -130,7 +130,7 @@ def test_cancel_line_error_package_not_found(self): }, message={ "message_type": "error", - "message": "The record you were working on does not exist anymore.", + "body": "The record you were working on does not exist anymore.", }, ) @@ -148,6 +148,6 @@ def test_cancel_line_error_line_not_found(self): }, message={ "message_type": "error", - "message": "The record you were working on does not exist anymore.", + "body": "The record you were working on does not exist anymore.", }, ) diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py index ea1c4c6e74..e88d312a18 100644 --- a/shopfloor/tests/test_checkout_change_packaging.py +++ b/shopfloor/tests/test_checkout_change_packaging.py @@ -75,7 +75,7 @@ def test_list_packaging_error_package_not_found(self): }, message={ "message_type": "error", - "message": "The record you were working on does not exist anymore.", + "body": "The record you were working on does not exist anymore.", }, ) @@ -100,7 +100,7 @@ def test_set_packaging_ok(self): }, message={ "message_type": "success", - "message": "Packaging changed on package {}".format(self.package.name), + "body": "Packaging changed on package {}".format(self.package.name), }, ) @@ -122,7 +122,7 @@ def test_set_packaging_error_package_not_found(self): }, message={ "message_type": "error", - "message": "The record you were working on does not exist anymore.", + "body": "The record you were working on does not exist anymore.", }, ) @@ -144,6 +144,6 @@ def test_set_packaging_error_packaging_not_found(self): }, message={ "message_type": "error", - "message": "The record you were working on does not exist anymore.", + "body": "The record you were working on does not exist anymore.", }, ) diff --git a/shopfloor/tests/test_checkout_done.py b/shopfloor/tests/test_checkout_done.py index 2ddb794c7c..8af9442973 100644 --- a/shopfloor/tests/test_checkout_done.py +++ b/shopfloor/tests/test_checkout_done.py @@ -17,7 +17,7 @@ def test_done_ok(self): next_state="select_document", message={ "message_type": "success", - "message": "Transfer {} done".format(picking.name), + "body": "Transfer {} done".format(picking.name), }, ) @@ -48,7 +48,7 @@ def test_done_partial(self): data={"picking": self._stock_picking_data(self.picking, done=True)}, message={ "message_type": "warning", - "message": "Not all lines have been processed with full quantity. " + "body": "Not all lines have been processed with full quantity. " "Do you confirm partial operation?", }, ) @@ -67,7 +67,7 @@ def test_done_partial_confirm(self): next_state="select_document", message={ "message_type": "success", - "message": "Transfer {} done".format(self.picking.name), + "body": "Transfer {} done".format(self.picking.name), }, ) @@ -105,7 +105,7 @@ def test_done_partial(self): data={"picking": self._stock_picking_data(self.picking, done=True)}, message={ "message_type": "warning", - "message": "Remaining raw product not packed, proceed anyway?", + "body": "Remaining raw product not packed, proceed anyway?", }, ) @@ -126,6 +126,6 @@ def test_done_partial_confirm(self): next_state="select_document", message={ "message_type": "success", - "message": "Transfer {} done".format(self.picking.name), + "body": "Transfer {} done".format(self.picking.name), }, ) diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py index 78af3be96c..2055f2041a 100644 --- a/shopfloor/tests/test_checkout_list_package.py +++ b/shopfloor/tests/test_checkout_list_package.py @@ -84,10 +84,7 @@ def test_list_dest_package_error_no_package(self): self._assert_selected_response( response, picking.move_line_ids, - message={ - "message_type": "warning", - "message": "No valid package to select.", - }, + message={"message_type": "warning", "body": "No valid package to select."}, ) @@ -148,7 +145,7 @@ def _assert_package_set(self, response): data={"picking": self._stock_picking_data(self.picking)}, message={ "message_type": "info", - "message": "Product(s) packed in {}".format(self.pack1.name), + "body": "Product(s) packed in {}".format(self.pack1.name), }, ) @@ -194,7 +191,7 @@ def test_scan_dest_package_error_not_allowed(self): self.allowed_packages, message={ "message_type": "error", - "message": "Not a valid destination package", + "body": "Not a valid destination package", }, ) @@ -239,6 +236,6 @@ def test_set_dest_package_error_not_allowed(self): self.allowed_packages, message={ "message_type": "error", - "message": "Not a valid destination package", + "body": "Not a valid destination package", }, ) diff --git a/shopfloor/tests/test_checkout_new_package.py b/shopfloor/tests/test_checkout_new_package.py index ab720ce5ae..46403ac9cb 100644 --- a/shopfloor/tests/test_checkout_new_package.py +++ b/shopfloor/tests/test_checkout_new_package.py @@ -57,6 +57,6 @@ def test_new_package_ok(self): data={"picking": self._stock_picking_data(picking)}, message={ "message_type": "info", - "message": "Product(s) packed in {}".format(new_package.name), + "body": "Product(s) packed in {}".format(new_package.name), }, ) diff --git a/shopfloor/tests/test_checkout_no_package.py b/shopfloor/tests/test_checkout_no_package.py index 2da2f732a6..052c8626c4 100644 --- a/shopfloor/tests/test_checkout_no_package.py +++ b/shopfloor/tests/test_checkout_no_package.py @@ -49,6 +49,6 @@ def test_no_package_ok(self): data={"picking": self._stock_picking_data(picking)}, message={ "message_type": "info", - "message": "Product(s) processed as raw product(s)", + "body": "Product(s) processed as raw product(s)", }, ) diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py index 43ba2cc6dd..672bfec403 100644 --- a/shopfloor/tests/test_checkout_scan.py +++ b/shopfloor/tests/test_checkout_scan.py @@ -28,7 +28,7 @@ def test_scan_document_error_not_found(self): self.assert_response( response, next_state="select_document", - message={"message_type": "error", "message": "Barcode not found"}, + message={"message_type": "error", "body": "Barcode not found"}, ) def _test_scan_document_error_not_available(self, barcode_func): @@ -49,7 +49,7 @@ def _test_scan_document_error_not_available(self, barcode_func): next_state="select_document", message={ "message_type": "error", - "message": "Transfer {} is not available.".format(picking.name), + "body": "Transfer {} is not available.".format(picking.name), }, ) @@ -77,7 +77,7 @@ def test_scan_document_error_location_not_child_of_type(self): self.assert_response( response, next_state="select_document", - message={"message_type": "error", "message": "Location not allowed here."}, + message={"message_type": "error", "body": "Location not allowed here."}, ) def _test_scan_document_error_different_picking_type(self, barcode_func): @@ -91,7 +91,7 @@ def _test_scan_document_error_different_picking_type(self, barcode_func): next_state="select_document", message={ "message_type": "error", - "message": "You cannot move this using this menu.", + "body": "You cannot move this using this menu.", }, ) @@ -122,7 +122,7 @@ def test_scan_document_error_location_several_pickings(self): next_state="select_document", message={ "message_type": "error", - "message": "Several transfers found, please scan a package" + "body": "Several transfers found, please scan a package" " or select a transfer manually.", }, ) diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py index 720bfb9973..e0063f515d 100644 --- a/shopfloor/tests/test_checkout_scan_line.py +++ b/shopfloor/tests/test_checkout_scan_line.py @@ -125,7 +125,7 @@ def test_scan_line_error_barcode_not_found(self): self._test_scan_line_error( picking, "NOT A BARCODE", - {"message_type": "error", "message": "Barcode not found"}, + {"message_type": "error", "body": "Barcode not found"}, ) def test_scan_line_error_package_not_in_picking(self): @@ -141,7 +141,7 @@ def test_scan_line_error_package_not_in_picking(self): package.name, { "message_type": "error", - "message": "Package {} is not in the current transfer.".format( + "body": "Package {} is not in the current transfer.".format( package.name ), }, @@ -159,7 +159,7 @@ def test_scan_line_error_product_tracked_by_lot(self): self.product_a.barcode, { "message_type": "warning", - "message": "Product tracked by lot, please scan one.", + "body": "Product tracked by lot, please scan one.", }, ) @@ -177,7 +177,7 @@ def test_scan_line_error_product_in_two_packages(self): self.product_a.barcode, { "message_type": "warning", - "message": "This product is part of multiple" + "body": "This product is part of multiple" " packages, please scan a package.", }, ) @@ -198,7 +198,7 @@ def test_scan_line_error_product_in_one_package_and_unit(self): self.product_a.barcode, { "message_type": "warning", - "message": "This product is part of multiple" + "body": "This product is part of multiple" " packages, please scan a package.", }, ) @@ -212,7 +212,7 @@ def test_scan_line_error_product_not_in_picking(self): self.product_b.barcode, { "message_type": "error", - "message": "Product is not in the current transfer.", + "body": "Product is not in the current transfer.", }, ) @@ -226,7 +226,7 @@ def test_scan_line_error_lot_not_in_picking(self): self._test_scan_line_error( picking, lot.name, - {"message_type": "error", "message": "Lot is not in the current transfer."}, + {"message_type": "error", "body": "Lot is not in the current transfer."}, ) def test_scan_line_error_lot_in_two_packages(self): @@ -247,7 +247,7 @@ def test_scan_line_error_lot_in_two_packages(self): lot.name, { "message_type": "warning", - "message": "This lot is part of multiple" + "body": "This lot is part of multiple" " packages, please scan a package.", }, ) @@ -270,7 +270,7 @@ def test_scan_line_error_lot_in_one_package_and_unit(self): lot.name, { "message_type": "warning", - "message": "This lot is part of multiple" + "body": "This lot is part of multiple" " packages, please scan a package.", }, ) diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py index b9d5b870b1..dcc78ce607 100644 --- a/shopfloor/tests/test_checkout_scan_package_action.py +++ b/shopfloor/tests/test_checkout_scan_package_action.py @@ -99,7 +99,7 @@ def _test_scan_package_action_scan_product_error_tracked_by( {move_line: origin_qty_done}, message={ "message_type": "warning", - "message": "Product tracked by lot, please scan one.", + "body": "Product tracked by lot, please scan one.", }, ) @@ -166,7 +166,7 @@ def test_scan_package_action_scan_package_keep_source_package_ok(self): data={"picking": self._stock_picking_data(picking)}, message={ "message_type": "info", - "message": "Product(s) packed in {}".format(pack1.name), + "body": "Product(s) packed in {}".format(pack1.name), }, ) @@ -204,7 +204,7 @@ def test_scan_package_action_scan_package_error_invalid(self): selected_line, message={ "message_type": "error", - "message": "Not a valid destination package", + "body": "Not a valid destination package", }, ) @@ -265,7 +265,7 @@ def test_scan_package_action_scan_package_use_existing_package_ok(self): }, message={ "message_type": "info", - "message": "Product(s) packed in {}".format(package.name), + "body": "Product(s) packed in {}".format(package.name), }, ) @@ -350,7 +350,7 @@ def test_scan_package_action_scan_packaging_ok(self): data={"picking": self._stock_picking_data(picking)}, message={ "message_type": "info", - "message": "Product(s) packed in {}".format(new_package.name), + "body": "Product(s) packed in {}".format(new_package.name), }, ) @@ -372,5 +372,5 @@ def test_scan_package_action_scan_not_found(self): self._assert_selected_response( response, selected_line, - message={"message_type": "error", "message": "Barcode not found"}, + message={"message_type": "error", "body": "Barcode not found"}, ) diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py index c8e30df13a..c22c47cb93 100644 --- a/shopfloor/tests/test_checkout_select.py +++ b/shopfloor/tests/test_checkout_select.py @@ -46,7 +46,7 @@ def _test_error(self, picking, msg): self.assert_response( response, next_state="manual_selection", - message={"message_type": "error", "message": msg}, + message={"message_type": "error", "body": msg}, data={"pickings": [self._picking_summary_data(self.picking)]}, ) diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py index c327ab989a..dede5bfd37 100644 --- a/shopfloor/tests/test_checkout_select_line.py +++ b/shopfloor/tests/test_checkout_select_line.py @@ -76,7 +76,7 @@ def test_select_line_package_error_not_found(self): {"picking_id": self.picking.id, "package_id": selected_lines[0].id}, { "message_type": "error", - "message": "The record you were working on does not exist anymore.", + "body": "The record you were working on does not exist anymore.", }, ) @@ -87,7 +87,7 @@ def test_select_line_move_line_error_not_found(self): {"picking_id": self.picking.id, "move_line_id": selected_lines[0].id}, { "message_type": "error", - "message": "The record you were working on does not exist anymore.", + "body": "The record you were working on does not exist anymore.", }, ) diff --git a/shopfloor/tests/test_checkout_set_qty.py b/shopfloor/tests/test_checkout_set_qty.py index d6a91c9028..39a6ee694f 100644 --- a/shopfloor/tests/test_checkout_set_qty.py +++ b/shopfloor/tests/test_checkout_set_qty.py @@ -68,7 +68,7 @@ def test_reset_line_qty_not_found(self): selected_lines, {line: line.product_uom_qty for line in selected_lines}, message={ - "message": "The record you were working on does not exist anymore.", + "body": "The record you were working on does not exist anymore.", "message_type": "error", }, ) @@ -116,7 +116,7 @@ def test_set_line_qty_not_found(self): selected_lines, {line: line.product_uom_qty for line in selected_lines}, message={ - "message": "The record you were working on does not exist anymore.", + "body": "The record you were working on does not exist anymore.", "message_type": "error", }, ) @@ -165,7 +165,7 @@ def test_set_custom_qty_not_found(self): selected_lines, {line: line.product_uom_qty for line in selected_lines}, message={ - "message": "The record you were working on does not exist anymore.", + "body": "The record you were working on does not exist anymore.", "message_type": "error", }, ) @@ -190,7 +190,7 @@ def test_set_custom_qty_above(self): selected_lines, {line1: line1.product_uom_qty, line2: line2.product_uom_qty}, message={ - "message": "Not allowed to pack more than the quantity, " + "body": "Not allowed to pack more than the quantity, " "the value has been changed to the maximum.", "message_type": "warning", }, @@ -214,7 +214,7 @@ def test_set_custom_qty_negative(self): selected_lines, {line1: line1.product_uom_qty, line2: line2.product_uom_qty}, message={ - "message": "Negative quantity not allowed.", + "body": "Negative quantity not allowed.", "message_type": "error", }, ) diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index 181b9e0e15..bf37e8767c 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -69,7 +69,7 @@ def test_scan_line_error_product_tracked(self): line.product_id.barcode, { "message_type": "warning", - "message": "Product tracked by lot, please scan one.", + "body": "Product tracked by lot, please scan one.", }, ) @@ -89,7 +89,7 @@ def test_scan_line_product_error_several_packages(self): move.product_id.barcode, { "message_type": "warning", - "message": "This product is part of multiple" + "body": "This product is part of multiple" " packages, please scan a package.", }, ) @@ -110,7 +110,7 @@ def test_scan_line_product_error_in_one_package_and_unit(self): move.product_id.barcode, { "message_type": "warning", - "message": "This product is part of multiple" + "body": "This product is part of multiple" " packages, please scan a package.", }, ) @@ -131,7 +131,7 @@ def test_scan_line_lot_error_several_packages(self): line.lot_id.name, { "message_type": "warning", - "message": "This lot is part of multiple" + "body": "This lot is part of multiple" " packages, please scan a package.", }, ) @@ -151,7 +151,7 @@ def test_scan_line_lot_error_in_one_package_and_unit(self): line.lot_id.name, { "message_type": "warning", - "message": "This lot is part of multiple" + "body": "This lot is part of multiple" " packages, please scan a package.", }, ) @@ -206,7 +206,7 @@ def test_scan_line_location_error_several_package(self): location.barcode, { "message_type": "warning", - "message": "Several packages found in Stock, please scan a package.", + "body": "Several packages found in Stock, please scan a package.", }, ) @@ -225,7 +225,7 @@ def test_scan_line_location_error_several_products(self): location.barcode, { "message_type": "warning", - "message": "Several products found in Stock, please scan a product.", + "body": "Several products found in Stock, please scan a product.", }, ) @@ -247,7 +247,7 @@ def test_scan_line_location_error_several_lots(self): location.barcode, { "message_type": "warning", - "message": "Several lots found in Stock, please scan a lot.", + "body": "Several lots found in Stock, please scan a lot.", }, ) @@ -257,7 +257,7 @@ def test_scan_line_error_not_found(self): self._scan_line_error( self.batch.picking_ids.move_line_ids, "NO_EXISTING_BARCODE", - {"message_type": "error", "message": "Barcode not found"}, + {"message_type": "error", "body": "Barcode not found"}, ) @@ -314,7 +314,7 @@ def test_scan_destination_pack_ok(self): data=self._line_data(next_line), message={ "message_type": "success", - "message": "{} {} put in {}".format( + "body": "{} {} put in {}".format( line.qty_done, line.product_id.display_name, self.bin1.name ), }, @@ -402,7 +402,7 @@ def test_scan_destination_pack_not_empty_different_picking(self): data=self._line_data(line), message={ "message_type": "error", - "message": "The destination bin {} is not empty," + "body": "The destination bin {} is not empty," " please take another.".format(self.bin1.name), }, ) @@ -425,7 +425,7 @@ def test_scan_destination_pack_bin_not_found(self): data=self._line_data(line), message={ "message_type": "error", - "message": "Bin {} doesn't exist".format("⌿"), + "body": "Bin {} doesn't exist".format("⌿"), }, ) @@ -446,7 +446,7 @@ def test_scan_destination_pack_quantity_more(self): data=self._line_data(line), message={ "message_type": "error", - "message": "You must not pick more than {} units.".format( + "body": "You must not pick more than {} units.".format( line.product_uom_qty ), }, @@ -482,7 +482,7 @@ def test_scan_destination_pack_quantity_less(self): data=self._line_data(new_line), message={ "message_type": "success", - "message": "{} {} put in {}".format( + "body": "{} {} put in {}".format( line.qty_done, line.product_id.display_name, self.bin1.name ), }, @@ -565,7 +565,7 @@ def test_is_zero_is_empty(self): data=self._line_data(self.next_line), message={ "message_type": "success", - "message": "{} {} put in {}".format( + "body": "{} {} put in {}".format( self.line.qty_done, self.line.product_id.display_name, self.bin1.name, @@ -596,7 +596,7 @@ def test_is_zero_is_not_empty(self): data=self._line_data(self.next_line), message={ "message_type": "success", - "message": "{} {} put in {}".format( + "body": "{} {} put in {}".format( self.line.qty_done, self.line.product_id.display_name, self.bin1.name, diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py index 00ef30dbb2..14c5285403 100644 --- a/shopfloor/tests/test_cluster_picking_select.py +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -158,7 +158,7 @@ def test_find_batch_not_found(self): next_state="start", message={ "message_type": "info", - "message": "No more work to do, please create a new batch transfer", + "body": "No more work to do, please create a new batch transfer", }, ) @@ -285,7 +285,7 @@ def test_select_not_exists(self): next_state="manual_selection", message={ "message_type": "warning", - "message": "This batch cannot be selected.", + "body": "This batch cannot be selected.", }, data={"size": 0, "records": []}, ) @@ -305,7 +305,7 @@ def test_select_already_assigned(self): next_state="manual_selection", message={ "message_type": "warning", - "message": "This batch cannot be selected.", + "body": "This batch cannot be selected.", }, data={"size": 0, "records": []}, ) @@ -409,7 +409,7 @@ def test_confirm_start_not_exists(self): response, message={ "message_type": "error", - "message": "The record you were working on does not exist anymore.", + "body": "The record you were working on does not exist anymore.", }, next_state="start", ) @@ -431,5 +431,5 @@ def test_confirm_start_all_is_done(self): self.assert_response( response, next_state="start", - message={"message": "Batch Transfer complete", "message_type": "success"}, + message={"body": "Batch Transfer complete", "message_type": "success"}, ) diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index 337be573ae..1205ec10b9 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -157,7 +157,7 @@ def test_set_destination_all_ok(self): self.assert_response( response, next_state="start", - message={"message_type": "success", "message": "Batch Transfer complete"}, + message={"message_type": "success", "body": "Batch Transfer complete"}, ) def test_set_destination_all_remaining_lines(self): @@ -274,7 +274,7 @@ def test_set_destination_all_error_location_not_found(self): }, message={ "message_type": "error", - "message": "No location found for this barcode.", + "body": "No location found for this barcode.", }, ) @@ -306,7 +306,7 @@ def test_set_destination_all_error_location_invalid(self): "name": move_lines[0].location_dest_id.name, }, }, - message={"message_type": "error", "message": "You cannot place it here"}, + message={"message_type": "error", "body": "You cannot place it here"}, ) def test_set_destination_all_need_confirmation(self): @@ -360,7 +360,7 @@ def test_set_destination_all_with_confirmation(self): self.assert_response( response, next_state="start", - message={"message_type": "success", "message": "Batch Transfer complete"}, + message={"message_type": "success", "body": "Batch Transfer complete"}, ) @@ -471,7 +471,7 @@ def test_unload_scan_pack_wrong_barcode(self): "name": self.move_lines[0].location_dest_id.name, }, }, - message={"message_type": "error", "message": "Wrong bin"}, + message={"message_type": "error", "body": "Wrong bin"}, ) @@ -679,7 +679,7 @@ def test_unload_scan_destination_last_line(self): self.assert_response( response, next_state="start", - message={"message": "Batch Transfer complete", "message_type": "success"}, + message={"body": "Batch Transfer complete", "message_type": "success"}, ) def test_unload_scan_destination_error_location_not_found(self): @@ -706,7 +706,7 @@ def test_unload_scan_destination_error_location_not_found(self): }, message={ "message_type": "error", - "message": "No location found for this barcode.", + "body": "No location found for this barcode.", }, ) @@ -736,7 +736,7 @@ def test_unload_scan_destination_error_location_invalid(self): "name": self.bin1_lines[0].location_dest_id.name, }, }, - message={"message_type": "error", "message": "You cannot place it here"}, + message={"message_type": "error", "body": "You cannot place it here"}, ) def test_unload_scan_destination_need_confirmation(self): diff --git a/shopfloor/tests/test_scan_anything.py b/shopfloor/tests/test_scan_anything.py index c3af31cab5..81616439f4 100644 --- a/shopfloor/tests/test_scan_anything.py +++ b/shopfloor/tests/test_scan_anything.py @@ -20,9 +20,9 @@ def _test_response_ko(self, identifier, tried=None): response = self.service.dispatch("scan", params=params) message = response["message"] self.assertEqual(message["message_type"], "error") - self.assertIn("Record not found", message["message"]) + self.assertIn("Record not found", message["body"]) for rec_type in tried: - self.assertIn(rec_type, message["message"]) + self.assertIn(rec_type, message["body"]) def test_scan_product(self): record = self.product_b diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 6428638418..31d260afa5 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -76,10 +76,7 @@ def test_start(self): self.assert_response( response, next_state="scan_location", - message={ - "message_type": "info", - "message": "Scan the destination location", - }, + message={"message_type": "info", "body": "Scan the destination location"}, data={ "id": self.ANY, "location_src": { @@ -111,7 +108,7 @@ def test_start_no_package_for_barcode(self): next_state="start", message={ "message_type": "error", - "message": "The package NOTHING_SHOULD_EXIST_WITH: 👀 doesn't exist", + "body": "The package NOTHING_SHOULD_EXIST_WITH: 👀 doesn't exist", }, ) @@ -137,7 +134,7 @@ def test_start_package_not_in_src_location(self): next_state="start", message={ "message_type": "error", - "message": "You cannot work on a package (%s) outside of locations: %s" + "body": "You cannot work on a package (%s) outside of locations: %s" % ( self.pack_a.name, self.menu.picking_type_ids.default_location_src_id.name, @@ -178,7 +175,7 @@ def test_start_move_in_different_picking_type(self): next_state="start", message={ "message_type": "error", - "message": "An operation exists in Delivery Orders %s. You cannot" + "body": "An operation exists in Delivery Orders %s. You cannot" " process it with this shopfloor scenario." % (picking.name,), }, ) @@ -212,7 +209,7 @@ def test_start_move_already_exist(self): next_state="confirm_start", message={ "message_type": "warning", - "message": "Operation's already running." + "body": "Operation's already running." " Would you like to take it over?", }, data={ @@ -278,7 +275,7 @@ def test_validate(self): next_state="start", message={ "message_type": "success", - "message": "The pack has been moved, you can scan a new pack.", + "body": "The pack has been moved, you can scan a new pack.", }, ) @@ -308,7 +305,7 @@ def test_validate_not_found(self): next_state="start", message={ "message_type": "error", - "message": "This operation does not exist anymore.", + "body": "This operation does not exist anymore.", }, ) @@ -340,7 +337,7 @@ def test_validate_location_not_found(self): next_state="scan_location", message={ "message_type": "error", - "message": "No location found for this barcode.", + "body": "No location found for this barcode.", }, data=self.ANY, ) @@ -375,7 +372,7 @@ def test_validate_location_forbidden(self): self.assert_response( response, next_state="scan_location", - message={"message_type": "error", "message": "You cannot place it here"}, + message={"message_type": "error", "body": "You cannot place it here"}, data=self.ANY, ) @@ -469,7 +466,7 @@ def test_validate_location_with_confirm(self): next_state="start", message={ "message_type": "success", - "message": "The pack has been moved, you can scan a new pack.", + "body": "The pack has been moved, you can scan a new pack.", }, ) @@ -516,7 +513,7 @@ def test_cancel(self): next_state="start", message={ "message_type": "info", - "message": "Canceled, you can scan a new pack.", + "body": "Canceled, you can scan a new pack.", }, ) @@ -559,7 +556,7 @@ def test_cancel_already_canceled(self): next_state="start", message={ "message_type": "info", - "message": "Canceled, you can scan a new pack.", + "body": "Canceled, you can scan a new pack.", }, ) @@ -596,7 +593,7 @@ def test_cancel_already_done(self): self.assert_response( response, next_state="start", - message={"message_type": "info", "message": "Operation already processed."}, + message={"message_type": "info", "body": "Operation already processed."}, ) def test_cancel_not_found(self): @@ -612,6 +609,6 @@ def test_cancel_not_found(self): next_state="start", message={ "message_type": "error", - "message": "This operation does not exist anymore.", + "body": "This operation does not exist anymore.", }, ) diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index 348baf34ae..ec78610073 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -141,9 +141,7 @@ def test_start_no_operation(self): next_state="start", message={ "message_type": "error", - "message": "No pending operation for package {}.".format( - self.pack_a.name - ), + "body": "No pending operation for package {}.".format(self.pack_a.name), }, ) @@ -165,7 +163,7 @@ def test_start_barcode_not_known(self): next_state="start", message={ "message_type": "error", - "message": "The package THIS_BARCODE_DOES_NOT_EXIST" " doesn't exist", + "body": "The package THIS_BARCODE_DOES_NOT_EXIST" " doesn't exist", }, ) @@ -222,7 +220,7 @@ def test_start_pack_from_location_empty(self): next_state="start", message={ "message_type": "error", - "message": "Location %s doesn't contain any package." + "body": "Location %s doesn't contain any package." % (self.shelf2.name,), }, ) @@ -260,7 +258,7 @@ def test_start_pack_from_location_several_packs(self): next_state="start", message={ "message_type": "warning", - "message": "Several packages found in %s, please scan a package." + "body": "Several packages found in %s, please scan a package." % (self.shelf1.name,), }, ) @@ -286,7 +284,7 @@ def test_start_pack_outside_of_location(self): next_state="start", message={ "message_type": "error", - "message": "You cannot work on a package (%s) outside of locations: %s" + "body": "You cannot work on a package (%s) outside of locations: %s" % (self.pack_a.name, self.picking_type.default_location_src_id.name), }, ) @@ -325,7 +323,7 @@ def test_start_already_started(self): next_state="confirm_start", message={ "message_type": "warning", - "message": "Operation's already running." + "body": "Operation's already running." " Would you like to take it over?", }, data=self._response_package_level_data(package_level), @@ -362,7 +360,7 @@ def test_validate(self): next_state="start", message={ "message_type": "success", - "message": "The pack has been moved, you can scan a new pack.", + "body": "The pack has been moved, you can scan a new pack.", }, ) @@ -434,7 +432,7 @@ def test_validate_completion_info(self): }, message={ "message_type": "success", - "message": "The pack has been moved, you can scan a new pack.", + "body": "The pack has been moved, you can scan a new pack.", }, ) @@ -455,7 +453,7 @@ def test_validate_not_found(self): next_state="start", message={ "message_type": "error", - "message": "This operation does not exist anymore.", + "body": "This operation does not exist anymore.", }, ) @@ -488,7 +486,7 @@ def test_validate_location_not_found(self): data=self.ANY, message={ "message_type": "error", - "message": "No location found for this barcode.", + "body": "No location found for this barcode.", }, ) @@ -523,7 +521,7 @@ def test_validate_location_forbidden(self): response, next_state="scan_location", data=self.ANY, - message={"message_type": "error", "message": "You cannot place it here"}, + message={"message_type": "error", "body": "You cannot place it here"}, ) def test_validate_location_to_confirm(self): @@ -605,7 +603,7 @@ def test_validate_location_with_confirm(self): next_state="start", message={ "message_type": "success", - "message": "The pack has been moved, you can scan a new pack.", + "body": "The pack has been moved, you can scan a new pack.", }, ) @@ -651,7 +649,7 @@ def test_cancel(self): next_state="start", message={ "message_type": "info", - "message": "Canceled, you can scan a new pack.", + "body": "Canceled, you can scan a new pack.", }, ) @@ -693,7 +691,7 @@ def test_cancel_already_canceled(self): next_state="start", message={ "message_type": "info", - "message": "Canceled, you can scan a new pack.", + "body": "Canceled, you can scan a new pack.", }, ) @@ -730,7 +728,7 @@ def test_cancel_already_done(self): self.assert_response( response, next_state="start", - message={"message_type": "info", "message": "Operation already processed."}, + message={"message_type": "info", "body": "Operation already processed."}, ) def test_cancel_not_found(self): @@ -746,6 +744,6 @@ def test_cancel_not_found(self): next_state="start", message={ "message_type": "error", - "message": "This operation does not exist anymore.", + "body": "This operation does not exist anymore.", }, ) From 6c0d138a69534a03a3371947aef26e7d981563ba Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 18 May 2020 08:36:34 +0200 Subject: [PATCH 211/940] pack transfer: Fix test I did not rebase before merging the PR :/ --- shopfloor/tests/test_single_pack_transfer.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index ec78610073..510eaced85 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -61,8 +61,16 @@ def _response_package_level_data(self, package_level): return { "id": package_level.id, "name": package_level.package_id.name, - "location_src": {"id": self.shelf1.id, "name": self.shelf1.name}, - "location_dest": {"id": self.shelf2.id, "name": self.shelf2.name}, + "location_src": { + "id": self.shelf1.id, + "name": self.shelf1.name, + "barcode": self.shelf1.barcode, + }, + "location_dest": { + "id": self.shelf2.id, + "name": self.shelf2.name, + "barcode": self.shelf2.barcode, + }, "picking": { "id": self.picking.id, "name": self.picking.name, @@ -76,6 +84,7 @@ def _response_package_level_data(self, package_level): "id": self.product_a.id, "name": self.product_a.name, "default_code": self.product_a.default_code, + "barcode": self.product_a.barcode, "display_name": self.product_a.display_name, }, } From 9b1268b847bc948a6281455342bcb7ac383abf64 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 26 Feb 2020 11:56:31 +0100 Subject: [PATCH 212/940] cluster picking: implement /stock_issue Co-authored-by: Simone Orsi --- shopfloor/actions/inventory.py | 97 +++++- shopfloor/services/cluster_picking.py | 67 +++- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_cluster_picking_base.py | 11 +- .../tests/test_cluster_picking_stock_issue.py | 308 ++++++++++++++++++ 5 files changed, 474 insertions(+), 10 deletions(-) create mode 100644 shopfloor/tests/test_cluster_picking_stock_issue.py diff --git a/shopfloor/actions/inventory.py b/shopfloor/actions/inventory.py index 7feeb36d44..a13eea5f0f 100644 --- a/shopfloor/actions/inventory.py +++ b/shopfloor/actions/inventory.py @@ -20,11 +20,104 @@ def create_draft_check_empty(self, location, product, ref=None): name = _("Zero check issue on location {} ({})").format(location.name, ref) else: name = _("Zero check issue on location {}").format(location.name) - inventory = self.env["stock.inventory"].create( + return self._create_draft_inventory(location, product, name) + + def _inventory_exists( + self, location, product, package=None, lot=None, states=("draft", "confirm") + ): + """Return if an inventory for location and product exist""" + domain = [ + ("location_ids", "=", location.id), + ("product_ids", "=", product.id), + ("state", "in", states), + ] + if package is not None: + domain.append(("package_id", "=", package.id)) + if lot is not None: + domain.append(("lot_id", "=", lot.id)) + return self.env["stock.inventory"].search_count(domain) + + def _create_draft_inventory(self, location, product, name): + return self.env["stock.inventory"].create( { "name": name, "location_ids": [(6, 0, location.ids)], "product_ids": [(6, 0, product.ids)], } ) - return inventory + + def create_control_stock(self, location, product, package, lot): + """Create a draft inventory so a user has to check a location + + If a draft or in progress inventory already exists for the same + combination of product/package/lot, no inventory is created. + """ + if not self._inventory_exists(location, product): + product_name = self._stock_issue_product_description(product, package, lot) + + name = _("Control stock issue in location {} for {}").format( + location.name, product_name + ) + self._create_draft_inventory(location, product, name) + + def create_stock_issue(self, move, location, package, lot): + """Create an inventory for a stock issue + + It reduces the quantity in a location in a way that: + * assigned move lines in other batch transfers stay assigned. + * assigned move lines in same batch but already picked stay assigned. + """ + other_lines = self._stock_issue_get_related_move_lines( + move, location, package, lot + ) + qty_to_keep = sum(other_lines.mapped("product_qty")) + values = self._stock_issue_inventory_values( + move, location, package, lot, qty_to_keep + ) + inventory = self.env["stock.inventory"].create(values) + inventory.action_start() + inventory.action_validate() + move._action_assign() + + def _stock_issue_get_related_move_lines(self, move, location, package, lot): + """Lookup for all the other moves lines that match given move line""" + domain = [ + ("location_id", "=", location.id), + ("product_id", "=", move.product_id.id), + ("package_id", "=", package.id), + ("lot_id", "=", lot.id), + ("state", "=", "assigned"), + ] + return self.env["stock.move.line"].search(domain) + + def _stock_issue_inventory_values(self, move, location, package, lot, line_qty): + name = _( + "{picking.name} stock correction in location {location.name} " + "for {product_desc}" + ).format( + picking=move.picking_id, + location=location, + product_desc=self._stock_issue_product_description( + move.product_id, package, lot + ), + ) + line_values = { + "location_id": location.id, + "product_id": move.product_id.id, + "package_id": package.id, + "prod_lot_id": lot.id, + "product_qty": line_qty, + } + return { + "name": name, + "line_ids": [(0, 0, line_values)], + } + + def _stock_issue_product_description(self, product, package, lot): + parts = [] + if package: + parts.append(package.name) + parts.append(product.name) + if lot.name: + parts.append(_("Lot: ") + lot.name) + return " - ".join(parts) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index edc7f6844e..77ae1953e5 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -755,14 +755,19 @@ def stock_issue(self, move_line_id): """Declare a stock issue for a line After errors in the stock, the user cannot take all the products - because there is physically not enough goods. The move line is - unassigned, and an inventory is created to reduce the quantity in the + because there is physically not enough goods. The move line is deleted + (unreserve), and an inventory is created to reduce the quantity in the source location to prevent future errors until a correction. Beware: the quantity already reserved by other lines should remain reserved so the inventory's quantity must be set to the quantity of lines reserved by other move lines (but not the current one). - A second inventory is created in draft to have someone do an inventory. + The other lines not yet picked in the batch for the same product, lot, + package are unreserved as well (moves lines deleted, which unreserve + their quantity on the move). + + A second inventory is created in draft to have someone do an inventory + check. Transitions: * start_line: when the batch still contains lines without destination @@ -775,7 +780,61 @@ def stock_issue(self, move_line_id): and the last line has a stock issue). In this case, this method *has* to handle the closing of the batch to create backorders (_unload_end) """ - return self._response() + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + return self._response( + next_state="start", message=self.msg_store.unrecoverable_error() + ) + batch = move_line.picking_id.batch_id + + inventory = self.actions_for("inventory") + # create a draft inventory for a user to check + inventory.create_control_stock( + move_line.location_id, + move_line.product_id, + move_line.package_id, + move_line.lot_id, + ) + move = move_line.move_id + lot = move_line.lot_id + package = move_line.package_id + location = move_line.location_id + + # unreserve every lines for the same product/lot in the same batch and + # not done yet, so the same user doesn't have to declare 2 times the + # stock issue for the same thing! + domain = self._domain_stock_issue_unlink_lines(move_line) + unreserve_move_lines = move_line | self.env["stock.move.line"].search(domain) + unreserve_move_lines.unlink() + + # Then, create an inventory with just enough qty so the other assigned + # move lines for the same product in other batches and the other move lines + # already picked stay assigned. + inventory.create_stock_issue(move, location, package, lot) + return self._pick_next_line(batch) + + def _domain_stock_issue_unlink_lines(self, move_line): + # Since we have not enough stock, delete the move lines, which will + # in turn unreserve the moves. The moves lines we delete are those + # in the same batch (we don't want to interfere with other operators + # work, they'll have to declare a stock issue), and not yet started. + # The goal is to prevent the same operator to declare twice the same + # stock issue for the same product/lot/package. + batch = move_line.picking_id.batch_id + move = move_line.move_id + lot = move_line.lot_id + package = move_line.package_id + location = move_line.location_id + domain = [ + ("location_id", "=", location.id), + ("product_id", "=", move.product_id.id), + ("package_id", "=", package.id), + ("lot_id", "=", lot.id), + ("state", "not in", ("cancel", "done")), + ("qty_done", "=", 0), + ("picking_id.batch_id", "=", batch.id), + ] + return domain def change_pack_lot(self, move_line_id, barcode): """Change the expected pack or the lot for a line diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index f6c6713ffd..417f09e6ad 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -11,6 +11,7 @@ from . import test_cluster_picking_select from . import test_cluster_picking_scan from . import test_cluster_picking_skip +from . import test_cluster_picking_stock_issue from . import test_cluster_picking_unload from . import test_checkout_base from . import test_checkout_scan diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py index 7d8333bbff..8ec69b8838 100644 --- a/shopfloor/tests/test_cluster_picking_base.py +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -17,7 +17,9 @@ def setUp(self): self.service = work.component(usage="cluster_picking") @classmethod - def _simulate_batch_selected(cls, batches, in_package=False, in_lot=False): + def _simulate_batch_selected( + cls, batches, in_package=False, in_lot=False, fill_stock=True + ): """Create a state as if a batch was selected by the user * The picking batch is in progress @@ -28,9 +30,10 @@ def _simulate_batch_selected(cls, batches, in_package=False, in_lot=False): all the products of the batch. It is enough for the current tests. """ pickings = batches.mapped("picking_ids") - cls._fill_stock_for_moves( - pickings.mapped("move_lines"), in_package=in_package, in_lot=in_lot - ) + if fill_stock: + cls._fill_stock_for_moves( + pickings.mapped("move_lines"), in_package=in_package, in_lot=in_lot + ) pickings.action_assign() batches.write({"state": "in_progress", "user_id": cls.env.uid}) diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py new file mode 100644 index 0000000000..e2094a52ba --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_stock_issue.py @@ -0,0 +1,308 @@ +from .test_cluster_picking_base import ClusterPickingCommonCase + + +class ClusterPickingStockIssue(ClusterPickingCommonCase): + """Tests covering the /stock_issue endpoint + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + # quants already existing are from demo data + loc_ids = (cls.stock_location.id, cls.shelf1.id, cls.shelf2.id) + cls.env["stock.quant"].search([("location_id", "in", loc_ids)]).unlink() + cls.batch = cls._create_picking_batch( + [ + [cls.BatchProduct(product=cls.product_a, quantity=10)], + [cls.BatchProduct(product=cls.product_a, quantity=5)], + [cls.BatchProduct(product=cls.product_a, quantity=20)], + [cls.BatchProduct(product=cls.product_a, quantity=10)], + [cls.BatchProduct(product=cls.product_a, quantity=7)], + ] + ) + + cls.moves = cls.batch.picking_ids.move_lines.sorted("id") + cls.move1, cls.move2, cls.move3, cls.move4, cls.move5 = cls.moves + cls.batch_other = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=30)]] + ) + cls.dest_package = cls.env["stock.quant.package"].create({}) + + def _stock_issue(self, line, next_line=None): + response = self.service.dispatch( + "stock_issue", params={"move_line_id": line.id} + ) + if next_line: + self.assert_response( + response, next_state="start_line", data=self._line_data(next_line) + ) + else: + self.assert_response( + response, + next_state="unload_all", + data={ + "id": self.batch.id, + "location_dest": { + "id": self.packing_location.id, + "name": self.packing_location.name, + }, + "name": self.batch.name, + }, + ) + + return response + + def assert_location_qty_and_reserved( + self, location, expected_reserved_qty, lot=None + ): + quant_domain = [("location_id", "=", location.id)] + if lot: + quant_domain += [("lot_id", "=", lot.id)] + location_quants = self.env["stock.quant"].search(quant_domain) + self.assertEqual(sum(location_quants.mapped("quantity")), expected_reserved_qty) + self.assertEqual( + sum(location_quants.mapped("reserved_quantity")), expected_reserved_qty + ) + + def assert_stock_issue_inventories( + self, issue_picking, location, product, remaining_qty, lot=None + ): + inventories = self.env["stock.inventory"].search([], order="id desc", limit=2) + product_desc = product.name + if lot: + product_desc = "{} - Lot: {}".format(product_desc, lot.name) + self.assertRecordValues( + inventories, + [ + { + # this one changed the quantity in the location to + # the quantity of the quant checked above + "state": "done", + "name": "{picking.name} stock correction in" + " location {location.name} for {product_desc}".format( + picking=issue_picking, + location=location, + product_desc=product_desc, + ), + }, + { + # this one is draft and empty, has to be done by a user + "state": "draft", + "name": "Control stock issue in location {} for {}".format( + location.name, product_desc + ), + }, + ], + ) + self.assertRecordValues( + inventories[0].line_ids, + [ + { + "product_id": product.id, + "location_id": location.id, + "product_qty": remaining_qty, + "package_id": False, + "prod_lot_id": lot.id if lot else False, + } + ], + ) + + def test_stock_issue_with_other_batch(self): + self._update_qty_in_location(self.shelf1, self.product_a, 25) + # The other batch will reserve 25 in shelf 1, now empty + self.batch_other.picking_ids.action_assign() + + self._update_qty_in_location(self.shelf2, self.product_a, 100) + # and then, the other batch reserves 5 in shelf 2. + # We'll want to check that even if on our batch we have a stock issue, + # we never change anything in the batch of another operator. + self._simulate_batch_selected(self.batch_other, fill_stock=False) + self.assertEqual( + set(self.batch_other.picking_ids.mapped("state")), {"assigned"} + ) + + # At this point, we have a remaining quantity of 0 in shelf1 + # and 95 in shelf2. + + # all the moves of our batch should be reserved as we have enough + # stock + self._simulate_batch_selected(self.batch, fill_stock=False) + self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"}) + + # the operator could pick the 2 first lines of the batch + self._set_dest_package_and_done(self.move1.move_line_ids, self.dest_package) + self._set_dest_package_and_done(self.move2.move_line_ids, self.dest_package) + + # on the third move, the operator can't pick anymore in shelf1 + # because there is nothing inside, they declare a stock issue + self._stock_issue(self.move3.move_line_ids) + + self.assertRecordValues( + self.moves, + [ + {"state": "assigned"}, + {"state": "assigned"}, + {"state": "confirmed"}, + {"state": "confirmed"}, + {"state": "confirmed"}, + ], + ) + expected_reserved_qty = ( + self.move1.product_uom_qty + + self.move2.product_uom_qty + + sum( + self.batch_other.picking_ids.move_line_ids.filtered( + lambda l: l.location_id == self.shelf2 + ).mapped("product_uom_qty") + ) + ) + # we should have a quant with 20 quantity and 20 reserved + # (5 for the other batch and 15 qty_done in this batch) + self.assert_location_qty_and_reserved(self.shelf2, expected_reserved_qty) + self.assert_stock_issue_inventories( + self.move3.picking_id, + self.shelf2, + self.move3.product_id, + expected_reserved_qty, + ) + + def test_stock_issue_several_move_lines(self): + self._update_qty_in_location(self.shelf1, self.product_a, 20) + # ensure these moves are reserved in shelf1 + self.move1._action_assign() + self.move2._action_assign() + + self._update_qty_in_location(self.shelf2, self.product_a, 100) + # reserve move3 first to ensure this one is reserved in both + # shelf1 and shelf2 + self.move3._action_assign() + + # all the remaining moves will be reserved in shelf2 + self._simulate_batch_selected(self.batch, fill_stock=False) + self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"}) + # The moves of our batch are reserved as: + self.assertEqual(self.move1.move_line_ids.location_id, self.shelf1) + self.assertEqual(self.move2.move_line_ids.location_id, self.shelf1) + self.assertEqual( + self.move3.move_line_ids.mapped("location_id"), self.shelf1 | self.shelf2 + ) + self.assertEqual(self.move4.move_line_ids.location_id, self.shelf2) + self.assertEqual(self.move5.move_line_ids.location_id, self.shelf2) + + line_shelf1 = self.move3.move_line_ids.filtered( + lambda l: l.location_id == self.shelf1 + ) + line_shelf2 = self.move3.move_line_ids.filtered( + lambda l: l.location_id == self.shelf2 + ) + + # pick the first 2 moves + self._set_dest_package_and_done(self.move1.move_line_ids, self.dest_package) + self._set_dest_package_and_done(self.move2.move_line_ids, self.dest_package) + # the operator could pick the first part of move3 in shelf1 + self._set_dest_package_and_done(line_shelf1, self.dest_package) + + # on the third move, the operator can't pick anymore in shelf1 + # because there is nothing inside, they declare a stock issue + self._stock_issue(line_shelf2) + + self.assertRecordValues( + self.moves, + [ + # move 1 and 2 aren't touched: they are in another location + {"state": "assigned"}, + {"state": "assigned"}, + {"state": "partially_available"}, + {"state": "confirmed"}, + {"state": "confirmed"}, + ], + ) + self.assertRecordValues( + # check that the other move line of the move was not altered + line_shelf1, + [ + { + "location_id": self.shelf1.id, + "qty_done": 5.0, + "result_package_id": self.dest_package.id, + } + ], + ) + self.assertFalse(line_shelf2.exists()) + # the quantity in shelf1 should be the original one since we didn't have + # a stock issue here + self.assert_location_qty_and_reserved(self.shelf1, 20) + # since we declared the stock issue without picking anything, its + # quantity should be zero + self.assert_location_qty_and_reserved(self.shelf2, 0) + self.assert_stock_issue_inventories( + self.move3.picking_id, self.shelf2, self.move3.product_id, 0 + ) + + def test_stock_issue_lot(self): + lot_a = self.env["stock.production.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + lot_b = self.env["stock.production.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + self._update_qty_in_location( + self.shelf2, + self.product_a, + self.move1.product_uom_qty + self.move5.product_uom_qty, + lot=lot_a, + ) + # ensure that move 1 and 5 take lot_a (10 + 7 units), so all of them + self.move1._action_assign() + self.move5._action_assign() + # add stock for the rest of the moves + self._update_qty_in_location(self.shelf2, self.product_a, 100, lot=lot_b) + # reserve the remaining moves + self._simulate_batch_selected(self.batch, fill_stock=False) + self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"}) + + # the operator could pick the 3 first lines of the batch + # move move1 with lot a + self._set_dest_package_and_done(self.move1.move_line_ids, self.dest_package) + # move move2 with lot b + self._set_dest_package_and_done(self.move2.move_line_ids, self.dest_package) + + # on the third move, the operator can't pick anymore in the location, + # because there is nothing inside, they declare a stock issue + self._stock_issue(self.move3.move_line_ids, next_line=self.move5.move_line_ids) + + self.assertRecordValues( + self.moves, + [ + # still reserved because using lot a + {"state": "assigned"}, + # still reserved because qty_done > 0 + {"state": "assigned"}, + # unreserved by the stock issue + {"state": "confirmed"}, + # collaterally unreserved by the stock issue (same lot as the + # stock issue) + {"state": "confirmed"}, + # still reserved because using lot a + {"state": "assigned"}, + ], + ) + # check the qty including lot a and lot b + total_reserved_qty = ( + self.move1.product_uom_qty + + self.move2.product_uom_qty + + self.move5.product_uom_qty + ) + self.assert_location_qty_and_reserved(self.shelf2, total_reserved_qty) + # this is the only product reserved for lot_b + expected_reserved_qty = self.move2.product_uom_qty + self.assert_location_qty_and_reserved( + self.shelf2, expected_reserved_qty, lot=lot_b + ) + self.assert_stock_issue_inventories( + self.move3.picking_id, + self.shelf2, + self.move3.product_id, + expected_reserved_qty, + lot=lot_b, + ) From 0dcf3becaf06837aed8edede31c75ca93eb3d00c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 11 May 2020 16:53:11 +0200 Subject: [PATCH 213/940] cluster picking: reassign moves after stock issue In the process, I had to change the sorting of the lines to reflect the sorting of the models, which broke the test checking that on the split of a move line because we picked not enough qty, the next line to pick should be the split line. It was working by chance, so I added a parameter to force pick the line. --- shopfloor/services/cluster_picking.py | 34 +++++++++++-- shopfloor/tests/test_cluster_picking_scan.py | 5 +- .../tests/test_cluster_picking_stock_issue.py | 48 +++++++++++++++++-- 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 77ae1953e5..561b002a2a 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -304,12 +304,25 @@ def confirm_start(self, picking_batch_id): return self._response_batch_does_not_exist() return self._pick_next_line(picking_batch) - def _pick_next_line(self, batch, message=None): - next_line = self._next_line_for_pick(batch) + def _pick_next_line(self, batch, message=None, force_line=None): + if force_line: + next_line = force_line + else: + next_line = self._next_line_for_pick(batch) if not next_line: return self.prepare_unload(batch.id) return self._response_for_start_line(next_line, message=message) + @staticmethod + def _sort_key_lines(line): + return ( + line.location_id, + line.shopfloor_postponed, + line.move_id.sequence, + line.move_id.id, + line.id, + ) + def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x): lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(filter_func) # TODO test line sorting and all these methods to retrieve lines @@ -318,7 +331,7 @@ def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x): # so that the picker start w/ products in the same location. # Postponed lines must come always # after ALL the other lines in the batch are processed. - return lines.sorted(key=lambda x: (x.location_id, x.shopfloor_postponed)) + return lines.sorted(key=self._sort_key_lines) def _lines_to_pick(self, picking_batch): return self._lines_for_picking_batch( @@ -538,6 +551,10 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): return self._response_for_start( message=self.msg_store.unrecoverable_error() ) + + # store a new line if we have split our line (not enough qty) + new_line = self.env["stock.move.line"] + rounding = move_line.product_uom_id.rounding compare = float_compare( quantity, move_line.product_uom_qty, precision_rounding=rounding @@ -559,8 +576,7 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): # has to pick some goods from another place because the location # contained less items than expected) remaining = move_line.product_uom_qty - quantity - # TODO it must be the next line to process - move_line.copy({"product_uom_qty": remaining, "qty_done": 0}) + new_line = move_line.copy({"product_uom_qty": remaining, "qty_done": 0}) move_line.product_uom_qty = quantity search = self.actions_for("search") @@ -598,6 +614,9 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): message=self.msg_store.x_units_put_in_package( move_line.qty_done, move_line.product_id, move_line.result_package_id ), + # if we split the move line, we want to process the one generated by the + # split right now + force_line=new_line, ) def _planned_qty_in_location_is_empty(self, product, location): @@ -805,12 +824,17 @@ def stock_issue(self, move_line_id): # stock issue for the same thing! domain = self._domain_stock_issue_unlink_lines(move_line) unreserve_move_lines = move_line | self.env["stock.move.line"].search(domain) + unreserve_moves = unreserve_move_lines.mapped("move_id").sorted() unreserve_move_lines.unlink() # Then, create an inventory with just enough qty so the other assigned # move lines for the same product in other batches and the other move lines # already picked stay assigned. inventory.create_stock_issue(move, location, package, lot) + + # try to reassign the moves in case we have stock in another location + unreserve_moves._action_assign() + return self._pick_next_line(batch) def _domain_stock_issue_unlink_lines(self, move_line): diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index bf37e8767c..9cdd4a2ce6 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -455,6 +455,9 @@ def test_scan_destination_pack_quantity_more(self): def test_scan_destination_pack_quantity_less(self): """Pick less units than expected""" line = self.one_line_picking.move_line_ids + # when we pick less quantity than expected, the line is split + # and the user is proposed to pick the next line for the remaining + # quantity response = self.service.dispatch( "scan_destination_pack", params={ @@ -477,8 +480,6 @@ def test_scan_destination_pack_quantity_less(self): self.assert_response( response, next_state="start_line", - # TODO ensure the duplicated line is the next line, it works now but - # maybe only by chance data=self._line_data(new_line), message={ "message_type": "success", diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py index e2094a52ba..deaa8204d9 100644 --- a/shopfloor/tests/test_cluster_picking_stock_issue.py +++ b/shopfloor/tests/test_cluster_picking_stock_issue.py @@ -28,13 +28,18 @@ def setUpClass(cls, *args, **kwargs): ) cls.dest_package = cls.env["stock.quant.package"].create({}) - def _stock_issue(self, line, next_line=None): + def _stock_issue(self, line, next_line_func=None): response = self.service.dispatch( "stock_issue", params={"move_line_id": line.id} ) - if next_line: + # use a function/lambda to delay the read of the next line, + # when calling _stock_issue(), the move_line may not exist and + # be created during the call to the stock_issue service + if next_line_func: self.assert_response( - response, next_state="start_line", data=self._line_data(next_line) + response, + next_state="start_line", + data=self._line_data(next_line_func()), ) else: self.assert_response( @@ -269,7 +274,9 @@ def test_stock_issue_lot(self): # on the third move, the operator can't pick anymore in the location, # because there is nothing inside, they declare a stock issue - self._stock_issue(self.move3.move_line_ids, next_line=self.move5.move_line_ids) + self._stock_issue( + self.move3.move_line_ids, next_line_func=lambda: self.move5.move_line_ids + ) self.assertRecordValues( self.moves, @@ -306,3 +313,36 @@ def test_stock_issue_lot(self): expected_reserved_qty, lot=lot_b, ) + + def test_stock_issue_reserve_elsewhere(self): + self._update_qty_in_location(self.shelf1, self.product_a, 100) + self._simulate_batch_selected(self.batch, fill_stock=False) + # now, everything is reserved in shelf1 as we had enough stock + self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"}) + + # put stock in shelf2, so we can test the outcome: goods should be + # reserved in shelf2 after a stock issue + self._update_qty_in_location(self.shelf2, self.product_a, 100) + + # the operator picks the first line + self._set_dest_package_and_done(self.move1.move_line_ids, self.dest_package) + + # and has a stock issue on the second line + # because there is nothing inside, they declare a stock issue + self._stock_issue( + self.move2.move_line_ids, next_line_func=lambda: self.move2.move_line_ids + ) + + # the inventory should have been done for shelf1, and all the remaining + # moves after move1 (already picked) should have been reserved in + # shelf2 + self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"}) + self.assertEqual(self.move1.move_line_ids.location_id, self.shelf1) + # all the following moves have been reserved in shelf2 as we still have + # stock there + self.assertEqual( + (self.move2 | self.move3 | self.move4 | self.move5).mapped( + "move_line_ids.location_id" + ), + self.shelf2, + ) From c5bb1d7906014cd9b098033d556b3a932fb365ed Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 6 Mar 2020 12:44:18 +0100 Subject: [PATCH 214/940] cluster picking: implement /change_pack_lot When an operator picks a move line, they can use a button to replace the package or the lot. Replacing a lot --------------- The lot can be replaced when: * the move was not moving a package * or the move was moving a package, but the lot is in a single package in the current move line's location Replacing the lot may lead negative quants if the available quantity was insuficient, but physically, the quantity was there, so the situation should balance itself eventually, and when it happens, a draft inventory is created in the location for a user to check. Replacing a package ------------------- The package can be replaced when: * the package content is the same, same products and quantities, but the lots can be different * the package is in another package level with the "is_done" flag, in this situation, the package is really not supposed to be here Some tricky situations may happen: * Physically, the replacement package is in the move line's location, but in the quants, it's elsewhere. It means it's an error in the data, and we should not prevent someone to pick it. The quant is updated with inventory moves to move the package in the correct location before doing the replacement. * The package is already reserved for another package level (not "is_done" yet). The operator may be allowed to choose another identical package, in this case, it switches the packages between the 2 transfers (we know their content is the same, except the lots). --- shopfloor/actions/inventory.py | 40 +- shopfloor/actions/message.py | 18 + shopfloor/models/__init__.py | 1 + shopfloor/models/stock_package_level.py | 28 + shopfloor/services/cluster_picking.py | 202 ++++++- shopfloor/tests/__init__.py | 1 + .../test_cluster_picking_change_pack_lot.py | 535 ++++++++++++++++++ 7 files changed, 818 insertions(+), 7 deletions(-) create mode 100644 shopfloor/models/stock_package_level.py create mode 100644 shopfloor/tests/test_cluster_picking_change_pack_lot.py diff --git a/shopfloor/actions/inventory.py b/shopfloor/actions/inventory.py index a13eea5f0f..9f71fd2243 100644 --- a/shopfloor/actions/inventory.py +++ b/shopfloor/actions/inventory.py @@ -46,7 +46,38 @@ def _create_draft_inventory(self, location, product, name): } ) - def create_control_stock(self, location, product, package, lot): + def move_package_quants_to_location(self, package, dest_location): + """Create inventories to move a package to a different location + + It should be called when the package is - in real life - already in + the destination. It creates an inventory to remove the package from + the source location and a second inventory to place the package + in the destination (to reflect the reality). + + The source location is the current location of the package. + """ + quant_values = [] + # sudo and the key in context activate is_inventory_mode on quants + quants = package.quant_ids.sudo().with_context(inventory_mode=True) + for quant in quants: + quantity = quant.quantity + quant.inventory_quantity = 0 + quant_values.append(self._quant_move_values(quant, dest_location, quantity)) + + quant_model = self.env["stock.quant"].sudo().with_context(inventory_mode=True) + quant_model.create(quant_values) + + def _quant_move_values(self, quant, location, quantity): + return { + "product_id": quant.product_id.id, + "inventory_quantity": quantity, + "location_id": location.id, + "lot_id": quant.lot_id.id, + "package_id": quant.package_id.id, + "owner_id": quant.owner_id.id, + } + + def create_control_stock(self, location, product, package, lot, name=None): """Create a draft inventory so a user has to check a location If a draft or in progress inventory already exists for the same @@ -55,9 +86,10 @@ def create_control_stock(self, location, product, package, lot): if not self._inventory_exists(location, product): product_name = self._stock_issue_product_description(product, package, lot) - name = _("Control stock issue in location {} for {}").format( - location.name, product_name - ) + if not name: + name = _("Control stock issue in location {} for {}").format( + location.name, product_name + ) self._create_draft_inventory(location, product, name) def create_stock_issue(self, move, location, package, lot): diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 7a6f099c84..f565b5e823 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -141,6 +141,18 @@ def several_packs_in_location(self, location): ), } + def no_lot_for_barcode(self, barcode): + return { + "message_type": "error", + "body": _("No lot found for {}".format(barcode)), + } + + def lot_on_wrong_product(self, barcode): + return { + "message_type": "error", + "body": _("Lot {} is for another product.").format(barcode), + } + def several_lots_in_location(self, location): return { "message_type": "warning", @@ -167,6 +179,12 @@ def unrecoverable_error(self): "body": _("Unrecoverable error, please restart."), } + def package_different_content(self, package): + return { + "message_type": "error", + "body": _("Package {} has a different content.").format(package.name), + } + def x_units_put_in_package(self, qty, product, package): return { "message_type": "success", diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index 46d5a1ccf6..b44bcc12eb 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -3,6 +3,7 @@ from . import shopfloor_profile from . import stock_location from . import stock_move_line +from . import stock_package_level from . import stock_picking from . import stock_picking_batch from . import stock_quant_package diff --git a/shopfloor/models/stock_package_level.py b/shopfloor/models/stock_package_level.py new file mode 100644 index 0000000000..1db120cc2f --- /dev/null +++ b/shopfloor/models/stock_package_level.py @@ -0,0 +1,28 @@ +from odoo import models + + +class StockPackageLevel(models.Model): + _inherit = "stock.package_level" + + def replace_package(self, new_package): + """Replace a package on an assigned package level and related records + + The replacement package must have the same properties (same products + and quantities). + """ + if self.state not in ("new", "assigned"): + return + + move_lines = self.move_line_ids + # the write method on stock.move.line updates the reservation on quants + move_lines.package_id = new_package + # when a package is set on a line, the destination package is the same + # by default + move_lines.result_package_id = new_package + for quant in new_package.quant_ids: + for line in move_lines: + if line.product_id == quant.product_id: + line.lot_id = quant.lot_id + line.owner_id = quant.owner_id + + self.package_id = new_package diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 561b002a2a..6c7d980bd1 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -113,6 +113,13 @@ def _response_for_scan_destination(self, move_line, message=None): ) return self._response(next_state="scan_destination", data=data, message=message) + def _response_for_change_pack_lot(self, move_line, message=None): + return self._response( + next_state="change_pack_lot", + data=self._data_move_line(move_line), + message=message, + ) + def _response_for_zero_check(self, move_line): data = { "id": move_line.id, @@ -876,9 +883,195 @@ def change_pack_lot(self, move_line_id, barcode): Transitions: * scan_destination: the pack or the lot could be changed - * start_line: any error occurred during the change + * change_pack_lot: any error occurred during the change """ - return self._response() + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + return self._response( + next_state="start", message=self.msg_store.unrecoverable_error() + ) + search = self.actions_for("search") + lot = search.lot_from_scan(barcode) + if lot: + # If the lot is part of a package, what we really want + # is not to change the lot, but change the package (which will + # in turn change the lot altogether), but we have to pay attention + # to some things: + # * cannot replace a package by a lot without package (qty may be + # different, ...) + # * if we have several packages for the same lot, we can't know which + # one the operator is moving, ask to scan a package + lot_package_quants = self.env["stock.quant"].search( + [ + ("lot_id", "=", lot.id), + ("location_id", "=", move_line.location_id.id), + ("package_id", "!=", False), + ("quantity", ">", 0), + ] + ) + if move_line.package_id and not lot_package_quants: + return self._response_for_change_pack_lot( + move_line, + message={ + "message_type": "error", + "body": _("Lot {} is not a package.").format(lot.name), + }, + ) + + if len(lot_package_quants) == 1: + package = lot_package_quants.package_id + return self._change_pack_lot_change_package(move_line, package) + elif len(lot_package_quants) > 1: + return self._response_for_change_pack_lot( + move_line, + message=self.msg_store.several_packs_in_location( + move_line.location_id + ), + ) + + return self._change_pack_lot_change_lot(move_line, lot) + + package = search.package_from_scan(barcode) + if package: + return self._change_pack_lot_change_package(move_line, package) + + return self._response_for_change_pack_lot( + move_line, + message={ + "message_type": "warning", + "body": _("No package or lot found for barcode {}").format(barcode), + }, + ) + + def _change_pack_lot_change_lot(self, move_line, lot): + inventory = self.actions_for("inventory") + product = move_line.product_id + if lot.product_id != product: + return self._response_for_change_pack_lot( + move_line, message=self.msg_store.lot_on_wrong_product(lot.name) + ) + previous_lot = move_line.lot_id + # Changing the lot on the move line updates the reservation + # on the quants + move_line.lot_id = lot + + success_body = _("Lot {} replaced by lot {}.").format( + previous_lot.name, lot.name + ) + # check that we are supposed to have enough of this lot in the + # source location + quant = lot.quant_ids.filtered(lambda q: q.location_id == move_line.location_id) + if not quant: + # not supposed to have this lot here... (if there is a quant + # but not enough quantity we don't care here: user will report + # a stock issue) + inventory.create_control_stock( + move_line.location_id, + move_line.product_id, + move_line.package_id, + move_line.lot_id, + _("Pick: stock issue on lot: {} found in {}").format( + lot.name, move_line.location_id.name + ), + ) + success_body += _(" A draft inventory has been created for control.") + + return self._response_for_scan_destination( + move_line, message={"message_type": "info", "body": success_body} + ) + + def _package_identical_move_lines_qty(self, package, move_lines): + grouped_quants = {} + for quant in package.quant_ids: + grouped_quants.setdefault(quant.product_id, 0) + grouped_quants[quant.product_id] += quant.quantity + + grouped_lines = {} + for move_line in move_lines: + grouped_lines.setdefault(move_line.product_id, 0) + grouped_lines[move_line.product_id] += move_line.product_uom_qty + + return grouped_quants == grouped_lines + + def _change_pack_lot_change_package(self, move_line, package): + inventory = self.actions_for("inventory") + + package_level = move_line.package_level_id + # several move lines can be moved by the package level, we'll have + # to update all of them + move_lines = package_level.move_line_ids + + # prevent to replace a package by a package with a different content + identical_content = self._package_identical_move_lines_qty(package, move_lines) + if not identical_content: + return self._response_for_change_pack_lot( + move_line, message=self.msg_store.package_different_content(package) + ) + + previous_package = move_line.package_id + + if package.location_id != move_line.location_id: + # the package has been scanned in the current location so we know its + # a mistake in the data... fix the quant to move the package here + inventory.move_package_quants_to_location(package, move_line.location_id) + + # search a package level which would already move the scanned package + reserved_level = ( + self.env["stock.package_level"].search([("package_id", "=", package.id)]) + # not possible to search on state + .filtered(lambda level: level.state in ("new", "assigned")) + ) + if reserved_level: + reserved_level.ensure_one() + if reserved_level.is_done: + # Not really supposed to happen: if someone sets is_done, the package + # should no longer be here! But we have to check this and inform the + # user in any case. + return self._response_for_change_pack_lot( + move_line, + message={ + "message_type": "error", + "body": _( + "Package {} cannot be picked, already moved by transfer {}" + ).format(package.name, reserved_level.picking_id.name), + }, + ) + + # Switch the package with the level which was moving it, as we know + # that: + # * only one package level at a time is supposed to move a package + # * the content of the other package is the same (as we checked the + # content is the same as the current move lines) + # * if we left the reserved level with the scanned package, we would + # have 2 levels for the same package and odoo would unreserve the + # move lines as soon as we confirm the current moves + # Considering this, we should be safe to interchange the packages + if reserved_level: + # Ignore updates on quant reservation, which would prevent to switch + # 2 packages between 2 assigned package levels: when writing the + # package of the second level to the first level, it would unreserve + # it because the second level is still using the package. + # But here, we know they both available before and must be available after! + reserved_level.with_context(bypass_reservation_update=True).replace_package( + previous_package + ) + package_level.with_context(bypass_reservation_update=True).replace_package( + package + ) + else: + # when we are not switching packages, we expect the quant + # reservations to be aligned + package_level.replace_package(package) + + return self._response_for_scan_destination( + move_line, + message={ + "message_type": "info", + "body": _("Package {} replaced by package {}").format( + previous_package.name, package.name + ), + }, + ) def set_destination_all(self, picking_batch_id, barcode, confirmation=False): """Set the destination for all the lines of the batch with a dest. package @@ -1202,6 +1395,7 @@ def _states(self): "unload_single": self._schema_for_unload_single, "unload_set_destination": self._schema_for_unload_single, "confirm_unload_set_destination": self._schema_for_unload_single, + "change_pack_lot": self._schema_for_single_line_details, } def find_batch(self): @@ -1296,7 +1490,9 @@ def stock_issue(self): ) def change_pack_lot(self): - return self._response_schema(next_states={"scan_destination", "start_line"}) + return self._response_schema( + next_states={"change_pack_lot", "scan_destination"} + ) def set_destination_all(self): return self._response_schema( diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 417f09e6ad..640b06ae81 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -12,6 +12,7 @@ from . import test_cluster_picking_scan from . import test_cluster_picking_skip from . import test_cluster_picking_stock_issue +from . import test_cluster_picking_change_pack_lot from . import test_cluster_picking_unload from . import test_checkout_base from . import test_checkout_scan diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py new file mode 100644 index 0000000000..5e5c77a561 --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py @@ -0,0 +1,535 @@ +from collections import namedtuple + +from odoo.tests.common import Form + +from .test_cluster_picking_base import ClusterPickingCommonCase + + +class ClusterPickingChangePackLotCommon(ClusterPickingCommonCase): + + # used by _create_package_in_location + PackageContent = namedtuple( + "PackageContent", + # recordset of the product, + # quantity in float + # recordset of the lot (optional) + "product quantity lot", + ) + + def _create_package_in_location(self, location, content): + """Create a package and quants in a location + + content is a list of PackageContent + """ + package = self.env["stock.quant.package"].create({}) + for product, quantity, lot in content: + self._update_qty_in_location( + location, product, quantity, package=package, lot=lot + ) + return package + + def _create_lot(self, product): + return self.env["stock.production.lot"].create( + {"product_id": product.id, "company_id": self.env.company.id} + ) + + def _test_change_pack_lot(self, line, barcode, success=True, message=None): + response = self.service.dispatch( + "change_pack_lot", params={"move_line_id": line.id, "barcode": barcode}, + ) + if success: + self.assert_response( + response, + message=message, + next_state="scan_destination", + data=self._line_data(line), + ) + else: + self.assert_response( + response, + message=message, + next_state="change_pack_lot", + data=self._line_data(line), + ) + + def _skip_line(self, line, next_line=None): + response = self.service.dispatch("skip_line", params={"move_line_id": line.id}) + if next_line: + self.assert_response( + response, next_state="start_line", data=self._line_data(next_line) + ) + return response + + def assert_quant_reserved_qty(self, move_line, qty_func, package=None, lot=None): + domain = [ + ("location_id", "=", move_line.location_id.id), + ("product_id", "=", move_line.product_id.id), + ] + if package: + domain.append(("package_id", "=", package.id)) + if lot: + domain.append(("lot_id", "=", lot.id)) + quant = self.env["stock.quant"].search(domain) + self.assertEqual(quant.reserved_quantity, qty_func()) + + def assert_quant_package_qty(self, location, package, qty_func): + quant = self.env["stock.quant"].search( + [("location_id", "=", location.id), ("package_id", "=", package.id)] + ) + self.assertEqual(quant.quantity, qty_func()) + + def assert_control_stock_inventory(self, location, product, lot): + inventory = self.env["stock.inventory"].search([], order="id desc", limit=1) + self.assertRecordValues( + inventory, + [ + { + "state": "draft", + "product_ids": product.ids, + "name": "Pick: stock issue on lot: {} found in {}".format( + lot.name, location.name + ), + }, + ], + ) + + +class ClusterPickingChangePackLotCase(ClusterPickingChangePackLotCommon): + """Tests covering the /change_pack_lot endpoint""" + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.batch = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=10)]] + ) + + def test_change_pack_lot_change_pack_ok(self): + initial_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + self._simulate_batch_selected(self.batch, fill_stock=False) + + # ensure we have our new package in the same location + new_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + + line = self.batch.picking_ids.move_line_ids + self._test_change_pack_lot( + line, + new_package.name, + success=True, + message={ + "message_type": "info", + "body": "Package {} replaced by package {}".format( + initial_package.name, new_package.name + ), + }, + ) + + self.assertRecordValues( + line, [{"package_id": new_package.id, "result_package_id": new_package.id}] + ) + + self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package) + self.assert_quant_reserved_qty( + line, lambda: line.product_qty, package=new_package + ) + + def test_change_pack_lot_change_pack_different_location(self): + initial_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + + # initial_package from shelf1 will be selected in our move line + self._simulate_batch_selected(self.batch, fill_stock=False) + + # put a package in shelf2 in the system, but we assume that in real, + # the operator put it in shelf1 + new_package = self._create_package_in_location( + self.shelf2, [self.PackageContent(self.product_a, 10, lot=None)] + ) + + line = self.batch.picking_ids.move_line_ids + # when the operator wants to pick the initial package, in shelf1, the new + # package is in front of the other so they want to change the package + self._test_change_pack_lot( + line, + new_package.name, + success=True, + message={ + "message_type": "info", + "body": "Package {} replaced by package {}".format( + initial_package.name, new_package.name + ), + }, + ) + + self.assertRecordValues( + line, [{"package_id": new_package.id, "result_package_id": new_package.id}] + ) + self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) + # check that reservations have been updated + self.assert_quant_package_qty(self.shelf2, new_package, lambda: 0) + self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package) + self.assert_quant_reserved_qty( + line, lambda: line.product_qty, package=new_package + ) + + def test_change_pack_lot_change_lot_in_package_ok(self): + self.product_a.tracking = "lot" + initial_lot = self._create_lot(self.product_a) + initial_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)] + ) + self._simulate_batch_selected(self.batch, fill_stock=False) + # ensure we have our new package in the same location + new_lot = self._create_lot(self.product_a) + new_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=new_lot)] + ) + line = self.batch.picking_ids.move_line_ids + self._test_change_pack_lot( + line, + new_lot.name, + success=True, + message={ + "message_type": "info", + "body": "Package {} replaced by package {}".format( + initial_package.name, new_package.name + ), + }, + ) + + self.assertRecordValues( + line, + [ + { + "package_id": new_package.id, + "result_package_id": new_package.id, + "lot_id": new_lot.id, + } + ], + ) + self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package) + self.assert_quant_reserved_qty( + line, lambda: line.product_qty, package=new_package + ) + + def test_change_pack_lot_change_lot_in_several_packages_error(self): + self.product_a.tracking = "lot" + initial_lot = self._create_lot(self.product_a) + self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)] + ) + self._simulate_batch_selected(self.batch, fill_stock=False) + line = self.batch.picking_ids.move_line_ids + # create 2 packages for the same new lot in the same location + new_lot = self._create_lot(self.product_a) + self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, new_lot)] + ) + self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, new_lot)] + ) + self._test_change_pack_lot( + line, + new_lot.name, + success=False, + message={ + "message_type": "warning", + "body": "Several packages found in {}," + " please scan a package.".format(self.shelf1.name), + }, + ) + + def test_change_pack_lot_change_lot_from_package_error(self): + # we shouldn't be allowed to replace a package by a lot + # if the lot is not a package in the quants (because we + # could then replace a package by a single unit) + self.product_a.tracking = "lot" + initial_lot = self._create_lot(self.product_a) + self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)] + ) + self._simulate_batch_selected(self.batch, fill_stock=False) + line = self.batch.picking_ids.move_line_ids + # create a lot and put a unit in the location without package + new_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, line.product_id, 1, lot=new_lot) + self._test_change_pack_lot( + line, + new_lot.name, + success=False, + message={ + "message_type": "error", + "body": "Lot {} is not a package.".format(new_lot.name), + }, + ) + + def test_change_pack_lot_change_lot_ok(self): + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + self._simulate_batch_selected(self.batch, fill_stock=False) + line = self.batch.picking_ids.move_line_ids + source_location = line.location_id + new_lot = self._create_lot(self.product_a) + # ensure we have our new package in the same location + self._update_qty_in_location(source_location, line.product_id, 10, lot=new_lot) + self._test_change_pack_lot( + line, + new_lot.name, + success=True, + message={ + "message_type": "info", + "body": "Lot {} replaced by lot {}.".format( + initial_lot.name, new_lot.name + ), + }, + ) + + self.assertRecordValues(line, [{"lot_id": new_lot.id}]) + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot) + self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot) + + def test_change_pack_lot_change_lot_different_location_ok(self): + self.product_a.tracking = "lot" + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + self._simulate_batch_selected(self.batch, fill_stock=False) + line = self.batch.picking_ids.move_line_ids + new_lot = self._create_lot(self.product_a) + # ensure we have our new package in a different location + self._update_qty_in_location(self.shelf2, line.product_id, 10, lot=new_lot) + self._test_change_pack_lot( + line, + new_lot.name, + success=True, + message={ + "message_type": "info", + "body": "Lot {} replaced by lot {}. A draft inventory has" + " been created for control.".format(initial_lot.name, new_lot.name), + }, + ) + + self.assertRecordValues(line, [{"lot_id": new_lot.id}]) + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot) + self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot) + self.assert_control_stock_inventory(self.shelf1, line.product_id, new_lot) + + +class ClusterPickingChangePackLotCaseSpecial(ClusterPickingChangePackLotCommon): + """Tests covering the /change_pack_lot endpoint + + Special cases where we use a custom batch transfer + """ + + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + + def _create_picking_with_package_level(self, packages): + picking_form = Form(self.env["stock.picking"]) + picking_form.partner_id = self.customer + picking_form.origin = "test" + picking_form.picking_type_id = self.picking_type + picking_form.location_id = self.stock_location + picking_form.location_dest_id = self.packing_location + for package in packages: + with picking_form.package_level_ids_details.new() as move: + move.package_id = package + picking = picking_form.save() + picking.action_confirm() + picking.action_assign() + return picking + + def _create_batch_with_pickings(self, pickings): + batch_form = Form(self.env["stock.picking.batch"]) + for picking in pickings: + batch_form.picking_ids.add(picking) + batch = batch_form.save() + return batch + + def test_change_pack_lot_change_pack_different_content_error(self): + # create the initial package, that will be reserved first + initial_package = self._create_package_in_location( + self.shelf1, + [ + self.PackageContent(self.product_a, 10, lot=None), + self.PackageContent(self.product_b, 10, lot=None), + ], + ) + picking = self._create_picking_with_package_level(initial_package) + batch = self._create_batch_with_pickings(picking) + self._simulate_batch_selected(batch, fill_stock=False) + + # create a new package in the same location + # with a different content + new_package = self._create_package_in_location( + self.shelf1, + [ + self.PackageContent(self.product_a, 10, lot=None), + self.PackageContent(self.product_b, 8, lot=None), + ], + ) + + lines = batch.picking_ids.move_line_ids + # try to use the new package, which has a different content, + # not accepted + self._test_change_pack_lot( + lines[0], + new_package.name, + success=False, + message={ + "message_type": "error", + "body": "Package {} has a different content.".format(new_package.name), + }, + ) + + def test_change_pack_lot_change_pack_multi_content_with_lot(self): + (self.product_a + self.product_b).tracking = "lot" + # create a package with 2 products tracked by lot, stored in shelf1 + # this package is reserved first on the move line + initial_lot_a = self._create_lot(self.product_a) + initial_lot_b = self._create_lot(self.product_b) + initial_package = self._create_package_in_location( + self.shelf1, + [ + self.PackageContent(self.product_a, 10, initial_lot_a), + self.PackageContent(self.product_b, 10, initial_lot_b), + ], + ) + + # create and reserve our transfer using the initial package + picking = self._create_picking_with_package_level(initial_package) + batch = self._create_batch_with_pickings(picking) + self._simulate_batch_selected(batch, fill_stock=False) + + lines = picking.move_line_ids + package_level = lines.mapped("package_level_id") + + # create a second package with the same content, which will be used + # as replacement + new_lot_a = self._create_lot(self.product_a) + new_lot_b = self._create_lot(self.product_b) + new_package = self._create_package_in_location( + self.shelf1, + [ + self.PackageContent(self.product_a, 10, new_lot_a), + self.PackageContent(self.product_b, 10, new_lot_b), + ], + ) + # changing the package of the first line will change all of them + self._test_change_pack_lot( + lines[0], + new_package.name, + success=True, + message={ + "message_type": "info", + "body": "Package {} replaced by package {}".format( + initial_package.name, new_package.name + ), + }, + ) + + self.assertRecordValues( + lines, + [ + { + "package_id": new_package.id, + "result_package_id": new_package.id, + "lot_id": new_lot_a.id, + }, + { + "package_id": new_package.id, + "result_package_id": new_package.id, + "lot_id": new_lot_b.id, + }, + ], + ) + + self.assertRecordValues(package_level, [{"package_id": new_package.id}]) + # check that reservations have been updated + for line in lines: + self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package) + self.assert_quant_reserved_qty( + line, lambda: line.product_qty, package=new_package + ) + + def test_change_pack_lot_change_pack_steal_from_other_move_line(self): + # create 2 picking, each with its own package + package1 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)], + ) + picking1 = self._create_picking_with_package_level(package1) + self.assertEqual(picking1.move_line_ids.package_id, package1) + + package2 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)], + ) + picking2 = self._create_picking_with_package_level(package2) + self.assertEqual(picking2.move_line_ids.package_id, package2) + + batch = self._create_batch_with_pickings(picking1 + picking2) + self._simulate_batch_selected(batch, fill_stock=False) + + line = picking1.move_line_ids + # We "steal" package2 for the picking1 + self._test_change_pack_lot( + line, + package2.name, + success=True, + message={ + "message_type": "info", + "body": "Package {} replaced by package {}".format( + package1.name, package2.name + ), + }, + ) + + self.assertRecordValues( + picking1.move_line_ids, + [ + { + "package_id": package2.id, + "result_package_id": package2.id, + "state": "assigned", + } + ], + ) + self.assertRecordValues( + picking2.move_line_ids, + [ + { + "package_id": package1.id, + "result_package_id": package1.id, + "state": "assigned", + } + ], + ) + self.assertRecordValues( + picking1.package_level_ids, + [{"package_id": package2.id, "state": "assigned"}], + ) + self.assertRecordValues( + picking2.package_level_ids, + [{"package_id": package1.id, "state": "assigned"}], + ) + # check that reservations have been updated + self.assert_quant_reserved_qty( + picking1.move_line_ids, + lambda: picking1.move_line_ids.product_qty, + package=package2, + ) + self.assert_quant_reserved_qty( + picking2.move_line_ids, + lambda: picking2.move_line_ids.product_qty, + package=package1, + ) From 633c7bf50fa08bec0c97444aab22cec4b138fa5a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 14 May 2020 08:11:33 +0200 Subject: [PATCH 215/940] cluster picking: fix flaky test on skip_line --- shopfloor/services/cluster_picking.py | 4 ++-- shopfloor/tests/test_cluster_picking_skip.py | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 6c7d980bd1..2d288feca7 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -771,9 +771,9 @@ def skip_line(self, move_line_id): ) # flag as postponed move_line.shopfloor_postponed = True - return self._response_for_skip_line(move_line) + return self._pick_after_skip_line(move_line) - def _response_for_skip_line(self, move_line): + def _pick_after_skip_line(self, move_line): batch = move_line.picking_id.batch_id return self._pick_next_line(batch) diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py index a2d83d1a6c..2e78a3acc2 100644 --- a/shopfloor/tests/test_cluster_picking_skip.py +++ b/shopfloor/tests/test_cluster_picking_skip.py @@ -35,8 +35,16 @@ def _skip_line(self, line, next_line=None): def test_skip_line(self): self._simulate_batch_selected(self.batch, in_package=True) - lines = self.batch.picking_ids.move_line_ids - # 1st line, next is 2nd + lines = self.batch.picking_ids.move_line_ids.sorted( + lambda line: ( + line.location_id, + line.shopfloor_postponed, + line.move_id.sequence, + line.move_id.id, + line.id, + ) + ) + self.assertFalse(lines[0].shopfloor_postponed) self._skip_line(lines[0], lines[1]) self.assertTrue(lines[0].shopfloor_postponed) From 331a2fdc1225d15bf514ab4688f4ea4be74f246b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 19 May 2020 16:38:09 +0200 Subject: [PATCH 216/940] backend: adapt data details --- shopfloor/actions/data.py | 28 +++++++---- shopfloor/actions/data_detail.py | 2 +- shopfloor/services/schema.py | 52 +++++++++++++++++---- shopfloor/services/schema_detail.py | 26 +---------- shopfloor/tests/common.py | 4 ++ shopfloor/tests/test_actions_data.py | 14 ++---- shopfloor/tests/test_actions_data_detail.py | 38 +++++++-------- 7 files changed, 90 insertions(+), 74 deletions(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 977550d2ac..0d987e0da0 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -58,13 +58,16 @@ def _picking_parser(self): "total_weight:weight", ] - def package(self, record, picking=None, **kw): + def package(self, record, picking=None, with_packaging=False, **kw): """Return data for a stock.quant.package If a picking is given, it will include the number of lines of the package for the picking. """ - data = self._jsonify(record, self._package_parser, **kw) + parser = self._package_parser + if with_packaging: + parser += self._package_packaging_parser + data = self._jsonify(record, parser, **kw) # handle special cases if data and picking: # TODO: exclude canceled and done? @@ -73,7 +76,7 @@ def package(self, record, picking=None, **kw): return data def packages(self, records, picking=None, **kw): - return [self.package(rec, picking=picking) for rec in records] + return [self.package(rec, picking=picking, **kw) for rec in records] @property def _package_parser(self): @@ -81,6 +84,11 @@ def _package_parser(self): "id", "name", "pack_weight:weight", + ] + + @property + def _package_packaging_parser(self): + return [ ("product_packaging_id:packaging", self._packaging_parser), ] @@ -112,16 +120,18 @@ def move_line(self, record, **kw): { # cannot use sub-parser here # because result might depend on picking - "package_src": self.package(record.package_id, record.picking_id), + "package_src": self.package( + record.package_id, record.picking_id, **kw + ), "package_dest": self.package( - record.result_package_id, record.picking_id, + record.result_package_id, record.picking_id, **kw ), } ) return data def move_lines(self, records, **kw): - return [self.move_line(rec) for rec in records] + return [self.move_line(rec, **kw) for rec in records] @property def _move_line_parser(self): @@ -145,14 +155,14 @@ def products(self, record, **kw): def _product_parser(self): return ["id", "name", "display_name", "default_code", "barcode"] - def picking_batch(self, record, with_pickings=True, **kw): + def picking_batch(self, record, with_pickings=False, **kw): parser = self._picking_batch_parser if with_pickings: parser.append(("picking_ids:pickings", self._picking_parser)) return self._jsonify(record, parser, **kw) - def picking_batches(self, record, **kw): - return self.picking_batch(record, multi=True) + def picking_batches(self, record, with_pickings=False, **kw): + return self.picking_batch(record, with_pickings=with_pickings, multi=True) @property def _picking_batch_parser(self): diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py index 2cd588e7f6..b118e86e93 100644 --- a/shopfloor/actions/data_detail.py +++ b/shopfloor/actions/data_detail.py @@ -53,7 +53,7 @@ def _picking_detail_parser(self): def package_detail(self, record, picking=None, **kw): # Define a new method to not overload the base one which is used in many places - data = self.package(record, picking=picking, **kw) + data = self.package(record, picking=picking, with_packaging=True, **kw) data.update(self._jsonify(record, self._package_detail_parser, **kw)) return data diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 224db160ad..d97dbf36b6 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -14,12 +14,46 @@ class BaseShopfloorSchemaResponse(Component): _usage = "schema" _is_rest_service_component = False + def _schema_list_of(self, schema, **kw): + return { + "type": "list", + "nullable": True, + "required": False, + "schema": {"type": "dict", "schema": schema}, + } + + def _simple_record(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + } + + def _schema_dict_of(self, schema, **kw): + schema = { + "type": "dict", + "nullable": True, + "required": False, + "schema": schema, + } + schema.update(kw) + return schema + + def _schema_search_results_of(self, schema, **kw): + return { + "size": {"required": True, "type": "integer"}, + "records": { + "type": "list", + "required": True, + "schema": {"type": "dict", "schema": schema}, + }, + } + def picking(self): return { "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, - "origin": {"type": "string", "nullable": True, "required": True}, - "note": {"type": "string", "nullable": True, "required": True}, + "origin": {"type": "string", "nullable": True, "required": False}, + "note": {"type": "string", "nullable": True, "required": False}, "move_line_count": {"type": "integer", "nullable": True, "required": True}, "weight": {"required": True, "nullable": True, "type": "float"}, "partner": { @@ -53,7 +87,7 @@ def move_line(self): }, "package_dest": { "type": "dict", - "required": True, + "required": False, "nullable": True, "schema": self.package(), }, @@ -78,19 +112,21 @@ def product(self): "barcode": {"type": "string", "nullable": True, "required": False}, } - def package(self): - return { + def package(self, with_packaging=False): + schema = { "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, "weight": {"required": True, "nullable": True, "type": "float"}, "move_line_count": {"required": False, "nullable": True, "type": "integer"}, - "packaging": { + } + if with_packaging: + schema["packaging"] = { "type": "dict", "required": True, "nullable": True, "schema": self.packaging(), - }, - } + } + return schema def lot(self): return { diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py index b28f069e7e..7736db7a8d 100644 --- a/shopfloor/services/schema_detail.py +++ b/shopfloor/services/schema_detail.py @@ -12,30 +12,6 @@ class ShopfloorSchemaDetailResponse(Component): _name = "base.shopfloor.schemas.detail" _usage = "schema_detail" - def _schema_list_of(self, schema, **kw): - return { - "type": "list", - "nullable": True, - "required": False, - "schema": {"type": "dict", "schema": schema}, - } - - def _simple_record(self): - return { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - } - - def _schema_dict_of(self, schema, **kw): - schema = { - "type": "dict", - "nullable": True, - "required": False, - "schema": schema, - } - schema.update(kw) - return schema - def location_detail(self): schema = self.location() schema.update( @@ -68,7 +44,7 @@ def picking_detail(self): return schema def package_detail(self): - schema = self.package() + schema = self.package(with_packaging=True) schema.update( { "pickings": self._schema_list_of(self.picking()), diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 966207b53f..1cacd73b78 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -67,6 +67,10 @@ def setUpClass(cls): cls.setUpComponent() cls.setUpClassVars() cls.setUpClassBaseData() + with cls.work_on_actions(cls) as work: + cls.data = work.component(usage="data") + with cls.work_on_actions(cls) as work: + cls.data_detail = work.component(usage="data_detail") @classmethod def setUpClassVars(cls): diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index b25c08cd0b..4f44ad4dcd 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -66,8 +66,6 @@ def _expected_product(self, record, **kw): class ActionsDataCase(ActionsDataCaseBase): def setUp(self): super().setUp() - with self.work_on_actions() as work: - self.data = work.component(usage="data") with self.work_on_services() as work: self.schema = work.component(usage="schema") @@ -104,8 +102,8 @@ def test_data_lot(self): def test_data_package(self): package = self.move_a.move_line_ids.package_id package.product_packaging_id = self.packaging.id - data = self.data.package(package, picking=self.picking) - self.assert_schema(self.schema.package(), data) + data = self.data.package(package, picking=self.picking, with_packaging=True) + self.assert_schema(self.schema.package(with_packaging=True), data) expected = { "id": package.id, "name": package.name, @@ -148,17 +146,15 @@ def test_data_move_line_package(self): "id": move_line.package_id.id, "name": move_line.package_id.name, "move_line_count": 1, - "packaging": None, # TODO - "weight": 0, + "weight": 0.0, }, "package_dest": { "id": result_package.id, "name": result_package.name, "move_line_count": 0, - "packaging": self.data.packaging(self.packaging), # TODO - "weight": 0, + "weight": 0.0, }, "location_src": self._expected_location(move_line.location_id), "location_dest": self._expected_location(move_line.location_dest_id), @@ -205,7 +201,6 @@ def test_data_move_line_package_lot(self): "id": move_line.package_id.id, "name": move_line.package_id.name, "move_line_count": 1, - "packaging": None, # TODO "weight": 0, }, @@ -213,7 +208,6 @@ def test_data_move_line_package_lot(self): "id": move_line.result_package_id.id, "name": move_line.result_package_id.name, "move_line_count": 1, - "packaging": None, # TODO "weight": 0, }, diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index cbd2e71bcd..c208d522c9 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -29,8 +29,6 @@ def setUpClass(cls): def setUp(self): super().setUp() - with self.work_on_actions() as work: - self.data = work.component(usage="data_detail") with self.work_on_services() as work: self.schema = work.component(usage="schema_detail") @@ -39,7 +37,9 @@ def _expected_location_detail(self, record, **kw): **self._expected_location(record), **{ "complete_name": record.complete_name, - "reserved_move_lines": self.data.move_lines(kw.get("move_lines", [])), + "reserved_move_lines": self.data_detail.move_lines( + kw.get("move_lines", []) + ), } ) @@ -82,7 +82,7 @@ def _expected_product_detail(self, record, **kw): class ActionsDataDetailCase(ActionsDataDetailCaseBase): def test_data_location(self): location = self.stock_location - data = self.data.location_detail(location) + data = self.data_detail.location_detail(location) self.assert_schema(self.schema.location_detail(), data) move_lines = self.env["stock.move.line"].search( [ @@ -96,7 +96,7 @@ def test_data_location(self): ) def test_data_packaging(self): - data = self.data.packaging(self.packaging) + data = self.data_detail.packaging(self.packaging) self.assert_schema(self.schema.packaging(), data) expected = {"id": self.packaging.id, "name": self.packaging.name} self.assertDictEqual(data, expected) @@ -111,7 +111,7 @@ def test_data_lot(self): "life_date": "2020-05-31", } ) - data = self.data.lot_detail(lot) + data = self.data_detail.lot_detail(lot) self.assert_schema(self.schema.lot_detail(), data) expected = { @@ -130,7 +130,7 @@ def test_data_package(self): package.product_packaging_id = self.packaging.id package.package_storage_type_id = self.storage_type_pallet # package.invalidate_cache() - data = self.data.package_detail(package, picking=self.picking) + data = self.data_detail.package_detail(package, picking=self.picking) self.assert_schema(self.schema.package_detail(), data) lines = self.env["stock.move.line"].search( @@ -141,10 +141,10 @@ def test_data_package(self): "id": package.id, "name": package.name, "move_line_count": 1, - "packaging": self.data.packaging(package.product_packaging_id), + "packaging": self.data_detail.packaging(package.product_packaging_id), "weight": 0, - "pickings": self.data.pickings(pickings), - "move_lines": self.data.move_lines(lines), + "pickings": self.data_detail.pickings(pickings), + "move_lines": self.data_detail.move_lines(lines), "storage_type": { "id": self.storage_type_pallet.id, "name": self.storage_type_pallet.name, @@ -164,7 +164,7 @@ def test_data_picking(self): } ) picking.move_lines.write({"date_expected": "2020-05-13"}) - data = self.data.picking_detail(picking) + data = self.data_detail.picking_detail(picking) self.assert_schema(self.schema.picking_detail(), data) expected = { "id": picking.id, @@ -180,7 +180,7 @@ def test_data_picking(self): "name": picking.picking_type_id.name, }, "carrier": {"id": carrier.id, "name": carrier.name}, - "lines": self.data.move_lines(picking.move_line_ids), + "lines": self.data_detail.move_lines(picking.move_line_ids), } self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-05-13") self.maxDiff = None @@ -192,7 +192,7 @@ def test_data_move_line_package(self): {"product_packaging_id": self.packaging.id} ) move_line.write({"qty_done": 3.0, "result_package_id": result_package.id}) - data = self.data.move_line(move_line) + data = self.data_detail.move_line(move_line) self.assert_schema(self.schema.move_line(), data) product = self.product_a.with_context(location=move_line.location_id.id) expected = { @@ -205,14 +205,12 @@ def test_data_move_line_package(self): "id": move_line.package_id.id, "name": move_line.package_id.name, "move_line_count": 1, - "packaging": None, "weight": 0.0, }, "package_dest": { "id": result_package.id, "name": result_package.name, "move_line_count": 0, - "packaging": self.data.packaging(self.packaging), "weight": 0.0, }, "location_src": self._expected_location(move_line.location_id), @@ -222,7 +220,7 @@ def test_data_move_line_package(self): def test_data_move_line_lot(self): move_line = self.move_b.move_line_ids - data = self.data.move_line(move_line) + data = self.data_detail.move_line(move_line) self.assert_schema(self.schema.move_line(), data) product = self.product_b.with_context(location=move_line.location_id.id) expected = { @@ -245,7 +243,7 @@ def test_data_move_line_lot(self): def test_data_move_line_package_lot(self): self.maxDiff = None move_line = self.move_c.move_line_ids - data = self.data.move_line(move_line) + data = self.data_detail.move_line(move_line) self.assert_schema(self.schema.move_line(), data) product = self.product_c.with_context(location=move_line.location_id.id) expected = { @@ -262,14 +260,12 @@ def test_data_move_line_package_lot(self): "id": move_line.package_id.id, "name": move_line.package_id.name, "move_line_count": 1, - "packaging": None, "weight": 0.0, }, "package_dest": { "id": move_line.result_package_id.id, "name": move_line.result_package_id.name, "move_line_count": 1, - "packaging": None, "weight": 0.0, }, "location_src": self._expected_location(move_line.location_id), @@ -279,7 +275,7 @@ def test_data_move_line_package_lot(self): def test_data_move_line_raw(self): move_line = self.move_d.move_line_ids - data = self.data.move_line(move_line) + data = self.data_detail.move_line(move_line) self.assert_schema(self.schema.move_line(), data) product = self.product_d.with_context(location=move_line.location_id.id) expected = { @@ -323,7 +319,7 @@ def test_product(self): "product_code": "SUPP2", } ) - data = self.data.product_detail(product) + data = self.data_detail.product_detail(product) self.assert_schema(self.schema.product_detail(), data) expected = self._expected_product_detail(product, full=True) self.assertDictEqual(data, expected) From 918f33b1e8a2ea384eb49a0625f2b4cfea9a681e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 19 May 2020 16:44:42 +0200 Subject: [PATCH 217/940] backend: unify data conversion all around --- shopfloor/services/checkout.py | 46 ++-- shopfloor/services/cluster_picking.py | 226 ++---------------- shopfloor/services/delivery.py | 6 +- shopfloor/services/single_pack_transfer.py | 2 +- shopfloor/services/validator.py | 1 + shopfloor/tests/test_checkout_base.py | 8 +- shopfloor/tests/test_cluster_picking_base.py | 58 ++--- shopfloor/tests/test_cluster_picking_scan.py | 18 +- .../tests/test_cluster_picking_select.py | 163 ++----------- .../tests/test_cluster_picking_stock_issue.py | 10 +- .../tests/test_cluster_picking_unload.py | 197 ++++----------- shopfloor/tests/test_scan_anything.py | 10 +- 12 files changed, 163 insertions(+), 582 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 536e2465a3..98db69b2db 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -152,11 +152,15 @@ def _response_for_summary(self, picking, need_confirm=False, message=None): def _response_for_select_document(self, message=None): return self._response(next_state="select_document", message=message) + def _data_for_move_lines(self, lines): + data_struct = self.actions_for("data") + return data_struct.move_lines(lines) + def _data_for_stock_picking(self, picking, done=False): data_struct = self.actions_for("data") data = data_struct.picking(picking) line_picker = self._lines_checkout_done if done else self._lines_to_pack - data.update({"move_lines": data_struct.move_lines(line_picker(picking))}) + data.update({"move_lines": self._data_for_move_lines(line_picker(picking))}) return data def _lines_checkout_done(self, picking): @@ -221,7 +225,7 @@ def _response_for_select_package(self, lines, message=None): return self._response( next_state="select_package", data={ - "selected_move_lines": data_struct.move_lines(lines.sorted()), + "selected_move_lines": self._data_for_move_lines(lines.sorted()), "picking": data_struct.picking(picking), }, message=message, @@ -736,14 +740,16 @@ def _response_for_select_dest_package(self, picking, move_lines, message=None): ) data_struct = self.actions_for("data") picking_data = data_struct.picking(picking) - packages_data = data_struct.packages(packages.sorted(), picking=picking) + packages_data = data_struct.packages( + packages.sorted(), picking=picking, with_packaging=True + ) data_struct = self.actions_for("data") return self._response( next_state="select_dest_package", data={ "picking": picking_data, "packages": packages_data, - "selected_move_lines": data_struct.move_lines(move_lines.sorted()), + "selected_move_lines": self._data_for_move_lines(move_lines.sorted()), }, message=message, ) @@ -850,7 +856,9 @@ def _response_for_change_packaging(self, picking, package, packaging_list): next_state="change_packaging", data={ "picking": data_struct.picking(picking), - "package": data_struct.package(package, picking=picking), + "package": data_struct.package( + package, picking=picking, with_packaging=True + ), "packagings": data_struct.packagings(packaging_list.sorted()), }, ) @@ -1164,12 +1172,12 @@ def _states(self): @property def _schema_stock_picking_details(self): - schema = self.schemas().picking() + schema = self.schemas.picking() schema.update( { "move_lines": { "type": "list", - "schema": {"type": "dict", "schema": self.schemas().move_line()}, + "schema": {"type": "dict", "schema": self.schemas.move_line()}, }, } ) @@ -1186,7 +1194,7 @@ def _schema_selection_list(self): return { "pickings": { "type": "list", - "schema": {"type": "dict", "schema": self.schemas().picking()}, + "schema": {"type": "dict", "schema": self.schemas.picking()}, } } @@ -1195,23 +1203,29 @@ def _schema_select_package(self): return { "selected_move_lines": { "type": "list", - "schema": {"type": "dict", "schema": self.schemas().move_line()}, + "schema": {"type": "dict", "schema": self.schemas.move_line()}, }, "packages": { "type": "list", - "schema": {"type": "dict", "schema": self.schemas().package()}, + "schema": { + "type": "dict", + "schema": self.schemas.package(with_packaging=True), + }, }, - "picking": {"type": "dict", "schema": self.schemas().picking()}, + "picking": {"type": "dict", "schema": self.schemas.picking()}, } @property def _schema_select_packaging(self): return { - "picking": {"type": "dict", "schema": self.schemas().picking()}, - "package": {"type": "dict", "schema": self.schemas().package()}, + "picking": {"type": "dict", "schema": self.schemas.picking()}, + "package": { + "type": "dict", + "schema": self.schemas.package(with_packaging=True), + }, "packagings": { "type": "list", - "schema": {"type": "dict", "schema": self.schemas().packaging()}, + "schema": {"type": "dict", "schema": self.schemas.packaging()}, }, } @@ -1220,9 +1234,9 @@ def _schema_selected_lines(self): return { "selected_move_lines": { "type": "list", - "schema": {"type": "dict", "schema": self.schemas().move_line()}, + "schema": {"type": "dict", "schema": self.schemas.move_line()}, }, - "picking": {"type": "dict", "schema": self.schemas().picking()}, + "picking": {"type": "dict", "schema": self.schemas.picking()}, } def scan_document(self): diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 2d288feca7..c0ba42fc97 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -368,19 +368,23 @@ def _next_line_for_pick(self, picking_batch): def _response_batch_does_not_exist(self): return self._response_for_start(message=self.msg_store.record_not_found()) - def _data_move_line(self, line): + def _data_move_line(self, line, **kw): picking = line.picking_id batch = picking.batch_id product = line.product_id data = self.data_struct.move_line(line) # additional values - data.pop("package_dest", None) + # Ensure destination pack is never proposed on the frontend. + # This should happen only as proposal on `scan_destination` + # where we set the last used package. + data["package_dest"] = None data["batch"] = self.data_struct.picking_batch(batch) data["picking"] = self.data_struct.picking(picking) data["postponed"] = line.shopfloor_postponed data["product"]["qty_available"] = product.with_context( location=line.location_id.id ).qty_available + data.update(kw) return data def unassign(self, picking_batch_id): @@ -686,7 +690,6 @@ def _data_for_unload_single(self, batch, package): line = fields.first( package.planned_move_line_ids.filtered(self._filter_for_unload) ) - # TODO disambiguate "id" everywhere? (id -> picking_batch_id) data = self.data_struct.picking_batch(batch) data.update( { @@ -1541,221 +1544,40 @@ def unload_scan_destination(self): } ) - # TODO single class for sharing schemas between services @property def _schema_for_batch_details(self): - return { - # TODO full name instead of id? or always wrap in batch/move_line? - # id is a stock.picking.batch - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "pickings": { - "type": "list", - "required": True, - "schema": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "move_line_count": {"required": True, "type": "integer"}, - "weight": { - "type": "float", - "nullable": False, - "required": True, - }, - "origin": { - "type": "string", - "nullable": False, - "required": True, - }, - "partner": { - "type": "dict", - "required": False, - "nullable": True, - "schema": { - "id": {"required": True, "type": "integer"}, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, - }, - }, - }, - }, - }, - } + schema = self.schemas.picking_batch() + schema["pickings"] = self.schemas._schema_list_of(self.schemas.picking()) + return schema @property def _schema_for_single_line_details(self): - return { - # id is a stock.move.line - "id": {"required": True, "type": "integer"}, - "quantity": {"type": "float", "required": True}, - "postponed": {"type": "boolean", "required": False}, - "picking": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "origin": {"type": "string", "nullable": True, "required": True}, - "note": {"type": "string", "nullable": True, "required": True}, - "partner": { - "type": "dict", - "required": False, - "nullable": True, - "schema": { - "id": {"required": True, "type": "integer"}, - "name": { - "type": "string", - "nullable": False, - "required": True, - }, - }, - }, - }, - }, - "batch": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - "product": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "display_name": { - "type": "string", - "nullable": False, - "required": True, - }, - "default_code": { - "type": "string", - "nullable": False, - "required": True, - }, - "qty_available": { - "type": "float", - "nullable": False, - "required": True, - }, - }, - }, - "lot": { - "type": "dict", - "required": False, - "nullable": True, - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "ref": {"type": "string", "nullable": True, "required": True}, - }, - }, - # TODO share parts of the schema? - "location_src": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - "location_dest": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - "package_src": { - "type": "dict", - "required": False, - "nullable": True, - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - "package_dest": { - "type": "dict", - "required": False, - "nullable": True, - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - } + schema = self.schemas.move_line() + schema["picking"] = self.schemas._schema_dict_of(self.schemas.picking()) + schema["batch"] = self.schemas._schema_dict_of(self.schemas.picking_batch()) + return schema @property def _schema_for_unload_all(self): - return { - # stock.batch.picking - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "location_dest": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - } + schema = self.schemas.picking_batch() + schema["location_dest"] = self.schemas._schema_dict_of(self.schemas.location()) + return schema @property def _schema_for_unload_single(self): - return { - # stock.batch.picking - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "package": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - "location_dest": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - } + schema = self.schemas.picking_batch() + schema["package"] = self.schemas._schema_dict_of(self.schemas.package()) + schema["location_dest"] = self.schemas._schema_dict_of(self.schemas.location()) + return schema @property def _schema_for_zero_check(self): - return { - # stock.move.line + schema = { "id": {"required": True, "type": "integer"}, - "location_src": { - "type": "dict", - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, - } - - @property - def _schema_for_completion_info(self): - return { - # stock.picking.batch - "id": {"required": True, "type": "integer"}, - "picking_done": {"type": "string", "nullable": False, "required": True}, - "picking_next": {"type": "string", "nullable": False, "required": True}, } + schema["location_src"] = self.schemas._schema_dict_of(self.schemas.location()) + return schema @property def _schema_for_batch_selection(self): - return { - "size": {"required": True, "type": "integer"}, - "records": { - "type": "list", - "required": True, - "schema": {"type": "dict", "schema": self.schemas().picking_batch()}, - }, - } + return self.schemas._schema_search_results_of(self.schemas.picking_batch()) diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 257910746f..9c59f4df34 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -202,12 +202,12 @@ def _states(self): @property def _schema_deliver(self): - schema = self.schemas().picking() + schema = self.schemas.picking() schema.update( { "move_lines": { "type": "list", - "schema": {"type": "dict", "schema": self.schemas().move_line()}, + "schema": {"type": "dict", "schema": self.schemas.move_line()}, } } ) @@ -218,7 +218,7 @@ def _schema_selection_list(self): return { "pickings": { "type": "list", - "schema": {"type": "dict", "schema": self.schemas().picking()}, + "schema": {"type": "dict", "schema": self.schemas.picking()}, } } diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index fc7e9465fe..adecaf1aed 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -264,4 +264,4 @@ def validate(self): @property def _schema_for_package_level_details(self): - return self.schemas().package_level() + return self.schemas.package_level() diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py index dd9a2d1fb3..c484c1514b 100644 --- a/shopfloor/services/validator.py +++ b/shopfloor/services/validator.py @@ -66,6 +66,7 @@ def _states(self): """ return {} + @property def schemas(self): return self.component(usage="schema") diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index ce6a969d6b..6dc4d8d885 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -21,13 +21,13 @@ def _stock_picking_data(self, picking, **kw): # we test the methods that structure data in test_actions_data.py def _picking_summary_data(self, picking): - return self.service.actions_for("data").picking(picking) + return self.data.picking(picking) def _move_line_data(self, move_line): - return self.service.actions_for("data").move_line(move_line) + return self.data.move_line(move_line) def _package_data(self, package, picking): - return self.service.actions_for("data").package(package, picking=picking) + return self.data.package(package, picking=picking, with_packaging=True) def _packaging_data(self, packaging): - return self.service.actions_for("data").packaging(packaging) + return self.data.packaging(packaging) diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py index 8ec69b8838..bd551f0c80 100644 --- a/shopfloor/tests/test_cluster_picking_base.py +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -37,47 +37,22 @@ def _simulate_batch_selected( pickings.action_assign() batches.write({"state": "in_progress", "user_id": cls.env.uid}) - def _line_data(self, move_line, qty=None): + def _line_data(self, move_line, qty=None, package_dest=False): picking = move_line.picking_id - batch = picking.batch_id # A package exists on the move line, because the quant created # by ``_simulate_batch_selected`` has a package. - package = move_line.package_id - lot = move_line.lot_id - return { - "id": move_line.id, - "quantity": qty or move_line.product_uom_qty, - "postponed": move_line.shopfloor_postponed, - "location_dest": { - "id": move_line.location_dest_id.id, - "name": move_line.location_dest_id.name, - }, - "location_src": { - "id": move_line.location_id.id, - "name": move_line.location_id.name, - }, - "picking": { - "id": picking.id, - "name": picking.name, - "note": None, - "origin": picking.origin, - "partner": {"id": self.customer.id, "name": self.customer.name}, - }, - "batch": {"id": batch.id, "name": batch.name}, - "product": { - "default_code": move_line.product_id.default_code, - "display_name": move_line.product_id.display_name, - "id": move_line.product_id.id, - "name": move_line.product_id.name, - "qty_available": move_line.product_id.qty_available, - }, - "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or None} - if lot - else None, - "package_src": {"id": package.id, "name": package.name} - if package - else None, - } + data = self.data.move_line(move_line) + if not package_dest: + data["package_dest"] = None + if qty: + data["quantity"] = qty + data.update( + { + "batch": self.data.picking_batch(picking.batch_id), + "picking": self.data.picking(picking), + } + ) + return data @classmethod def _set_dest_package_and_done(cls, move_lines, dest_package): @@ -87,6 +62,13 @@ def _set_dest_package_and_done(cls, move_lines, dest_package): {"qty_done": line.product_uom_qty, "result_package_id": dest_package.id} ) + def _data_for_batch(self, batch, location, pack=None): + data = self.data.picking_batch(batch) + data["location_dest"] = self.data.location(location) + if pack: + data["package"] = self.data.package(pack) + return data + class ClusterPickingLineCommonCase(ClusterPickingCommonCase): @classmethod diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index 9cdd4a2ce6..3f54ab3984 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -343,18 +343,12 @@ def test_scan_destination_pack_ok_last_line(self): self.assertRecordValues( line, [{"qty_done": qty_done, "result_package_id": self.bin2.id}] ) + data = self._data_for_batch(self.batch, self.packing_location) self.assert_response( response, # they reach the same destination so next state unload_all next_state="unload_all", - data={ - "id": self.batch.id, - "name": self.batch.name, - "location_dest": { - "id": self.packing_location.id, - "name": self.packing_location.name, - }, - }, + data=data, ) def test_scan_destination_pack_not_empty_same_picking(self): @@ -510,13 +504,7 @@ def test_scan_destination_pack_zero_check(self): self.assert_response( response, next_state="zero_check", - data={ - "id": line.id, - "location_src": { - "id": line.location_id.id, - "name": line.location_id.name, - }, - }, + data={"id": line.id, "location_src": self.data.location(line.location_id)}, ) diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py index 14c5285403..79e51fc58d 100644 --- a/shopfloor/tests/test_cluster_picking_select.py +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -51,27 +51,9 @@ def test_find_batch_in_progress_current_user(self): # we expect to find batch 3 as it's assigned to the current # user and in progress (first priority) + data = self.data.picking_batch(self.batch3, with_pickings=True) self.assert_response( - response, - next_state="confirm_start", - data={ - "id": self.batch3.id, - "name": self.batch3.name, - "pickings": [ - { - "id": self.batch3.picking_ids.id, - "name": self.batch3.picking_ids.name, - "move_line_count": len(self.batch3.picking_ids.move_line_ids), - "origin": self.batch3.picking_ids.origin, - # quantity of products (3) * weight of product (2) - "weight": 6.0, - "partner": { - "id": self.batch3.picking_ids.partner_id.id, - "name": self.batch3.picking_ids.partner_id.name, - }, - } - ], - }, + response, next_state="confirm_start", data=data, ) def test_find_batch_assigned(self): @@ -89,26 +71,9 @@ def test_find_batch_assigned(self): self.assertEqual(self.batch2.state, "in_progress") # we expect to find batch 2 as it's assigned to the current user + data = self.data.picking_batch(self.batch2, with_pickings=True) self.assert_response( - response, - next_state="confirm_start", - data={ - "id": self.batch2.id, - "name": self.batch2.name, - "pickings": [ - { - "id": self.batch2.picking_ids.id, - "name": self.batch2.picking_ids.name, - "move_line_count": len(self.batch2.picking_ids.move_line_ids), - "origin": self.batch2.picking_ids.origin, - "weight": 6.0, - "partner": { - "id": self.batch2.picking_ids.partner_id.id, - "name": self.batch2.picking_ids.partner_id.name, - }, - } - ], - }, + response, next_state="confirm_start", data=data, ) def test_find_batch_unassigned_draft(self): @@ -125,26 +90,9 @@ def test_find_batch_unassigned_draft(self): # we expect to find batch 2 as it's the first one with all pickings # available + data = self.data.picking_batch(self.batch2, with_pickings=True) self.assert_response( - response, - next_state="confirm_start", - data={ - "id": self.batch2.id, - "name": self.batch2.name, - "pickings": [ - { - "id": self.batch2.picking_ids.id, - "name": self.batch2.picking_ids.name, - "move_line_count": len(self.batch2.picking_ids.move_line_ids), - "origin": self.batch2.picking_ids.origin, - "weight": 6.0, - "partner": { - "id": self.batch2.picking_ids.partner_id.id, - "name": self.batch2.picking_ids.partner_id.name, - }, - } - ], - }, + response, next_state="confirm_start", data=data, ) def test_find_batch_not_found(self): @@ -152,7 +100,6 @@ def test_find_batch_not_found(self): # No batch match the rules to work on them, because # their pickings are not available response = self.service.dispatch("find_batch") - self.assert_response( response, next_state="start", @@ -185,25 +132,7 @@ def test_list_batch(self): next_state="manual_selection", data={ "size": 2, - "records": [ - { - "id": self.batch1.id, - "name": self.batch1.name, - "picking_count": 1, - "move_line_count": 1, - "weight": 6.0, - }, - # batch 2 is excluded because assigned to someone else - { - "id": self.batch3.id, - "name": self.batch3.name, - "picking_count": 1, - "move_line_count": 1, - "weight": 6.0, - }, - # batch 4 is excluded because not all of its pickings are - # assigned - ], + "records": self.data.picking_batches(self.batch1 + self.batch3), }, ) @@ -215,16 +144,11 @@ def test_select_in_progress_assigned(self): response = self.service.dispatch( "select", params={"picking_batch_id": self.batch1.id} ) + data = self.data.picking_batch(self.batch1) + # we don't care in these tests, 'find_batch' tests them already + data["pickings"] = self.ANY self.assert_response( - response, - next_state="confirm_start", - data={ - "id": self.batch1.id, - "name": self.batch1.name, - # we don't care in these tests, the 'find_batch' tests already - # check this - "pickings": self.ANY, - }, + response, next_state="confirm_start", data=data, ) def test_select_draft_assigned(self): @@ -238,16 +162,11 @@ def test_select_draft_assigned(self): # The endpoint starts the batch and assign it to self self.assertEqual(self.batch1.user_id, self.env.user) self.assertEqual(self.batch1.state, "in_progress") + data = self.data.picking_batch(self.batch1) + # we don't care in these tests, 'find_batch' tests them already + data["pickings"] = self.ANY self.assert_response( - response, - next_state="confirm_start", - data={ - "id": self.batch1.id, - "name": self.batch1.name, - # we don't care in these tests, the 'find_batch' tests already - # check this - "pickings": self.ANY, - }, + response, next_state="confirm_start", data=data, ) def test_select_draft_unassigned(self): @@ -260,16 +179,11 @@ def test_select_draft_unassigned(self): # The endpoint starts the batch and assign it to self self.assertEqual(self.batch1.user_id, self.env.user) self.assertEqual(self.batch1.state, "in_progress") + data = self.data.picking_batch(self.batch1) + # we don't care in these tests, 'find_batch' tests them already + data["pickings"] = self.ANY self.assert_response( - response, - next_state="confirm_start", - data={ - "id": self.batch1.id, - "name": self.batch1.name, - # we don't care in these tests, the 'find_batch' tests already - # check this - "pickings": self.ANY, - }, + response, next_state="confirm_start", data=data, ) def test_select_not_exists(self): @@ -363,39 +277,12 @@ def test_confirm_start_ok(self): response = self.service.dispatch( "confirm_start", params={"picking_batch_id": self.batch.id} ) + data = self.data.move_line(first_move_line) + data["package_dest"] = None + data["picking"] = self.data.picking(picking) + data["batch"] = self.data.picking_batch(batch) self.assert_response( - response, - data={ - "id": first_move_line.id, - "quantity": 1.0, - "postponed": False, - "location_dest": { - "id": first_move_line.location_dest_id.id, - "name": first_move_line.location_dest_id.name, - }, - "location_src": { - "id": first_move_line.location_id.id, - "name": first_move_line.location_id.name, - }, - "picking": { - "id": picking.id, - "name": picking.name, - "note": None, - "origin": picking.origin, - "partner": {"id": self.customer.id, "name": self.customer.name}, - }, - "batch": {"id": batch.id, "name": batch.name}, - "product": { - "default_code": first_move_line.product_id.default_code, - "display_name": first_move_line.product_id.display_name, - "id": first_move_line.product_id.id, - "name": first_move_line.product_id.name, - "qty_available": first_move_line.product_id.qty_available, - }, - "lot": None, - "package_src": {"id": package.id, "name": package.name}, - }, - next_state="start_line", + response, data=data, next_state="start_line", ) def test_confirm_start_not_exists(self): @@ -433,3 +320,5 @@ def test_confirm_start_all_is_done(self): next_state="start", message={"body": "Batch Transfer complete", "message_type": "success"}, ) + + # TODO: add a test for lines sorting diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py index deaa8204d9..1c7c7b12fb 100644 --- a/shopfloor/tests/test_cluster_picking_stock_issue.py +++ b/shopfloor/tests/test_cluster_picking_stock_issue.py @@ -45,16 +45,8 @@ def _stock_issue(self, line, next_line_func=None): self.assert_response( response, next_state="unload_all", - data={ - "id": self.batch.id, - "location_dest": { - "id": self.packing_location.id, - "name": self.packing_location.name, - }, - "name": self.batch.name, - }, + data=self._data_for_batch(self.batch, self.packing_location), ) - return response def assert_location_qty_and_reserved( diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index 1205ec10b9..9d43419d80 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -61,17 +61,10 @@ def test_prepare_unload_all_same_dest(self): response = self.service.dispatch( "prepare_unload", params={"picking_batch_id": self.batch.id} ) + location = self.packing_location + data = self._data_for_batch(self.batch, location) self.assert_response( - response, - next_state="unload_all", - data={ - "id": self.batch.id, - "name": self.batch.name, - "location_dest": { - "id": self.packing_location.id, - "name": self.packing_location.name, - }, - }, + response, next_state="unload_all", data=data, ) def test_prepare_unload_different_dest(self): @@ -85,18 +78,10 @@ def test_prepare_unload_different_dest(self): "prepare_unload", params={"picking_batch_id": self.batch.id} ) first_line = move_lines[0] + location = first_line.location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) self.assert_response( - response, - next_state="unload_single", - data={ - "id": self.batch.id, - "name": self.batch.name, - "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dest": { - "id": first_line.location_dest_id.id, - "name": first_line.location_dest_id.name, - }, - }, + response, next_state="unload_single", data=data, ) @@ -237,18 +222,10 @@ def test_set_destination_all_but_different_dest(self): "barcode": self.packing_location.barcode, }, ) + location = move_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) self.assert_response( - response, - next_state="unload_single", - data={ - "id": self.batch.id, - "name": self.batch.name, - "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dest": { - "id": move_lines[0].location_dest_id.id, - "name": move_lines[0].location_dest_id.name, - }, - }, + response, next_state="unload_single", data=data, ) def test_set_destination_all_error_location_not_found(self): @@ -261,17 +238,12 @@ def test_set_destination_all_error_location_not_found(self): "set_destination_all", params={"picking_batch_id": self.batch.id, "barcode": "NOTFOUND"}, ) + location = move_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location) self.assert_response( response, next_state="unload_all", - data={ - "id": self.batch.id, - "name": self.batch.name, - "location_dest": { - "id": move_lines[0].location_dest_id.id, - "name": move_lines[0].location_dest_id.name, - }, - }, + data=data, message={ "message_type": "error", "body": "No location found for this barcode.", @@ -295,17 +267,12 @@ def test_set_destination_all_error_location_invalid(self): "barcode": self.dispatch_location.barcode, }, ) + location = move_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location) self.assert_response( response, next_state="unload_all", - data={ - "id": self.batch.id, - "name": self.batch.name, - "location_dest": { - "id": move_lines[0].location_dest_id.id, - "name": move_lines[0].location_dest_id.name, - }, - }, + data=data, message={"message_type": "error", "body": "You cannot place it here"}, ) @@ -322,17 +289,10 @@ def test_set_destination_all_need_confirmation(self): "barcode": self.packing_b_location.barcode, }, ) + location = move_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location) self.assert_response( - response, - next_state="confirm_unload_all", - data={ - "id": self.batch.id, - "name": self.batch.name, - "location_dest": { - "id": move_lines[0].location_dest_id.id, - "name": move_lines[0].location_dest_id.name, - }, - }, + response, next_state="confirm_unload_all", data=data, ) def test_set_destination_all_with_confirmation(self): @@ -391,19 +351,13 @@ def test_unload_split_ok(self): response = self.service.dispatch( "unload_split", params={"picking_batch_id": self.batch.id} ) + location = move_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) self.assert_response( # the remaining move line still needs to be picked response, next_state="unload_single", - data={ - "id": self.batch.id, - "name": self.batch.name, - "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dest": { - "id": move_lines[0].location_dest_id.id, - "name": move_lines[0].location_dest_id.name, - }, - }, + data=data, ) @@ -435,18 +389,10 @@ def test_unload_scan_pack_ok(self): "barcode": self.bin1.name, }, ) + location = self.move_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) self.assert_response( - response, - next_state="unload_set_destination", - data={ - "id": self.batch.id, - "name": self.batch.name, - "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dest": { - "id": self.move_lines[0].location_dest_id.id, - "name": self.move_lines[0].location_dest_id.name, - }, - }, + response, next_state="unload_set_destination", data=data, ) def test_unload_scan_pack_wrong_barcode(self): @@ -459,18 +405,12 @@ def test_unload_scan_pack_wrong_barcode(self): "barcode": self.bin2.name, }, ) + location = self.move_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) self.assert_response( response, next_state="unload_single", - data={ - "id": self.batch.id, - "name": self.batch.name, - "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dest": { - "id": self.move_lines[0].location_dest_id.id, - "name": self.move_lines[0].location_dest_id.name, - }, - }, + data=data, message={"message_type": "error", "body": "Wrong bin"}, ) @@ -550,19 +490,10 @@ def test_unload_scan_destination_ok(self): ) self.assertRecordValues(self.batch, [{"state": "in_progress"}]) + location = self.bin2_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin2) self.assert_response( - response, - next_state="unload_single", - data={ - "id": self.batch.id, - "name": self.batch.name, - # the line of bin1 is unloaded, next one will be bin2 - "package": {"id": self.bin2.id, "name": self.bin2.name}, - "location_dest": { - "id": self.bin2_lines[0].location_dest_id.id, - "name": self.bin2_lines[0].location_dest_id.name, - }, - }, + response, next_state="unload_single", data=data, ) def test_unload_scan_destination_one_line_of_picking_only(self): @@ -619,20 +550,10 @@ def test_unload_scan_destination_one_line_of_picking_only(self): ], ) self.assertRecordValues(self.batch, [{"state": "in_progress"}]) - + location = bin3_line.location_dest_id + data = self._data_for_batch(self.batch, location, pack=bin3) self.assert_response( - response, - next_state="unload_single", - data={ - "id": self.batch.id, - "name": self.batch.name, - # the line of bin2 is unloaded, next one will be bin3 - "package": {"id": bin3.id, "name": bin3.name}, - "location_dest": { - "id": bin3_line.location_dest_id.id, - "name": bin3_line.location_dest_id.name, - }, - }, + response, next_state="unload_single", data=data, ) def test_unload_scan_destination_last_line(self): @@ -692,18 +613,12 @@ def test_unload_scan_destination_error_location_not_found(self): "barcode": "¤", }, ) + location = self.bin1_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) self.assert_response( response, next_state="unload_set_destination", - data={ - "id": self.batch.id, - "name": self.batch.name, - "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dest": { - "id": self.bin1_lines[0].location_dest_id.id, - "name": self.bin1_lines[0].location_dest_id.name, - }, - }, + data=data, message={ "message_type": "error", "body": "No location found for this barcode.", @@ -724,18 +639,12 @@ def test_unload_scan_destination_error_location_invalid(self): "barcode": self.dispatch_location.barcode, }, ) + location = self.bin1_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) self.assert_response( response, next_state="unload_set_destination", - data={ - "id": self.batch.id, - "name": self.batch.name, - "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dest": { - "id": self.bin1_lines[0].location_dest_id.id, - "name": self.bin1_lines[0].location_dest_id.name, - }, - }, + data=data, message={"message_type": "error", "body": "You cannot place it here"}, ) @@ -749,18 +658,10 @@ def test_unload_scan_destination_need_confirmation(self): "barcode": self.packing_b_location.barcode, }, ) + location = self.bin1_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) self.assert_response( - response, - next_state="confirm_unload_set_destination", - data={ - "id": self.batch.id, - "name": self.batch.name, - "package": {"id": self.bin1.id, "name": self.bin1.name}, - "location_dest": { - "id": self.bin1_lines[0].location_dest_id.id, - "name": self.bin1_lines[0].location_dest_id.name, - }, - }, + response, next_state="confirm_unload_set_destination", data=data, ) def test_unload_scan_destination_with_confirmation(self): @@ -817,7 +718,8 @@ def test_unload_scan_destination_completion_info(self): "barcode": dest_location.barcode, }, ) - + location = self.bin2_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin2) self.assert_response( response, next_state="unload_single", @@ -825,14 +727,5 @@ def test_unload_scan_destination_completion_info(self): "body": "Last operation of transfer {}. Next operation " "({}) is ready to proceed.".format(picking.name, next_picking.name) }, - data={ - "id": self.batch.id, - "name": self.batch.name, - # the line of bin1 is unloaded, next one will be bin2 - "package": {"id": self.bin2.id, "name": self.bin2.name}, - "location_dest": { - "id": self.bin2_lines[0].location_dest_id.id, - "name": self.bin2_lines[0].location_dest_id.name, - }, - }, + data=data, ) diff --git a/shopfloor/tests/test_scan_anything.py b/shopfloor/tests/test_scan_anything.py index 81616439f4..87128304e0 100644 --- a/shopfloor/tests/test_scan_anything.py +++ b/shopfloor/tests/test_scan_anything.py @@ -29,35 +29,35 @@ def test_scan_product(self): record.barcode = "PROD-B" rec_type = "product" identifier = record.barcode - data = self.data.product_detail(record) + data = self.data_detail.product_detail(record) self._test_response_ok(rec_type, data, identifier) def test_scan_location(self): record = self.stock_location rec_type = "location" identifier = record.barcode - data = self.data.location_detail(record) + data = self.data_detail.location_detail(record) self._test_response_ok(rec_type, data, identifier) def test_scan_package(self): record = self.package rec_type = "package" identifier = record.name - data = self.data.package_detail(record) + data = self.data_detail.package_detail(record) self._test_response_ok(rec_type, data, identifier) def test_scan_lot(self): record = self.lot rec_type = "lot" identifier = record.name - data = self.data.lot_detail(record) + data = self.data_detail.lot_detail(record) self._test_response_ok(rec_type, data, identifier) def test_scan_transfer(self): record = self.picking rec_type = "transfer" identifier = record.name - data = self.data.picking_detail(record) + data = self.data_detail.picking_detail(record) self._test_response_ok(rec_type, data, identifier) def test_scan_error(self): From bd01a6dd5dc37418adc4e3c61f17a220e9d2d9f8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 19 May 2020 16:45:25 +0200 Subject: [PATCH 218/940] backend: cluster_picking add message for line done --- shopfloor/actions/message.py | 6 ++++++ shopfloor/services/cluster_picking.py | 6 +++++- shopfloor/tests/test_cluster_picking_unload.py | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index f565b5e823..aa68853d4a 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -224,3 +224,9 @@ def batch_transfer_complete(self): "message_type": "success", "body": _("Batch Transfer complete"), } + + def batch_transfer_line_done(self): + return { + "message_type": "success", + "body": _("Batch Transfer line done"), + } diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index c0ba42fc97..632b802c86 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -1153,7 +1153,11 @@ def _unload_end(self, batch, completion_info_popup=None): next_line = self._next_line_for_pick(batch) if next_line: - return self._response_for_start_line(next_line, popup=completion_info_popup) + return self._response_for_start_line( + next_line, + message=self.msg_store.batch_transfer_line_done(), + popup=completion_info_popup, + ) else: # TODO add tests for this (for instance a picking is not 'done' # because a move was unassigned, we want to validate the batch to diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index 9d43419d80..2152b79d0c 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -206,6 +206,7 @@ def test_set_destination_all_remaining_lines(self): response, next_state="start_line", data=self._line_data(move_lines[2]), + message={"body": "Batch Transfer line done", "message_type": "success"}, ) def test_set_destination_all_but_different_dest(self): From 9bb17a1ada9ac142893d992b5ad9894734630f50 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 20 May 2020 08:15:08 +0200 Subject: [PATCH 219/940] backend: fix cluster picking sorting lines --- shopfloor/services/cluster_picking.py | 2 +- shopfloor/tests/test_cluster_picking_skip.py | 52 +++++++++++--------- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 632b802c86..4565801184 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -323,8 +323,8 @@ def _pick_next_line(self, batch, message=None, force_line=None): @staticmethod def _sort_key_lines(line): return ( - line.location_id, line.shopfloor_postponed, + line.location_id.name, line.move_id.sequence, line.move_id.id, line.id, diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py index 2e78a3acc2..8e1a004c10 100644 --- a/shopfloor/tests/test_cluster_picking_skip.py +++ b/shopfloor/tests/test_cluster_picking_skip.py @@ -34,34 +34,38 @@ def _skip_line(self, line, next_line=None): return response def test_skip_line(self): + # put one picking in another location + self.batch.picking_ids[1].location_id = self.shelf1 + self.batch.picking_ids[1].move_lines.location_id = self.shelf1 + # select batch self._simulate_batch_selected(self.batch, in_package=True) - lines = self.batch.picking_ids.move_line_ids.sorted( - lambda line: ( - line.location_id, - line.shopfloor_postponed, - line.move_id.sequence, - line.move_id.id, - line.id, - ) + + # enforce names to have reliable sorting + self.stock_location.name = "LOC2" + self.shelf1.name = "LOC1" + all_lines = self.batch.picking_ids.move_line_ids + loc1_lines = all_lines.filtered(lambda line: (line.location_id == self.shelf1)) + loc2_lines = all_lines.filtered( + lambda line: (line.location_id == self.stock_location) + ) + # no line postponed yet + self.assertEqual( + all_lines.mapped("shopfloor_postponed"), [False, False, False, False] ) - self.assertFalse(lines[0].shopfloor_postponed) - self._skip_line(lines[0], lines[1]) - self.assertTrue(lines[0].shopfloor_postponed) - # 2nd line, next is 3rd - self.assertFalse(lines[1].shopfloor_postponed) - self._skip_line(lines[1], lines[2]) - self.assertTrue(lines[1].shopfloor_postponed) + # skip line from loc 1 + self._skip_line(loc1_lines[0], loc1_lines[1]) + self.assertTrue(loc1_lines[0].shopfloor_postponed) + + # 2nd line, next is 1st from 2nd location + self.assertFalse(loc1_lines[1].shopfloor_postponed) + self._skip_line(loc1_lines[1], loc2_lines[0]) + self.assertTrue(loc1_lines[1].shopfloor_postponed) + # 3rd line, next is 4th - self.assertFalse(lines[2].shopfloor_postponed) - self._skip_line(lines[2], lines[3]) - self.assertTrue(lines[2].shopfloor_postponed) - # 4th line, next is 1st - # the next line for the last one is the 1st, - # because you'll have to process it anyway - self.assertFalse(lines[3].shopfloor_postponed) - self._skip_line(lines[3], lines[0]) - self.assertTrue(lines[3].shopfloor_postponed) + self.assertFalse(loc2_lines[0].shopfloor_postponed) + self._skip_line(loc2_lines[0], loc2_lines[1]) + self.assertTrue(loc2_lines[0].shopfloor_postponed) # TODO tests for transitions to next line / no next lines, ... From 6057e5a620a67eccf18d7cc5e0a91cab29e8368d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 20 May 2020 08:57:40 +0200 Subject: [PATCH 220/940] backend: move some key stuff to tests common --- shopfloor/tests/common.py | 25 ++++++++++++++++++++ shopfloor/tests/test_actions_data_detail.py | 25 ++++++++------------ shopfloor/tests/test_cluster_picking_base.py | 21 ---------------- 3 files changed, 35 insertions(+), 36 deletions(-) diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 1cacd73b78..db21e2a791 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -71,6 +71,10 @@ def setUpClass(cls): cls.data = work.component(usage="data") with cls.work_on_actions(cls) as work: cls.data_detail = work.component(usage="data_detail") + with cls.work_on_services(cls) as work: + cls.schema = work.component(usage="schema") + with cls.work_on_services(cls) as work: + cls.schema_detail = work.component(usage="schema_detail") @classmethod def setUpClassVars(cls): @@ -263,3 +267,24 @@ def _create_picking_batch(cls, products): batch.picking_ids.action_confirm() batch.picking_ids.action_assign() return batch + + @classmethod + def _simulate_batch_selected( + cls, batches, in_package=False, in_lot=False, fill_stock=True + ): + """Create a state as if a batch was selected by the user + + * The picking batch is in progress + * It is assigned to the current user + * All the move lines are available + + Note: currently, this method create a source package that contains + all the products of the batch. It is enough for the current tests. + """ + pickings = batches.mapped("picking_ids") + if fill_stock: + cls._fill_stock_for_moves( + pickings.mapped("move_lines"), in_package=in_package, in_lot=in_lot + ) + pickings.action_assign() + batches.write({"state": "in_progress", "user_id": cls.env.uid}) diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index c208d522c9..2ff4ccf67e 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -27,11 +27,6 @@ def setUpClass(cls): "stock_storage_type.package_storage_type_pallets" ) - def setUp(self): - super().setUp() - with self.work_on_services() as work: - self.schema = work.component(usage="schema_detail") - def _expected_location_detail(self, record, **kw): return dict( **self._expected_location(record), @@ -83,7 +78,7 @@ class ActionsDataDetailCase(ActionsDataDetailCaseBase): def test_data_location(self): location = self.stock_location data = self.data_detail.location_detail(location) - self.assert_schema(self.schema.location_detail(), data) + self.assert_schema(self.schema_detail.location_detail(), data) move_lines = self.env["stock.move.line"].search( [ ("location_id", "=", location.id), @@ -97,7 +92,7 @@ def test_data_location(self): def test_data_packaging(self): data = self.data_detail.packaging(self.packaging) - self.assert_schema(self.schema.packaging(), data) + self.assert_schema(self.schema_detail.packaging(), data) expected = {"id": self.packaging.id, "name": self.packaging.name} self.assertDictEqual(data, expected) @@ -112,7 +107,7 @@ def test_data_lot(self): } ) data = self.data_detail.lot_detail(lot) - self.assert_schema(self.schema.lot_detail(), data) + self.assert_schema(self.schema_detail.lot_detail(), data) expected = { "id": lot.id, @@ -131,7 +126,7 @@ def test_data_package(self): package.package_storage_type_id = self.storage_type_pallet # package.invalidate_cache() data = self.data_detail.package_detail(package, picking=self.picking) - self.assert_schema(self.schema.package_detail(), data) + self.assert_schema(self.schema_detail.package_detail(), data) lines = self.env["stock.move.line"].search( [("package_id", "=", package.id), ("state", "not in", ("done", "cancel"))] @@ -165,7 +160,7 @@ def test_data_picking(self): ) picking.move_lines.write({"date_expected": "2020-05-13"}) data = self.data_detail.picking_detail(picking) - self.assert_schema(self.schema.picking_detail(), data) + self.assert_schema(self.schema_detail.picking_detail(), data) expected = { "id": picking.id, "move_line_count": 4, @@ -193,7 +188,7 @@ def test_data_move_line_package(self): ) move_line.write({"qty_done": 3.0, "result_package_id": result_package.id}) data = self.data_detail.move_line(move_line) - self.assert_schema(self.schema.move_line(), data) + self.assert_schema(self.schema_detail.move_line(), data) product = self.product_a.with_context(location=move_line.location_id.id) expected = { "id": move_line.id, @@ -221,7 +216,7 @@ def test_data_move_line_package(self): def test_data_move_line_lot(self): move_line = self.move_b.move_line_ids data = self.data_detail.move_line(move_line) - self.assert_schema(self.schema.move_line(), data) + self.assert_schema(self.schema_detail.move_line(), data) product = self.product_b.with_context(location=move_line.location_id.id) expected = { "id": move_line.id, @@ -244,7 +239,7 @@ def test_data_move_line_package_lot(self): self.maxDiff = None move_line = self.move_c.move_line_ids data = self.data_detail.move_line(move_line) - self.assert_schema(self.schema.move_line(), data) + self.assert_schema(self.schema_detail.move_line(), data) product = self.product_c.with_context(location=move_line.location_id.id) expected = { "id": move_line.id, @@ -276,7 +271,7 @@ def test_data_move_line_package_lot(self): def test_data_move_line_raw(self): move_line = self.move_d.move_line_ids data = self.data_detail.move_line(move_line) - self.assert_schema(self.schema.move_line(), data) + self.assert_schema(self.schema_detail.move_line(), data) product = self.product_d.with_context(location=move_line.location_id.id) expected = { "id": move_line.id, @@ -320,6 +315,6 @@ def test_product(self): } ) data = self.data_detail.product_detail(product) - self.assert_schema(self.schema.product_detail(), data) + self.assert_schema(self.schema_detail.product_detail(), data) expected = self._expected_product_detail(product, full=True) self.assertDictEqual(data, expected) diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py index bd551f0c80..3453ede428 100644 --- a/shopfloor/tests/test_cluster_picking_base.py +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -16,27 +16,6 @@ def setUp(self): with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="cluster_picking") - @classmethod - def _simulate_batch_selected( - cls, batches, in_package=False, in_lot=False, fill_stock=True - ): - """Create a state as if a batch was selected by the user - - * The picking batch is in progress - * It is assigned to the current user - * All the move lines are available - - Note: currently, this method create a source package that contains - all the products of the batch. It is enough for the current tests. - """ - pickings = batches.mapped("picking_ids") - if fill_stock: - cls._fill_stock_for_moves( - pickings.mapped("move_lines"), in_package=in_package, in_lot=in_lot - ) - pickings.action_assign() - batches.write({"state": "in_progress", "user_id": cls.env.uid}) - def _line_data(self, move_line, qty=None, package_dest=False): picking = move_line.picking_id # A package exists on the move line, because the quant created From ff1fad115e3e3cb73773d44e9b59bee6e203c7a7 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 20 May 2020 09:00:07 +0200 Subject: [PATCH 221/940] backend: fix picking batch conversion + add test --- shopfloor/models/stock_picking_batch.py | 4 +- shopfloor/services/cluster_picking.py | 4 +- shopfloor/services/schema.py | 7 ++- shopfloor/tests/test_actions_data.py | 62 ++++++++++++++++++++++--- 4 files changed, 64 insertions(+), 13 deletions(-) diff --git a/shopfloor/models/stock_picking_batch.py b/shopfloor/models/stock_picking_batch.py index aff3ef2ecf..07bf57a099 100644 --- a/shopfloor/models/stock_picking_batch.py +++ b/shopfloor/models/stock_picking_batch.py @@ -17,7 +17,9 @@ class StockPickingBatch(models.Model): help="Technical field. Indicates total weight of transfers included.", ) - @api.depends("picking_ids.total_weight", "picking_ids.move_line_ids") + @api.depends( + "picking_ids.state", "picking_ids.total_weight", "picking_ids.move_line_ids" + ) def _compute_picking_info(self): for item in self: assigned_pickings = item.picking_ids.filtered( diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 4565801184..9653e9f743 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -1550,9 +1550,7 @@ def unload_scan_destination(self): @property def _schema_for_batch_details(self): - schema = self.schemas.picking_batch() - schema["pickings"] = self.schemas._schema_list_of(self.schemas.picking()) - return schema + return self.schemas.picking_batch(with_pickings=True) @property def _schema_for_single_line_details(self): diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index d97dbf36b6..78e8545520 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -148,14 +148,17 @@ def packaging(self): "name": {"type": "string", "nullable": False, "required": True}, } - def picking_batch(self): - return { + def picking_batch(self, with_pickings=True): + schema = { "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, "picking_count": {"required": True, "type": "integer"}, "move_line_count": {"required": True, "type": "integer"}, "weight": {"required": True, "nullable": True, "type": "float"}, } + if with_pickings: + schema["pickings"] = self._schema_list_of(self.picking()) + return schema def package_level(self): return { diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 4f44ad4dcd..64f95e8399 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -1,6 +1,6 @@ import logging -from .common import CommonCase +from .common import CommonCase, PickingBatchMixin _logger = logging.getLogger(__name__) @@ -64,11 +64,6 @@ def _expected_product(self, record, **kw): class ActionsDataCase(ActionsDataCaseBase): - def setUp(self): - super().setUp() - with self.work_on_services() as work: - self.schema = work.component(usage="schema") - def test_data_packaging(self): data = self.data.packaging(self.packaging) self.assert_schema(self.schema.packaging(), data) @@ -183,7 +178,6 @@ def test_data_move_line_lot(self): self.assertDictEqual(data, expected) def test_data_move_line_package_lot(self): - self.maxDiff = None move_line = self.move_c.move_line_ids data = self.data.move_line(move_line) self.assert_schema(self.schema.move_line(), data) @@ -232,3 +226,57 @@ def test_data_move_line_raw(self): "location_dest": self._expected_location(move_line.location_dest_id), } self.assertDictEqual(data, expected) + + +class ActionsDataCaseBatchPicking(ActionsDataCaseBase, PickingBatchMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.batch = cls._create_picking_batch( + [ + [ + cls.BatchProduct(product=cls.product_a, quantity=10), + cls.BatchProduct(product=cls.product_b, quantity=20), + ], + [cls.BatchProduct(product=cls.product_a, quantity=30)], + ] + ) + + def test_data_picking_batch(self): + data = self.data.picking_batch(self.batch) + self.assert_schema(self.schema.picking_batch(), data) + # no assigned pickings + expected = { + "id": self.batch.id, + "name": self.batch.name, + "picking_count": 0, + "move_line_count": 0, + "weight": 0.0, + } + self.assertDictEqual(data, expected) + + self._simulate_batch_selected(self.batch, fill_stock=True) + expected.update( + { + "picking_count": 2, + "move_line_count": 3, + "weight": sum(self.batch.picking_ids.mapped("total_weight")), + } + ) + data = self.data.picking_batch(self.batch) + self.assertDictEqual(data, expected) + + def test_data_picking_batch_with_pickings(self): + self._simulate_batch_selected(self.batch, fill_stock=True) + data = self.data.picking_batch(self.batch, with_pickings=True) + self.assert_schema(self.schema.picking_batch(with_pickings=True), data) + # no assigned pickings + expected = { + "id": self.batch.id, + "name": self.batch.name, + "picking_count": 2, + "move_line_count": 3, + "weight": sum(self.batch.picking_ids.mapped("total_weight")), + "pickings": self.data.pickings(self.batch.picking_ids), + } + self.assertDictEqual(data, expected) From 17b23a2494cd7de03439b2bc840330ea8aa23550 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 20 May 2020 17:29:24 +0200 Subject: [PATCH 222/940] backend: allow edit control flags on move lines --- shopfloor/views/stock_move_line.xml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/shopfloor/views/stock_move_line.xml b/shopfloor/views/stock_move_line.xml index 3945954cdb..5b970af673 100644 --- a/shopfloor/views/stock_move_line.xml +++ b/shopfloor/views/stock_move_line.xml @@ -10,22 +10,20 @@ + From 4458fbafd4bc77104feea146a11cc2257824b111 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 22 May 2020 14:14:18 +0200 Subject: [PATCH 223/940] backend: fix picking summary data (missing packaging) --- shopfloor/services/checkout.py | 39 ++++++++++++++++++++++------------ shopfloor/services/schema.py | 6 +++--- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 98db69b2db..b00291df70 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -152,15 +152,21 @@ def _response_for_summary(self, picking, need_confirm=False, message=None): def _response_for_select_document(self, message=None): return self._response(next_state="select_document", message=message) - def _data_for_move_lines(self, lines): + def _data_for_move_lines(self, lines, **kw): data_struct = self.actions_for("data") - return data_struct.move_lines(lines) + return data_struct.move_lines(lines, **kw) def _data_for_stock_picking(self, picking, done=False): data_struct = self.actions_for("data") data = data_struct.picking(picking) line_picker = self._lines_checkout_done if done else self._lines_to_pack - data.update({"move_lines": self._data_for_move_lines(line_picker(picking))}) + data.update( + { + "move_lines": self._data_for_move_lines( + line_picker(picking), with_packaging=done + ) + } + ) return data def _lines_checkout_done(self, picking): @@ -576,7 +582,7 @@ def _put_lines_in_allowed_package(self, picking, selected_lines, package): return self._response_for_select_line( picking, message={ - "message_type": "info", + "message_type": "success", "body": _("Product(s) packed in {}").format(package.name), }, ) @@ -1167,28 +1173,35 @@ def _states(self): "select_dest_package": self._schema_select_package, "summary": self._schema_summary, "change_packaging": self._schema_select_packaging, - "confirm_done": self._schema_stock_picking_details, + "confirm_done": self._schema_confirm_done, } - @property - def _schema_stock_picking_details(self): + def _schema_stock_picking(self, lines_with_packaging=False): schema = self.schemas.picking() schema.update( { - "move_lines": { - "type": "list", - "schema": {"type": "dict", "schema": self.schemas.move_line()}, - }, + "move_lines": self.schemas._schema_list_of( + self.schemas.move_line(with_packaging=lines_with_packaging) + ), } ) - return {"picking": {"type": "dict", "schema": schema}} + return {"picking": self.schemas._schema_dict_of(schema, required=True)} + + @property + def _schema_stock_picking_details(self): + return self._schema_stock_picking() @property def _schema_summary(self): return dict( - self._schema_stock_picking_details, all_processed={"type": "boolean"} + self._schema_stock_picking(lines_with_packaging=True), + all_processed={"type": "boolean"}, ) + @property + def _schema_confirm_done(self): + return self._schema_stock_picking(lines_with_packaging=True) + @property def _schema_selection_list(self): return { diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 78e8545520..999c78c658 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -67,7 +67,7 @@ def picking(self): }, } - def move_line(self): + def move_line(self, with_packaging=False): return { "id": {"type": "integer", "required": True}, "qty_done": {"type": "float", "required": True}, @@ -83,13 +83,13 @@ def move_line(self): "type": "dict", "required": True, "nullable": True, - "schema": self.package(), + "schema": self.package(with_packaging=with_packaging), }, "package_dest": { "type": "dict", "required": False, "nullable": True, - "schema": self.package(), + "schema": self.package(with_packaging=with_packaging), }, "location_src": { "type": "dict", From 56ecc902e01d904e7960b122ad36b3201dc2b827 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 22 May 2020 14:14:46 +0200 Subject: [PATCH 224/940] backend: cluster picking fix list batch w/out pickings --- shopfloor/services/cluster_picking.py | 42 +++++++++++++++------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 9653e9f743..c72435ca72 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -222,26 +222,32 @@ def _batch_picking_search(self, name_fragment=None, batch_ids=None): if batch_ids: domain = expression.AND([domain, [("id", "in", batch_ids)]]) records = self.env["stock.picking.batch"].search(domain, order="id asc") - records = records.filtered( - # Include done/cancel because we want to be able to work on the - # batch even if some pickings are done/canceled. They'll should be - # ignored later. - lambda batch: all( - ( - # When the batch is already in progress, we do not care - # about state of the pickings, because we want to be able - # to recover it in any case, even if, for instance, a stock - # error changed a picking to unavailable after the user - # started to work on the batch. - batch.state == "in_progress" - or picking.state in ("assigned", "done", "cancel") - ) - and picking.picking_type_id in self.picking_types - for picking in batch.picking_ids - ) - ) + records = records.filtered(self._batch_filter) return records + def _batch_filter(self, batch): + if not batch.picking_ids: + return False + return batch.picking_ids.filtered(self._batch_picking_filter) + + def _batch_picking_filter(self, picking): + # Picking type guard + if picking.picking_type_id not in self.picking_types: + return False + # Include done/cancel because we want to be able to work on the + # batch even if some pickings are done/canceled. They'll should be + # ignored later. + # When the batch is already in progress, we do not care + # about state of the pickings, because we want to be able + # to recover it in any case, even if, for instance, a stock + # error changed a picking to unavailable after the user + # started to work on the batch. + return picking.batch_id.state == "in_progress" or picking.state in ( + "assigned", + "done", + "cancel", + ) + # TODO this may be used in other scenarios? if so, extract def _select_a_picking_batch(self, batches): # look for in progress + assigned to self first From acba2cc89dec7cb9f3cfc6afcef006fb1fa39958 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 22 May 2020 14:59:21 +0200 Subject: [PATCH 225/940] backend: checkout fixes --- shopfloor/services/checkout.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index b00291df70..73cb1395d2 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -706,11 +706,13 @@ def no_package(self, picking_id, selected_line_ids): if not picking.exists(): return self._response_stock_picking_does_not_exist() selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() - selected_lines.write({"shopfloor_checkout_done": True}) + selected_lines.write( + {"shopfloor_checkout_done": True, "result_package_id": False} + ) return self._response_for_select_line( picking, message={ - "message_type": "info", + "message_type": "success", "body": _("Product(s) processed as raw product(s)"), }, ) @@ -938,10 +940,13 @@ def cancel_line(self, picking_id, package_id=None, line_id=None): "shopfloor_checkout_done": False, } ) + msg = _("Package cancelled") if line: line.write({"qty_done": 0, "shopfloor_checkout_done": False}) - - return self._response_for_summary(picking) + msg = _("Line cancelled") + return self._response_for_summary( + picking, message={"message_type": "success", "body": msg} + ) def done(self, picking_id, confirmation=False): """Set the moves as done From 73701a31f034ad9808650abfcd95bb9a8d46f80c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 22 May 2020 15:46:21 +0200 Subject: [PATCH 226/940] backend: fix success messages --- shopfloor/actions/message.py | 2 +- shopfloor/services/cluster_picking.py | 4 ++-- shopfloor/tests/test_checkout_cancel_line.py | 2 ++ shopfloor/tests/test_checkout_list_package.py | 2 +- shopfloor/tests/test_checkout_new_package.py | 2 +- shopfloor/tests/test_checkout_no_package.py | 2 +- .../tests/test_checkout_scan_package_action.py | 6 +++--- .../tests/test_cluster_picking_change_pack_lot.py | 14 +++++++------- shopfloor/tests/test_single_pack_putaway.py | 4 ++-- shopfloor/tests/test_single_pack_transfer.py | 4 ++-- 10 files changed, 22 insertions(+), 20 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index aa68853d4a..30bc8e10fd 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -123,7 +123,7 @@ def already_done(self): def confirm_canceled_scan_next_pack(self): return { - "message_type": "info", + "message_type": "success", "body": _("Canceled, you can scan a new pack."), } diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index c72435ca72..8ad83d8ac1 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -986,7 +986,7 @@ def _change_pack_lot_change_lot(self, move_line, lot): success_body += _(" A draft inventory has been created for control.") return self._response_for_scan_destination( - move_line, message={"message_type": "info", "body": success_body} + move_line, message={"message_type": "success", "body": success_body} ) def _package_identical_move_lines_qty(self, package, move_lines): @@ -1075,7 +1075,7 @@ def _change_pack_lot_change_package(self, move_line, package): return self._response_for_scan_destination( move_line, message={ - "message_type": "info", + "message_type": "success", "body": _("Package {} replaced by package {}").format( previous_package.name, package.name ), diff --git a/shopfloor/tests/test_checkout_cancel_line.py b/shopfloor/tests/test_checkout_cancel_line.py index 2dce5bca77..392a525ccc 100644 --- a/shopfloor/tests/test_checkout_cancel_line.py +++ b/shopfloor/tests/test_checkout_cancel_line.py @@ -89,6 +89,7 @@ def test_cancel_package_ok(self): "picking": self._stock_picking_data(picking, done=True), "all_processed": False, }, + message={"body": "Package cancelled", "message_type": "success"}, ) def test_cancel_line_ok(self): @@ -114,6 +115,7 @@ def test_cancel_line_ok(self): "picking": self._stock_picking_data(picking, done=True), "all_processed": False, }, + message={"body": "Line cancelled", "message_type": "success"}, ) def test_cancel_line_error_package_not_found(self): diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py index 2055f2041a..5a075d183a 100644 --- a/shopfloor/tests/test_checkout_list_package.py +++ b/shopfloor/tests/test_checkout_list_package.py @@ -144,7 +144,7 @@ def _assert_package_set(self, response): next_state="select_line", data={"picking": self._stock_picking_data(self.picking)}, message={ - "message_type": "info", + "message_type": "success", "body": "Product(s) packed in {}".format(self.pack1.name), }, ) diff --git a/shopfloor/tests/test_checkout_new_package.py b/shopfloor/tests/test_checkout_new_package.py index 46403ac9cb..7e62c0f3f0 100644 --- a/shopfloor/tests/test_checkout_new_package.py +++ b/shopfloor/tests/test_checkout_new_package.py @@ -56,7 +56,7 @@ def test_new_package_ok(self): next_state="select_line", data={"picking": self._stock_picking_data(picking)}, message={ - "message_type": "info", + "message_type": "success", "body": "Product(s) packed in {}".format(new_package.name), }, ) diff --git a/shopfloor/tests/test_checkout_no_package.py b/shopfloor/tests/test_checkout_no_package.py index 052c8626c4..f997ccad18 100644 --- a/shopfloor/tests/test_checkout_no_package.py +++ b/shopfloor/tests/test_checkout_no_package.py @@ -48,7 +48,7 @@ def test_no_package_ok(self): next_state="select_line", data={"picking": self._stock_picking_data(picking)}, message={ - "message_type": "info", + "message_type": "success", "body": "Product(s) processed as raw product(s)", }, ) diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py index dcc78ce607..58edd46ded 100644 --- a/shopfloor/tests/test_checkout_scan_package_action.py +++ b/shopfloor/tests/test_checkout_scan_package_action.py @@ -165,7 +165,7 @@ def test_scan_package_action_scan_package_keep_source_package_ok(self): next_state="select_line", data={"picking": self._stock_picking_data(picking)}, message={ - "message_type": "info", + "message_type": "success", "body": "Product(s) packed in {}".format(pack1.name), }, ) @@ -264,7 +264,7 @@ def test_scan_package_action_scan_package_use_existing_package_ok(self): "all_processed": True, }, message={ - "message_type": "info", + "message_type": "success", "body": "Product(s) packed in {}".format(package.name), }, ) @@ -349,7 +349,7 @@ def test_scan_package_action_scan_packaging_ok(self): next_state="select_line", data={"picking": self._stock_picking_data(picking)}, message={ - "message_type": "info", + "message_type": "success", "body": "Product(s) packed in {}".format(new_package.name), }, ) diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py index 5e5c77a561..ed0f38d3d3 100644 --- a/shopfloor/tests/test_cluster_picking_change_pack_lot.py +++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py @@ -121,7 +121,7 @@ def test_change_pack_lot_change_pack_ok(self): new_package.name, success=True, message={ - "message_type": "info", + "message_type": "success", "body": "Package {} replaced by package {}".format( initial_package.name, new_package.name ), @@ -161,7 +161,7 @@ def test_change_pack_lot_change_pack_different_location(self): new_package.name, success=True, message={ - "message_type": "info", + "message_type": "success", "body": "Package {} replaced by package {}".format( initial_package.name, new_package.name ), @@ -197,7 +197,7 @@ def test_change_pack_lot_change_lot_in_package_ok(self): new_lot.name, success=True, message={ - "message_type": "info", + "message_type": "success", "body": "Package {} replaced by package {}".format( initial_package.name, new_package.name ), @@ -286,7 +286,7 @@ def test_change_pack_lot_change_lot_ok(self): new_lot.name, success=True, message={ - "message_type": "info", + "message_type": "success", "body": "Lot {} replaced by lot {}.".format( initial_lot.name, new_lot.name ), @@ -312,7 +312,7 @@ def test_change_pack_lot_change_lot_different_location_ok(self): new_lot.name, success=True, message={ - "message_type": "info", + "message_type": "success", "body": "Lot {} replaced by lot {}. A draft inventory has" " been created for control.".format(initial_lot.name, new_lot.name), }, @@ -432,7 +432,7 @@ def test_change_pack_lot_change_pack_multi_content_with_lot(self): new_package.name, success=True, message={ - "message_type": "info", + "message_type": "success", "body": "Package {} replaced by package {}".format( initial_package.name, new_package.name ), @@ -487,7 +487,7 @@ def test_change_pack_lot_change_pack_steal_from_other_move_line(self): package2.name, success=True, message={ - "message_type": "info", + "message_type": "success", "body": "Package {} replaced by package {}".format( package1.name, package2.name ), diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 31d260afa5..c9c6fb21b7 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -512,7 +512,7 @@ def test_cancel(self): response, next_state="start", message={ - "message_type": "info", + "message_type": "success", "body": "Canceled, you can scan a new pack.", }, ) @@ -555,7 +555,7 @@ def test_cancel_already_canceled(self): response, next_state="start", message={ - "message_type": "info", + "message_type": "success", "body": "Canceled, you can scan a new pack.", }, ) diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index 510eaced85..bde20f97bc 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -657,7 +657,7 @@ def test_cancel(self): response, next_state="start", message={ - "message_type": "info", + "message_type": "success", "body": "Canceled, you can scan a new pack.", }, ) @@ -699,7 +699,7 @@ def test_cancel_already_canceled(self): response, next_state="start", message={ - "message_type": "info", + "message_type": "success", "body": "Canceled, you can scan a new pack.", }, ) From f2c98e4f8d64d69382a246e02d401385ae33443d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 26 May 2020 12:24:43 +0200 Subject: [PATCH 227/940] backend: run tests as a stock user We have to be sure that all scenarii can be run with a normal stock user, without needing manager permissions. Now the test are all run as normal stock user, to verify this. The consequences on writing tests: * Records created or written in a test setup must use sudo() if the user has no permission on these models. * Tests setUps should no longer extend setUpClass but setUpClassVars and setUpClassBaseData, which already have an environment with the stock user. * Be wary of creating records before setUpClassUsers is called, because it their "env.user" would be admin and could lead to inconsistencies in tests. The services / actions which needed a correction for stock users: * Creating an inventory (cluster picking stock issue, ...) * Validating an inventory (cluster picking stock issue, ...) * In cluster's picking when a package is changed, 'StockQuant.inventory_quantity' is updated, this field automatically create moves to adjust quantities. However, it was ignored if the user doesn't have the 'stock manager' group. * OpenAPI: the swagger UI could not work without being admin because it reads the demo auth API key, which is obviously restricted. Reading it as sudo is safe as it's a demo key. --- shopfloor/actions/inventory.py | 12 +- shopfloor/models/__init__.py | 2 + shopfloor/models/stock_inventory.py | 17 ++ shopfloor/models/stock_quant.py | 17 ++ shopfloor/services/service.py | 6 +- shopfloor/tests/common.py | 163 +++++++++++++----- shopfloor/tests/test_actions_data.py | 14 +- shopfloor/tests/test_actions_data_detail.py | 24 ++- shopfloor/tests/test_app.py | 6 +- shopfloor/tests/test_checkout_base.py | 10 +- shopfloor/tests/test_checkout_cancel_line.py | 4 +- .../tests/test_checkout_change_packaging.py | 70 ++++---- shopfloor/tests/test_checkout_done.py | 8 +- shopfloor/tests/test_checkout_list_package.py | 4 +- .../test_checkout_scan_package_action.py | 20 ++- shopfloor/tests/test_checkout_select.py | 6 +- shopfloor/tests/test_checkout_select_line.py | 4 +- shopfloor/tests/test_checkout_set_qty.py | 4 +- shopfloor/tests/test_cluster_picking_base.py | 16 +- shopfloor/tests/test_cluster_picking_batch.py | 25 ++- .../test_cluster_picking_change_pack_lot.py | 12 +- shopfloor/tests/test_cluster_picking_scan.py | 8 +- .../tests/test_cluster_picking_select.py | 8 +- shopfloor/tests/test_cluster_picking_skip.py | 10 +- .../tests/test_cluster_picking_stock_issue.py | 6 +- .../tests/test_cluster_picking_unload.py | 54 +++--- shopfloor/tests/test_delivery_base.py | 11 +- shopfloor/tests/test_menu.py | 12 +- shopfloor/tests/test_openapi.py | 7 +- shopfloor/tests/test_single_pack_putaway.py | 40 +++-- shopfloor/tests/test_single_pack_transfer.py | 40 +++-- 31 files changed, 408 insertions(+), 232 deletions(-) create mode 100644 shopfloor/models/stock_inventory.py create mode 100644 shopfloor/models/stock_quant.py diff --git a/shopfloor/actions/inventory.py b/shopfloor/actions/inventory.py index 9f71fd2243..f195c05d11 100644 --- a/shopfloor/actions/inventory.py +++ b/shopfloor/actions/inventory.py @@ -14,6 +14,12 @@ class InventoryAction(Component): _inherit = "shopfloor.process.action" _usage = "inventory" + @property + def inventory_model(self): + # the _sf_inventory key bypass groups checks, + # see comment in models/stock_inventory.py + return self.env["stock.inventory"].with_context(_sf_inventory=True) + def create_draft_check_empty(self, location, product, ref=None): """Create a draft inventory for a product with a zero quantity""" if ref: @@ -35,10 +41,10 @@ def _inventory_exists( domain.append(("package_id", "=", package.id)) if lot is not None: domain.append(("lot_id", "=", lot.id)) - return self.env["stock.inventory"].search_count(domain) + return self.inventory_model.search_count(domain) def _create_draft_inventory(self, location, product, name): - return self.env["stock.inventory"].create( + return self.inventory_model.sudo().create( { "name": name, "location_ids": [(6, 0, location.ids)], @@ -106,7 +112,7 @@ def create_stock_issue(self, move, location, package, lot): values = self._stock_issue_inventory_values( move, location, package, lot, qty_to_keep ) - inventory = self.env["stock.inventory"].create(values) + inventory = self.inventory_model.sudo().create(values) inventory.action_start() inventory.action_validate() move._action_assign() diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index b44bcc12eb..7f9b986965 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -1,9 +1,11 @@ from . import shopfloor_menu from . import stock_picking_type from . import shopfloor_profile +from . import stock_inventory from . import stock_location from . import stock_move_line from . import stock_package_level from . import stock_picking from . import stock_picking_batch +from . import stock_quant from . import stock_quant_package diff --git a/shopfloor/models/stock_inventory.py b/shopfloor/models/stock_inventory.py new file mode 100644 index 0000000000..3581a18dce --- /dev/null +++ b/shopfloor/models/stock_inventory.py @@ -0,0 +1,17 @@ +from odoo import models + + +class StockInventory(models.Model): + _inherit = "stock.inventory" + + def user_has_groups(self, groups): + if self.env.context.get("_sf_inventory"): + allow_groups = groups.split(",") + # action_validate checks if the user is a manager, but + # in shopfloor, we want to programmatically create and + # validate inventories under the hood. sudo sets the su + # flag but not the group: allow to bypass the check when + # sudo is used. + if "stock.group_stock_manager" in allow_groups and self.env.su: + return True + return super().user_has_groups(groups) diff --git a/shopfloor/models/stock_quant.py b/shopfloor/models/stock_quant.py new file mode 100644 index 0000000000..77c6667359 --- /dev/null +++ b/shopfloor/models/stock_quant.py @@ -0,0 +1,17 @@ +from odoo import models + + +class StockQuant(models.Model): + _inherit = "stock.quant" + + def _is_inventory_mode(self): + """ Used to control whether a quant was written on or created during an + "inventory session", meaning a mode where we need to create the stock.move + record necessary to be consistent with the `inventory_quantity` field. + """ + # The default method check if we have the stock.group_stock_manager + # group, however, we want to force using this mode from shopfloor + # (cluster picking) when sudo is used and the user is a stock user. + if self.env.context.get("inventory_mode") is True and self.env.su: + return True + return super()._is_inventory_mode() diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 66a7702f43..3841079f56 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -116,7 +116,11 @@ def _response( def _get_openapi_default_parameters(self): defaults = super()._get_openapi_default_parameters() - demo_api_key = self.env.ref("shopfloor.api_key_demo", raise_if_not_found=False) + # Normal users can't read an API key, ignore it using sudo() only + # because it's a demo key. + demo_api_key = self.env.ref( + "shopfloor.api_key_demo", raise_if_not_found=False + ).sudo() service_params = [ { "name": "API-KEY", diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index db21e2a791..b83f076c0b 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -64,9 +64,12 @@ def setUpClass(cls): cls.env = cls.env( context=dict(cls.env.context, tracking_disable=cls.tracking_disable) ) + cls.setUpComponent() + cls.setUpClassUsers() cls.setUpClassVars() cls.setUpClassBaseData() + with cls.work_on_actions(cls) as work: cls.data = work.component(usage="data") with cls.work_on_actions(cls) as work: @@ -76,73 +79,139 @@ def setUpClass(cls): with cls.work_on_services(cls) as work: cls.schema_detail = work.component(usage="schema_detail") + @classmethod + def setUpClassUsers(cls): + Users = cls.env["res.users"].with_context( + {"no_reset_password": True, "mail_create_nosubscribe": True} + ) + cls.stock_user = Users.create( + { + "name": "Pauline Poivraisselle", + "login": "pauline2", + "email": "p.p@example.com", + "notification_type": "inbox", + "groups_id": [(6, 0, [cls.env.ref("stock.group_stock_user").id])], + } + ) + cls.env = cls.env(user=cls.stock_user) + @classmethod def setUpClassVars(cls): stock_location = cls.env.ref("stock.stock_location_stock") cls.stock_location = stock_location cls.customer_location = cls.env.ref("stock.stock_location_customers") - cls.customer_location.barcode = "CUSTOMERS" cls.dispatch_location = cls.env.ref("stock.location_dispatch_zone") - cls.dispatch_location.barcode = "DISPATCH" cls.packing_location = cls.env.ref("stock.location_pack_zone") - cls.packing_location.barcode = "PACKING" cls.input_location = cls.env.ref("stock.stock_location_company") - cls.input_location.barcode = "INPUT" cls.shelf1 = cls.env.ref("stock.stock_location_components") - cls.shelf1.barcode = "SHELF1" cls.shelf2 = cls.env.ref("stock.stock_location_14") - cls.shelf2.barcode = "SHELF2" - cls.customer = cls.env["res.partner"].create({"name": "Customer"}) @classmethod def setUpClassBaseData(cls): - cls.product_a = cls.env["product.product"].create( - { - "name": "Product A", - "type": "product", - "default_code": "A", - "barcode": "A", - "weight": 2, - } + cls.customer = cls.env["res.partner"].sudo().create({"name": "Customer"}) + + cls.customer_location.sudo().barcode = "CUSTOMERS" + cls.dispatch_location.sudo().barcode = "DISPATCH" + cls.packing_location.sudo().barcode = "PACKING" + cls.input_location.sudo().barcode = "INPUT" + cls.shelf1.sudo().barcode = "SHELF1" + cls.shelf2.sudo().barcode = "SHELF2" + + cls.product_a = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product A", + "type": "product", + "default_code": "A", + "barcode": "A", + "weight": 2, + } + ) ) - cls.product_a_packaging = cls.env["product.packaging"].create( - {"name": "Box", "product_id": cls.product_a.id, "barcode": "ProductABox"} + cls.product_a_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box", + "product_id": cls.product_a.id, + "barcode": "ProductABox", + } + ) ) - cls.product_b = cls.env["product.product"].create( - { - "name": "Product B", - "type": "product", - "default_code": "B", - "barcode": "B", - "weight": 3, - } + cls.product_b = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product B", + "type": "product", + "default_code": "B", + "barcode": "B", + "weight": 3, + } + ) ) - cls.product_b_packaging = cls.env["product.packaging"].create( - {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductBBox"} + cls.product_b_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box", + "product_id": cls.product_b.id, + "barcode": "ProductBBox", + } + ) ) - cls.product_c = cls.env["product.product"].create( - { - "name": "Product C", - "type": "product", - "default_code": "C", - "barcode": "C", - "weight": 3, - } + cls.product_c = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product C", + "type": "product", + "default_code": "C", + "barcode": "C", + "weight": 3, + } + ) ) - cls.product_c_packaging = cls.env["product.packaging"].create( - {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductCBox"} + cls.product_c_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box", + "product_id": cls.product_b.id, + "barcode": "ProductCBox", + } + ) ) - cls.product_d = cls.env["product.product"].create( - { - "name": "Product D", - "type": "product", - "default_code": "D", - "barcode": "D", - "weight": 3, - } + cls.product_d = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product D", + "type": "product", + "default_code": "D", + "barcode": "D", + "weight": 3, + } + ) ) - cls.product_d_packaging = cls.env["product.packaging"].create( - {"name": "Box", "product_id": cls.product_d.id, "barcode": "ProductDBox"} + cls.product_d_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box", + "product_id": cls.product_d.id, + "barcode": "ProductDBox", + } + ) ) def assert_response( diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 64f95e8399..d0612fe1ee 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -13,11 +13,15 @@ class ActionsDataCaseBase(CommonCase): @classmethod - def setUpClass(cls): - super().setUpClass() + def setUpClassVars(cls): + super().setUpClassVars() cls.wh = cls.env.ref("stock.warehouse0") cls.picking_type = cls.wh.out_type_id - cls.packaging = cls.env["product.packaging"].create({"name": "Pallet"}) + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.packaging = cls.env["product.packaging"].sudo().create({"name": "Pallet"}) cls.product_b.tracking = "lot" cls.product_c.tracking = "lot" cls.picking = cls._create_picking( @@ -230,8 +234,8 @@ def test_data_move_line_raw(self): class ActionsDataCaseBatchPicking(ActionsDataCaseBase, PickingBatchMixin): @classmethod - def setUpClass(cls): - super().setUpClass() + def setUpClassBaseData(cls): + super().setUpClassBaseData() cls.batch = cls._create_picking_batch( [ [ diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index 2ff4ccf67e..6f3c85d62d 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -17,12 +17,16 @@ def fake_colored_image(color="#4169E1", size=(800, 500)): class ActionsDataDetailCaseBase(ActionsDataCaseBase): @classmethod - def setUpClass(cls): - super().setUpClass() - cls.package = cls.move_a.move_line_ids.package_id + def setUpClassBaseData(cls): + super().setUpClassBaseData() cls.lot = cls.env["stock.production.lot"].create( {"product_id": cls.product_b.id, "company_id": cls.env.company.id} ) + cls.package = cls.move_a.move_line_ids.package_id + + @classmethod + def setUpClassVars(cls): + super().setUpClassVars() cls.storage_type_pallet = cls.env.ref( "stock_storage_type.package_storage_type_pallets" ) @@ -289,16 +293,18 @@ def test_data_move_line_raw(self): def test_product(self): move_line = self.move_b.move_line_ids product = move_line.product_id.with_context(location=move_line.location_id.id) - manuf = self.env["res.partner"].create({"name": "Manuf 1"}) - product.write( + Partner = self.env["res.partner"].sudo() + manuf = Partner.create({"name": "Manuf 1"}) + product.sudo().write( { "image_128": fake_colored_image(size=(128, 128)), "manufacturer": manuf.id, } ) - vendor_a = self.env["res.partner"].create({"name": "Supplier A"}) - vendor_b = self.env["res.partner"].create({"name": "Supplier B"}) - self.env["product.supplierinfo"].create( + vendor_a = Partner.create({"name": "Supplier A"}) + vendor_b = Partner.create({"name": "Supplier B"}) + SupplierInfo = self.env["product.supplierinfo"].sudo() + SupplierInfo.create( { "name": vendor_a.id, "product_tmpl_id": product.product_tmpl_id.id, @@ -306,7 +312,7 @@ def test_product(self): "product_code": "SUPP1", } ) - self.env["product.supplierinfo"].create( + SupplierInfo.create( { "name": vendor_b.id, "product_tmpl_id": product.product_tmpl_id.id, diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py index dcfada2fb7..d45965861e 100644 --- a/shopfloor/tests/test_app.py +++ b/shopfloor/tests/test_app.py @@ -3,8 +3,8 @@ class AppCase(CommonCase): @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) cls.profile = cls.env.ref("shopfloor.shopfloor_profile_hb_truck_demo") cls.profile2 = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") @@ -61,7 +61,7 @@ def test_menu_no_profile(self): def test_menu_by_profile(self): """Request /app/menu w/ a specific profile""" # Simulate the client asking the menu - menus = self.env["shopfloor.menu"].search([]) + menus = self.env["shopfloor.menu"].sudo().search([]) menu = menus[0] menu.profile_ids = self.profile (menus - menu).profile_ids = self.profile2 diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index 6dc4d8d885..25cbdc1dc5 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -3,14 +3,18 @@ class CheckoutCommonCase(CommonCase): @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout") cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id - cls.wh.delivery_steps = "pick_pack_ship" cls.picking_type = cls.menu.picking_type_ids + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.wh.sudo().delivery_steps = "pick_pack_ship" + def setUp(self): super().setUp() with self.work_on_services(menu=self.menu, profile=self.profile) as work: diff --git a/shopfloor/tests/test_checkout_cancel_line.py b/shopfloor/tests/test_checkout_cancel_line.py index 392a525ccc..108df0e03c 100644 --- a/shopfloor/tests/test_checkout_cancel_line.py +++ b/shopfloor/tests/test_checkout_cancel_line.py @@ -3,8 +3,8 @@ class CheckoutRemovePackageCase(CheckoutCommonCase): @classmethod - def setUpClass(cls): - super().setUpClass() + def setUpClassBaseData(cls): + super().setUpClassBaseData() cls.picking = picking = cls._create_picking( lines=[ (cls.product_a, 10), diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py index e88d312a18..b7d4165837 100644 --- a/shopfloor/tests/test_checkout_change_packaging.py +++ b/shopfloor/tests/test_checkout_change_packaging.py @@ -3,37 +3,49 @@ class CheckoutListSetPackagingCase(CheckoutCommonCase): @classmethod - def setUpClass(cls): - super().setUpClass() - cls.packaging_pallet = cls.env["product.packaging"].create( - { - "sequence": 3, - "name": "Pallet", - "barcode": "PPP", - "height": 100, - "width": 100, - "lngth": 100, - } + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.packaging_pallet = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "sequence": 3, + "name": "Pallet", + "barcode": "PPP", + "height": 100, + "width": 100, + "lngth": 100, + } + ) ) - cls.packaging_box = cls.env["product.packaging"].create( - { - "sequence": 2, - "name": "Box", - "barcode": "BBB", - "height": 20, - "width": 20, - "lngth": 20, - } + cls.packaging_box = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "sequence": 2, + "name": "Box", + "barcode": "BBB", + "height": 20, + "width": 20, + "lngth": 20, + } + ) ) - cls.packaging_inner_box = cls.env["product.packaging"].create( - { - "sequence": 1, - "name": "Inner Box", - "barcode": "III", - "height": 10, - "width": 10, - "lngth": 10, - } + cls.packaging_inner_box = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "sequence": 1, + "name": "Inner Box", + "barcode": "III", + "height": 10, + "width": 10, + "lngth": 10, + } + ) ) cls.picking = cls._create_picking(lines=[(cls.product_a, 10)]) cls._fill_stock_for_moves(cls.picking.move_lines, in_package=True) diff --git a/shopfloor/tests/test_checkout_done.py b/shopfloor/tests/test_checkout_done.py index 8af9442973..bba6f7af58 100644 --- a/shopfloor/tests/test_checkout_done.py +++ b/shopfloor/tests/test_checkout_done.py @@ -24,8 +24,8 @@ def test_done_ok(self): class CheckoutDonePartialCase(CheckoutCommonCase): @classmethod - def setUpClass(cls): - super().setUpClass() + def setUpClassBaseData(cls): + super().setUpClassBaseData() cls.picking = picking = cls._create_picking( lines=[(cls.product_a, 10), (cls.product_b, 10)] ) @@ -74,8 +74,8 @@ def test_done_partial_confirm(self): class CheckoutDoneRawUnpackedCase(CheckoutCommonCase): @classmethod - def setUpClass(cls): - super().setUpClass() + def setUpClassBaseData(cls): + super().setUpClassBaseData() cls.picking = picking = cls._create_picking( lines=[(cls.product_a, 10), (cls.product_b, 10)] ) diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py index 5a075d183a..e5d21d8fa3 100644 --- a/shopfloor/tests/test_checkout_list_package.py +++ b/shopfloor/tests/test_checkout_list_package.py @@ -90,8 +90,8 @@ def test_list_dest_package_error_no_package(self): class CheckoutScanSetDestPackageCase(CheckoutCommonCase, SelectDestPackageMixin): @classmethod - def setUpClass(cls): - super().setUpClass() + def setUpClassBaseData(cls): + super().setUpClassBaseData() picking = cls._create_picking( lines=[ (cls.product_a, 10), diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py index 58edd46ded..c708ec47ea 100644 --- a/shopfloor/tests/test_checkout_scan_package_action.py +++ b/shopfloor/tests/test_checkout_scan_package_action.py @@ -294,14 +294,18 @@ def test_scan_package_action_scan_packaging_ok(self): move_line2.qty_done = move_line2.product_uom_qty move_line3.qty_done = 0 - packaging = self.env["product.packaging"].create( - { - "name": "Pallet", - "barcode": "PPP", - "height": 12, - "width": 13, - "lngth": 14, - } + packaging = ( + self.env["product.packaging"] + .sudo() + .create( + { + "name": "Pallet", + "barcode": "PPP", + "height": 12, + "width": 13, + "lngth": 14, + } + ) ) response = self.service.dispatch( diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py index c22c47cb93..442ab0ae14 100644 --- a/shopfloor/tests/test_checkout_select.py +++ b/shopfloor/tests/test_checkout_select.py @@ -25,8 +25,8 @@ def test_list_stock_picking(self): class CheckoutSelectCase(CheckoutCommonCase): @classmethod - def setUpClass(cls): - super().setUpClass() + def setUpClassBaseData(cls): + super().setUpClassBaseData() cls.picking = cls._create_picking() cls._fill_stock_for_moves(cls.picking.move_lines, in_package=True) cls.picking.action_assign() @@ -52,7 +52,7 @@ def _test_error(self, picking, msg): def test_select_error_not_found(self): picking = self._create_picking() - picking.unlink() + picking.sudo().unlink() self._test_error(picking, "This transfer does not exist anymore.") def test_select_error_not_available(self): diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py index dede5bfd37..c84d5bed9d 100644 --- a/shopfloor/tests/test_checkout_select_line.py +++ b/shopfloor/tests/test_checkout_select_line.py @@ -4,8 +4,8 @@ class CheckoutSelectLineCase(CheckoutCommonCase, CheckoutSelectPackageMixin): @classmethod - def setUpClass(cls): - super().setUpClass() + def setUpClassBaseData(cls): + super().setUpClassBaseData() picking = cls._create_picking( lines=[(cls.product_a, 10), (cls.product_b, 10), (cls.product_c, 10)] ) diff --git a/shopfloor/tests/test_checkout_set_qty.py b/shopfloor/tests/test_checkout_set_qty.py index 39a6ee694f..5440e473d4 100644 --- a/shopfloor/tests/test_checkout_set_qty.py +++ b/shopfloor/tests/test_checkout_set_qty.py @@ -4,8 +4,8 @@ class CheckoutSetQtyCommonCase(CheckoutCommonCase, CheckoutSelectPackageMixin): @classmethod - def setUpClass(cls): - super().setUpClass() + def setUpClassBaseData(cls): + super().setUpClassBaseData() picking = cls._create_picking( lines=[(cls.product_a, 10), (cls.product_b, 10), (cls.product_c, 10)] ) diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py index 3453ede428..2fd44ecb46 100644 --- a/shopfloor/tests/test_cluster_picking_base.py +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -3,14 +3,18 @@ class ClusterPickingCommonCase(CommonCase, PickingBatchMixin): @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id - cls.wh.delivery_steps = "pick_pack_ship" cls.picking_type = cls.menu.picking_type_ids + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.wh.sudo().delivery_steps = "pick_pack_ship" + def setUp(self): super().setUp() with self.work_on_services(menu=self.menu, profile=self.profile) as work: @@ -51,10 +55,10 @@ def _data_for_batch(self, batch, location, pack=None): class ClusterPickingLineCommonCase(ClusterPickingCommonCase): @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) # quants already existing are from demo data - cls.env["stock.quant"].search( + cls.env["stock.quant"].sudo().search( [("location_id", "=", cls.stock_location.id)] ).unlink() cls.batch = cls._create_picking_batch( diff --git a/shopfloor/tests/test_cluster_picking_batch.py b/shopfloor/tests/test_cluster_picking_batch.py index 114cd5354a..b7a2e915fc 100644 --- a/shopfloor/tests/test_cluster_picking_batch.py +++ b/shopfloor/tests/test_cluster_picking_batch.py @@ -3,19 +3,26 @@ class ClusterPickingBatchCase(CommonCase, PickingBatchMixin): @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - cls.product_a = cls.env["product.product"].create( - {"name": "Product A", "type": "product"} - ) - cls.product_b = cls.env["product.product"].create( - {"name": "Product B", "type": "product"} - ) - # which menu we pick should not matter for the batch picking api + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id cls.picking_type = cls.menu.picking_type_ids + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.product_a = ( + cls.env["product.product"] + .sudo() + .create({"name": "Product A", "type": "product"}) + ) + cls.product_b = ( + cls.env["product.product"] + .sudo() + .create({"name": "Product B", "type": "product"}) + ) cls.batch1 = cls._create_picking_batch( [[cls.BatchProduct(product=cls.product_a, quantity=1)]] ) diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py index ed0f38d3d3..f809f19c8f 100644 --- a/shopfloor/tests/test_cluster_picking_change_pack_lot.py +++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py @@ -98,8 +98,8 @@ class ClusterPickingChangePackLotCase(ClusterPickingChangePackLotCommon): """Tests covering the /change_pack_lot endpoint""" @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) cls.batch = cls._create_picking_batch( [[cls.BatchProduct(product=cls.product_a, quantity=10)]] ) @@ -172,7 +172,9 @@ def test_change_pack_lot_change_pack_different_location(self): line, [{"package_id": new_package.id, "result_package_id": new_package.id}] ) self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) - # check that reservations have been updated + # check that reservations have been updated, the new package is not + # supposed to be in shelf2 anymore, and we should have no reserved qty + # for the initial package anymore self.assert_quant_package_qty(self.shelf2, new_package, lambda: 0) self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package) self.assert_quant_reserved_qty( @@ -331,10 +333,6 @@ class ClusterPickingChangePackLotCaseSpecial(ClusterPickingChangePackLotCommon): Special cases where we use a custom batch transfer """ - @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - def _create_picking_with_package_level(self, packages): picking_form = Form(self.env["stock.picking"]) picking_form.partner_id = self.customer diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index 3f54ab3984..353125ffb2 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -270,8 +270,8 @@ class ClusterPickingScanDestinationPackCase(ClusterPickingCommonCase): """ @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) cls.batch = cls._create_picking_batch( [ [ @@ -520,8 +520,8 @@ class ClusterPickingIsZeroCase(ClusterPickingCommonCase): """ @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) cls.batch = cls._create_picking_batch( [ [ diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py index 79e51fc58d..1b86cdf690 100644 --- a/shopfloor/tests/test_cluster_picking_select.py +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -17,8 +17,8 @@ class ClusterPickingSelectionCase(ClusterPickingCommonCase): """ @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) # drop base demo data and create our own batches to work with cls.env["stock.picking.batch"].search([]).unlink() cls.batch1 = cls._create_picking_batch( @@ -254,8 +254,8 @@ class ClusterPickingSelectedCase(ClusterPickingCommonCase): """ @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) cls.batch = cls._create_picking_batch( [[cls.BatchProduct(product=cls.product_a, quantity=1)]] ) diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py index 8e1a004c10..41c612380a 100644 --- a/shopfloor/tests/test_cluster_picking_skip.py +++ b/shopfloor/tests/test_cluster_picking_skip.py @@ -6,10 +6,10 @@ class ClusterPickingSkipLineCase(ClusterPickingCommonCase): """ @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) # quants already existing are from demo data - cls.env["stock.quant"].search( + cls.env["stock.quant"].sudo().search( [("location_id", "=", cls.stock_location.id)] ).unlink() cls.batch = cls._create_picking_batch( @@ -41,8 +41,8 @@ def test_skip_line(self): self._simulate_batch_selected(self.batch, in_package=True) # enforce names to have reliable sorting - self.stock_location.name = "LOC2" - self.shelf1.name = "LOC1" + self.stock_location.sudo().name = "LOC2" + self.shelf1.sudo().name = "LOC1" all_lines = self.batch.picking_ids.move_line_ids loc1_lines = all_lines.filtered(lambda line: (line.location_id == self.shelf1)) loc2_lines = all_lines.filtered( diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py index 1c7c7b12fb..8a2de6b921 100644 --- a/shopfloor/tests/test_cluster_picking_stock_issue.py +++ b/shopfloor/tests/test_cluster_picking_stock_issue.py @@ -6,11 +6,11 @@ class ClusterPickingStockIssue(ClusterPickingCommonCase): """ @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) # quants already existing are from demo data loc_ids = (cls.stock_location.id, cls.shelf1.id, cls.shelf2.id) - cls.env["stock.quant"].search([("location_id", "in", loc_ids)]).unlink() + cls.env["stock.quant"].sudo().search([("location_id", "in", loc_ids)]).unlink() cls.batch = cls._create_picking_batch( [ [cls.BatchProduct(product=cls.product_a, quantity=10)], diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index 2152b79d0c..6528eafe12 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -3,12 +3,12 @@ class ClusterPickingUnloadingCommonCase(ClusterPickingCommonCase): @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) # activate the computation of this field, so we have a chance to # transition to the 'show completion info' popup. - cls.picking_type.display_completion_info = True + cls.picking_type.sudo().display_completion_info = True cls.batch = cls._create_picking_batch( [ @@ -22,19 +22,27 @@ def setUpClass(cls, *args, **kwargs): cls._simulate_batch_selected(cls.batch) cls.bin1 = cls.env["stock.quant.package"].create({}) cls.bin2 = cls.env["stock.quant.package"].create({}) - cls.packing_a_location = cls.env["stock.location"].create( - { - "name": "Packing A", - "barcode": "Packing-A", - "location_id": cls.packing_location.id, - } + cls.packing_a_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Packing A", + "barcode": "Packing-A", + "location_id": cls.packing_location.id, + } + ) ) - cls.packing_b_location = cls.env["stock.location"].create( - { - "name": "Packing B", - "barcode": "Packing-B", - "location_id": cls.packing_location.id, - } + cls.packing_b_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Packing B", + "barcode": "Packing-B", + "location_id": cls.packing_location.id, + } + ) ) @@ -334,12 +342,6 @@ class ClusterPickingUnloadSplitCase(ClusterPickingUnloadingCommonCase): screen even if the destinations are the same. """ - @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - # this is what the /prepare_endpoint method would have set as all the - # destinations are the same: - def test_unload_split_ok(self): """Call /unload_split and continue to unload single""" move_lines = self.batch.mapped("picking_ids.move_line_ids") @@ -373,8 +375,8 @@ class ClusterPickingUnloadScanPackCase(ClusterPickingUnloadingCommonCase): """ @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids") cls._set_dest_package_and_done(cls.move_lines, cls.bin1) cls.move_lines[:2].write({"location_dest_id": cls.packing_a_location.id}) @@ -427,8 +429,8 @@ class ClusterPickingUnloadScanDestinationCase(ClusterPickingUnloadingCommonCase) """ @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids") cls.bin1_lines = cls.move_lines[:1] cls.bin2_lines = cls.move_lines[1:] @@ -696,7 +698,7 @@ def test_unload_scan_destination_completion_info(self): """/unload_scan_destination that make chained picking ready""" picking = self.one_line_picking dest_location = picking.move_line_ids.location_dest_id - self.picking_type.display_completion_info = True + self.picking_type.sudo().display_completion_info = True # create a chained picking after the current one next_picking = picking.copy( diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py index 564960a4c4..a96ff00841 100644 --- a/shopfloor/tests/test_delivery_base.py +++ b/shopfloor/tests/test_delivery_base.py @@ -3,15 +3,18 @@ class DeliveryCommonCase(CommonCase): @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_delivery") cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id - cls.wh.delivery_steps = "pick_pack_ship" cls.picking_type = cls.menu.picking_type_ids + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.wh.sudo().delivery_steps = "pick_pack_ship" + def setUp(self): super().setUp() with self.work_on_services(menu=self.menu, profile=self.profile) as work: diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py index 0c0f9ba6a8..31ef016042 100644 --- a/shopfloor/tests/test_menu.py +++ b/shopfloor/tests/test_menu.py @@ -3,8 +3,8 @@ class MenuCase(CommonCase): @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") def setUp(self): @@ -42,7 +42,7 @@ def test_menu_search(self): def test_menu_search_restricted(self): """Request /menu/search with profile attributions""" # Simulate the client searching menus - menus = self.env["shopfloor.menu"].search([]) + menus = self.env["shopfloor.menu"].sudo().search([]) menus_without_profile = menus[0:2] # these menus should now be hidden for the current profile other_profile = self.env.ref("shopfloor.shopfloor_profile_hb_truck_demo") @@ -55,10 +55,12 @@ def test_menu_search_restricted(self): def test_menu_search_warehouse_filter(self): """Request /menu/search with different warehouse on profile""" - menus = self.env["shopfloor.menu"].search([]) + menus = self.env["shopfloor.menu"].sudo().search([]) # should not be visible as the profile has another wh menu_different_wh = menus[0] - other_wh = self.env["stock.warehouse"].create({"name": "Test", "code": "test"}) + other_wh = ( + self.env["stock.warehouse"].sudo().create({"name": "Test", "code": "test"}) + ) menu_different_wh.picking_type_ids.warehouse_id = other_wh # should be visible to any profile diff --git a/shopfloor/tests/test_openapi.py b/shopfloor/tests/test_openapi.py index 1eae2f5569..913324c4a5 100644 --- a/shopfloor/tests/test_openapi.py +++ b/shopfloor/tests/test_openapi.py @@ -3,17 +3,14 @@ class TestOpenAPICommonCase(CommonCase): @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) # we don't really care about which menu and profile we use # to read the OpenAPI specs cls.menu = cls.env.ref("shopfloor.shopfloor_menu_delivery") cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") - def setUp(self): - super().setUp() - def test_openapi(self): with self.work_on_services(menu=self.menu, profile=self.profile) as work: services = work.many_components() diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index c9c6fb21b7..cbfcff74b1 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -5,32 +5,42 @@ class SinglePackPutawayCase(CommonCase): @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - cls.product_a = cls.env["product.product"].create( - {"name": "Product A", "type": "product"} + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck") + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + cls.wh = cls.profile.warehouse_id + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.product_a = ( + cls.env["product.product"] + .sudo() + .create({"name": "Product A", "type": "product"}) ) cls.pack_a = cls.env["stock.quant.package"].create( {"location_id": cls.stock_location.id} ) - cls.env["stock.putaway.rule"].create( + cls.env["stock.putaway.rule"].sudo().create( { "product_id": cls.product_a.id, "location_in_id": cls.stock_location.id, "location_out_id": cls.shelf1.id, } ) - cls.quant_a = cls.env["stock.quant"].create( - { - "product_id": cls.product_a.id, - "location_id": cls.dispatch_location.id, - "quantity": 1, - "package_id": cls.pack_a.id, - } + cls.quant_a = ( + cls.env["stock.quant"] + .sudo() + .create( + { + "product_id": cls.product_a.id, + "location_id": cls.dispatch_location.id, + "quantity": 1, + "package_id": cls.pack_a.id, + } + ) ) - cls.menu = cls.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck") - cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") - cls.wh = cls.profile.warehouse_id def setUp(self): super().setUp() diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index bde20f97bc..bebde9dcc5 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -5,28 +5,36 @@ class SinglePackTransferCase(CommonCase): @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) - cls.pack_a = cls.env["stock.quant.package"].create( - {"location_id": cls.stock_location.id} - ) - cls.quant_a = cls.env["stock.quant"].create( - { - "product_id": cls.product_a.id, - "location_id": cls.shelf1.id, - "quantity": 1, - "package_id": cls.pack_a.id, - } - ) + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) cls.menu = cls.env.ref("shopfloor.shopfloor_menu_single_pallet_transfer") cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") cls.wh = cls.profile.warehouse_id cls.picking_type = cls.menu.picking_type_ids + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.pack_a = cls.env["stock.quant.package"].create( + {"location_id": cls.stock_location.id} + ) + cls.quant_a = ( + cls.env["stock.quant"] + .sudo() + .create( + { + "product_id": cls.product_a.id, + "location_id": cls.shelf1.id, + "quantity": 1, + "package_id": cls.pack_a.id, + } + ) + ) cls.picking = cls._create_initial_move() # disable the completion on the picking type, we'll have specific test(s) # to check the behavior of this screen - cls.picking_type.display_completion_info = False + cls.picking_type.sudo().display_completion_info = False def setUp(self): super().setUp() @@ -251,7 +259,7 @@ def test_start_pack_from_location_several_packs(self): pack_b = self.env["stock.quant.package"].create( {"location_id": self.stock_location.id} ) - self.env["stock.quant"].create( + self.env["stock.quant"].sudo().create( { "product_id": self.product_a.id, "location_id": self.shelf1.id, @@ -407,7 +415,7 @@ def test_validate_completion_info(self): # activate the computation of this field, so we have a chance to # transition to the 'show completion info' screen. - self.picking_type.display_completion_info = True + self.picking_type.sudo().display_completion_info = True # create a chained picking after the current one next_picking = self.picking.copy( From 335a2e795ea201d904f6b8836efa94d4df8d3cf3 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 26 May 2020 15:08:57 +0200 Subject: [PATCH 228/940] cluster picking: Add picking_batch_id in every method call So we can always handle a move line deleted on the backend by taking the next move line of the batch instead of aborting. --- shopfloor/services/cluster_picking.py | 92 +++++++++++-------- .../test_cluster_picking_change_pack_lot.py | 13 ++- shopfloor/tests/test_cluster_picking_scan.py | 44 ++++++++- shopfloor/tests/test_cluster_picking_skip.py | 5 +- .../tests/test_cluster_picking_stock_issue.py | 4 +- 5 files changed, 112 insertions(+), 46 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 8ad83d8ac1..881b56931e 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -120,11 +120,12 @@ def _response_for_change_pack_lot(self, move_line, message=None): message=message, ) - def _response_for_zero_check(self, move_line): + def _response_for_zero_check(self, batch, move_line): data = { "id": move_line.id, "location_src": self.data_struct.location(move_line.location_id), } + data["batch"] = self.data_struct.picking_batch(batch) return self._response(next_state="zero_check", data=data) def _response_for_unload_all(self, batch, message=None): @@ -312,10 +313,10 @@ def confirm_start(self, picking_batch_id): package * start: if the condition above is wrong (rare case of race condition...) """ - picking_batch = self.env["stock.picking.batch"].browse(picking_batch_id) - if not picking_batch.exists(): + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): return self._response_batch_does_not_exist() - return self._pick_next_line(picking_batch) + return self._pick_next_line(batch) def _pick_next_line(self, batch, message=None, force_line=None): if force_line: @@ -404,7 +405,7 @@ def unassign(self, picking_batch_id): batch.write({"state": "draft", "user_id": False}) return self._response_for_start() - def scan_line(self, move_line_id, barcode): + def scan_line(self, picking_batch_id, move_line_id, barcode): """Scan a location, a pack, a product or a lots There is no side-effect, it is only to check that the operator takes @@ -427,10 +428,13 @@ def scan_line(self, move_line_id, barcode): pack meanwhile (race condition). * scan_destination: if the barcode matches. """ + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() move_line = self.env["stock.move.line"].browse(move_line_id) if not move_line.exists(): - return self._response_for_start( - message=self.msg_store.unrecoverable_error() + return self._pick_next_line( + batch, message=self.msg_store.operation_not_found() ) search = self.actions_for("search") @@ -543,7 +547,7 @@ def _scan_line_by_location(self, picking, move_line, location): return self._response_for_scan_destination(move_line) - def scan_destination_pack(self, move_line_id, barcode, quantity): + def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantity): """Scan the destination package (bin) for a move line If the quantity picked (passed to the endpoint) is < expected quantity, @@ -563,10 +567,13 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): have the same destination. * start_line: to pick the next line if any. """ + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() move_line = self.env["stock.move.line"].browse(move_line_id) if not move_line.exists(): - return self._response_for_start( - message=self.msg_store.unrecoverable_error() + return self._pick_next_line( + batch, message=self.msg_store.operation_not_found() ) # store a new line if we have split our line (not enough qty) @@ -619,12 +626,10 @@ def scan_destination_pack(self, move_line_id, barcode, quantity): ) move_line.write({"qty_done": quantity, "result_package_id": bin_package.id}) - batch = move_line.picking_id.batch_id - if self._planned_qty_in_location_is_empty( move_line.product_id, move_line.location_id ): - return self._response_for_zero_check(move_line) + return self._response_for_zero_check(batch, move_line) return self._pick_next_line( batch, @@ -725,7 +730,7 @@ def _next_bin_package_for_unload_single(self, batch): packages = self._bin_packages_to_unload(batch) return fields.first(packages) - def is_zero(self, move_line_id, zero): + def is_zero(self, picking_batch_id, move_line_id, zero): """Confirm or not if the source location of a move has zero qty If the user confirms there is zero quantity, it means the stock was @@ -739,10 +744,13 @@ def is_zero(self, move_line_id, zero): * unload_single: if all lines have a destination package and different destination """ + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() move_line = self.env["stock.move.line"].browse(move_line_id) if not move_line.exists(): - return self._response_for_start( - message=self.msg_store.unrecoverable_error() + return self._pick_next_line( + batch, message=self.msg_store.operation_not_found() ) if not zero: @@ -753,7 +761,6 @@ def is_zero(self, move_line_id, zero): ref=move_line.picking_id.name, ) - batch = move_line.picking_id.batch_id return self._pick_next_line( batch, message=self.msg_store.x_units_put_in_package( @@ -761,7 +768,7 @@ def is_zero(self, move_line_id, zero): ), ) - def skip_line(self, move_line_id): + def skip_line(self, picking_batch_id, move_line_id): """Skip a line. The line will be processed at the end. It adds a flag on the move line, when the next line to pick @@ -773,10 +780,13 @@ def skip_line(self, move_line_id): * start_line: with data for the next line (or itself if it's the last one, in such case, a helpful message is returned) """ + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() move_line = self.env["stock.move.line"].browse(move_line_id) if not move_line.exists(): - return self._response_for_start( - message=self.msg_store.unrecoverable_error() + return self._pick_next_line( + batch, message=self.msg_store.operation_not_found() ) # flag as postponed move_line.shopfloor_postponed = True @@ -786,7 +796,7 @@ def _pick_after_skip_line(self, move_line): batch = move_line.picking_id.batch_id return self._pick_next_line(batch) - def stock_issue(self, move_line_id): + def stock_issue(self, picking_batch_id, move_line_id): """Declare a stock issue for a line After errors in the stock, the user cannot take all the products @@ -815,12 +825,14 @@ def stock_issue(self, move_line_id): and the last line has a stock issue). In this case, this method *has* to handle the closing of the batch to create backorders (_unload_end) """ + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() move_line = self.env["stock.move.line"].browse(move_line_id) if not move_line.exists(): - return self._response( - next_state="start", message=self.msg_store.unrecoverable_error() + return self._pick_next_line( + batch, message=self.msg_store.operation_not_found() ) - batch = move_line.picking_id.batch_id inventory = self.actions_for("inventory") # create a draft inventory for a user to check @@ -876,7 +888,7 @@ def _domain_stock_issue_unlink_lines(self, move_line): ] return domain - def change_pack_lot(self, move_line_id, barcode): + def change_pack_lot(self, picking_batch_id, move_line_id, barcode): """Change the expected pack or the lot for a line If the expected lot is at the very bottom of the location or a stock @@ -894,10 +906,13 @@ def change_pack_lot(self, move_line_id, barcode): * scan_destination: the pack or the lot could be changed * change_pack_lot: any error occurred during the change """ + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() move_line = self.env["stock.move.line"].browse(move_line_id) if not move_line.exists(): - return self._response( - next_state="start", message=self.msg_store.unrecoverable_error() + return self._pick_next_line( + batch, message=self.msg_store.operation_not_found() ) search = self.actions_for("search") lot = search.lot_from_scan(barcode) @@ -1316,12 +1331,14 @@ def unassign(self): def scan_line(self): return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, "barcode": {"required": True, "type": "string"}, } def scan_destination_pack(self): return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, "barcode": {"required": True, "type": "string"}, "quantity": {"coerce": to_float, "required": True, "type": "float"}, @@ -1334,18 +1351,26 @@ def prepare_unload(self): def is_zero(self): return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, "zero": {"coerce": to_bool, "required": True, "type": "boolean"}, } def skip_line(self): - return {"move_line_id": {"coerce": to_int, "required": True, "type": "integer"}} + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } def stock_issue(self): - return {"move_line_id": {"coerce": to_int, "required": True, "type": "integer"}} + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } def change_pack_lot(self): return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, "barcode": {"required": True, "type": "string"}, } @@ -1362,11 +1387,6 @@ def unload_split(self): "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"} } - def unload_router(self): - return { - "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"} - } - def unload_scan_pack(self): return { "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, @@ -1527,9 +1547,6 @@ def set_destination_all(self): def unload_split(self): return self._response_schema(next_states={"unload_single"}) - def unload_router(self): - return self._response_schema(next_states={"unload_single", "start_line"}) - def unload_scan_pack(self): return self._response_schema( next_states={ @@ -1584,6 +1601,7 @@ def _schema_for_zero_check(self): "id": {"required": True, "type": "integer"}, } schema["location_src"] = self.schemas._schema_dict_of(self.schemas.location()) + schema["batch"] = self.schemas._schema_dict_of(self.schemas.picking_batch()) return schema @property diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py index f809f19c8f..f68772726b 100644 --- a/shopfloor/tests/test_cluster_picking_change_pack_lot.py +++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py @@ -34,8 +34,14 @@ def _create_lot(self, product): ) def _test_change_pack_lot(self, line, barcode, success=True, message=None): + batch = line.picking_id.batch_id response = self.service.dispatch( - "change_pack_lot", params={"move_line_id": line.id, "barcode": barcode}, + "change_pack_lot", + params={ + "picking_batch_id": batch.id, + "move_line_id": line.id, + "barcode": barcode, + }, ) if success: self.assert_response( @@ -53,7 +59,10 @@ def _test_change_pack_lot(self, line, barcode, success=True, message=None): ) def _skip_line(self, line, next_line=None): - response = self.service.dispatch("skip_line", params={"move_line_id": line.id}) + batch = line.picking_id.batch_id + response = self.service.dispatch( + "skip_line", params={"picking_batch_id": batch.id, "move_line_id": line.id} + ) if next_line: self.assert_response( response, next_state="start_line", data=self._line_data(next_line) diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index 353125ffb2..b67471d560 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -15,16 +15,28 @@ class ClusterPickingScanLineCase(ClusterPickingLineCommonCase): """ def _scan_line_ok(self, line, scanned): + batch = line.picking_id.batch_id response = self.service.dispatch( - "scan_line", params={"move_line_id": line.id, "barcode": scanned} + "scan_line", + params={ + "picking_batch_id": batch.id, + "move_line_id": line.id, + "barcode": scanned, + }, ) self.assert_response( response, next_state="scan_destination", data=self._line_data(line) ) def _scan_line_error(self, line, scanned, message): + batch = line.picking_id.batch_id response = self.service.dispatch( - "scan_line", params={"move_line_id": line.id, "barcode": scanned} + "scan_line", + params={ + "picking_batch_id": batch.id, + "move_line_id": line.id, + "barcode": scanned, + }, ) self.assert_response( response, @@ -300,6 +312,7 @@ def test_scan_destination_pack_ok(self): response = self.service.dispatch( "scan_destination_pack", params={ + "picking_batch_id": self.batch.id, "move_line_id": line.id, "barcode": self.bin1.name, "quantity": qty_done, @@ -335,6 +348,7 @@ def test_scan_destination_pack_ok_last_line(self): response = self.service.dispatch( "scan_destination_pack", params={ + "picking_batch_id": self.batch.id, "move_line_id": line.id, "barcode": self.bin2.name, "quantity": qty_done, @@ -360,6 +374,7 @@ def test_scan_destination_pack_not_empty_same_picking(self): response = self.service.dispatch( "scan_destination_pack", params={ + "picking_batch_id": self.batch.id, "move_line_id": line2.id, # this bin is used for the same picking, should be allowed "barcode": self.bin1.name, @@ -383,6 +398,7 @@ def test_scan_destination_pack_not_empty_different_picking(self): response = self.service.dispatch( "scan_destination_pack", params={ + "picking_batch_id": self.batch.id, "move_line_id": line.id, # this bin is used for the other picking "barcode": self.bin1.name, @@ -407,6 +423,7 @@ def test_scan_destination_pack_bin_not_found(self): response = self.service.dispatch( "scan_destination_pack", params={ + "picking_batch_id": self.batch.id, "move_line_id": line.id, # this bin is used for the other picking "barcode": "⌿", @@ -429,6 +446,7 @@ def test_scan_destination_pack_quantity_more(self): response = self.service.dispatch( "scan_destination_pack", params={ + "picking_batch_id": self.batch.id, "move_line_id": line.id, "barcode": self.bin1.name, "quantity": line.product_uom_qty + 1, @@ -455,6 +473,7 @@ def test_scan_destination_pack_quantity_less(self): response = self.service.dispatch( "scan_destination_pack", params={ + "picking_batch_id": self.batch.id, "move_line_id": line.id, "barcode": self.bin1.name, "quantity": line.product_uom_qty - 3, @@ -495,6 +514,7 @@ def test_scan_destination_pack_zero_check(self): response = self.service.dispatch( "scan_destination_pack", params={ + "picking_batch_id": self.batch.id, "move_line_id": line.id, "barcode": self.bin1.name, "quantity": line.product_uom_qty, @@ -504,7 +524,11 @@ def test_scan_destination_pack_zero_check(self): self.assert_response( response, next_state="zero_check", - data={"id": line.id, "location_src": self.data.location(line.location_id)}, + data={ + "id": line.id, + "location_src": self.data.location(line.location_id), + "batch": self.data.picking_batch(self.batch), + }, ) @@ -546,7 +570,12 @@ def setUpClassBaseData(cls, *args, **kwargs): def test_is_zero_is_empty(self): """call /is_zero confirming it's empty""" response = self.service.dispatch( - "is_zero", params={"move_line_id": self.line.id, "zero": True} + "is_zero", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": self.line.id, + "zero": True, + }, ) self.assert_response( response, @@ -565,7 +594,12 @@ def test_is_zero_is_empty(self): def test_is_zero_is_not_empty(self): """call /is_zero not confirming it's empty""" response = self.service.dispatch( - "is_zero", params={"move_line_id": self.line.id, "zero": False} + "is_zero", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": self.line.id, + "zero": False, + }, ) inventory = self.env["stock.inventory"].search( [ diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py index 41c612380a..a0054f741e 100644 --- a/shopfloor/tests/test_cluster_picking_skip.py +++ b/shopfloor/tests/test_cluster_picking_skip.py @@ -26,7 +26,10 @@ def setUpClassBaseData(cls, *args, **kwargs): ) def _skip_line(self, line, next_line=None): - response = self.service.dispatch("skip_line", params={"move_line_id": line.id}) + response = self.service.dispatch( + "skip_line", + params={"picking_batch_id": self.batch.id, "move_line_id": line.id}, + ) if next_line: self.assert_response( response, next_state="start_line", data=self._line_data(next_line) diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py index 8a2de6b921..8d1eaa0a83 100644 --- a/shopfloor/tests/test_cluster_picking_stock_issue.py +++ b/shopfloor/tests/test_cluster_picking_stock_issue.py @@ -29,8 +29,10 @@ def setUpClassBaseData(cls, *args, **kwargs): cls.dest_package = cls.env["stock.quant.package"].create({}) def _stock_issue(self, line, next_line_func=None): + batch = line.picking_id.batch_id response = self.service.dispatch( - "stock_issue", params={"move_line_id": line.id} + "stock_issue", + params={"picking_batch_id": batch.id, "move_line_id": line.id}, ) # use a function/lambda to delay the read of the next line, # when calling _stock_issue(), the move_line may not exist and From a2c165edfe7ab23d445f051ad631ac8476d70efd Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 26 May 2020 16:03:21 +0200 Subject: [PATCH 229/940] backend rest api: make schema dict/list required by default But nullable. If a key is missing and the schema is required by default, the test will fail so we can decide either to add the missing data either to pass required=False to the schema builder. --- shopfloor/services/schema.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 999c78c658..6e97fa3525 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -15,12 +15,14 @@ class BaseShopfloorSchemaResponse(Component): _is_rest_service_component = False def _schema_list_of(self, schema, **kw): - return { + schema = { "type": "list", "nullable": True, - "required": False, + "required": True, "schema": {"type": "dict", "schema": schema}, } + schema.update(kw) + return schema def _simple_record(self): return { @@ -32,7 +34,7 @@ def _schema_dict_of(self, schema, **kw): schema = { "type": "dict", "nullable": True, - "required": False, + "required": True, "schema": schema, } schema.update(kw) @@ -148,7 +150,7 @@ def packaging(self): "name": {"type": "string", "nullable": False, "required": True}, } - def picking_batch(self, with_pickings=True): + def picking_batch(self, with_pickings=False): schema = { "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, From 8ebe2133cff8ff64f43c1753129eda20067cf746 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 26 May 2020 16:02:58 +0200 Subject: [PATCH 230/940] backend: Hide SF fields on move lines by default --- shopfloor/views/stock_move_line.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shopfloor/views/stock_move_line.xml b/shopfloor/views/stock_move_line.xml index 5b970af673..67ee61d002 100644 --- a/shopfloor/views/stock_move_line.xml +++ b/shopfloor/views/stock_move_line.xml @@ -14,16 +14,19 @@ From 224964a3b2d3e145882c95ace3c088274e1834ba Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 26 May 2020 11:34:50 +0200 Subject: [PATCH 231/940] backend: add move line prod qty by packaging --- shopfloor/__manifest__.py | 3 ++ shopfloor/actions/data.py | 6 +++- shopfloor/services/schema.py | 52 ++++++++++++---------------- shopfloor/tests/common.py | 4 +++ shopfloor/tests/test_actions_data.py | 20 ++++++++++- 5 files changed, 53 insertions(+), 32 deletions(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index f9e2462919..7e0f0bddc7 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -35,6 +35,9 @@ # TODO: used for picking.carrier_id detail info. # This must be an optional dep "delivery", + # TODO: used for calculating qty by packaging to pick + # This must be an optional dep + "stock_packaging_calculator", ], "data": [ "security/ir.model.access.csv", diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 0d987e0da0..b16a6538fe 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -112,7 +112,7 @@ def lots(self, record, **kw): def _lot_parser(self): return ["id", "name", "ref"] - def move_line(self, record, **kw): + def move_line(self, record, qty_by_packaging=False, **kw): record = record.with_context(location=record.location_id.id) data = self._jsonify(record, self._move_line_parser) if data: @@ -128,6 +128,10 @@ def move_line(self, record, **kw): ), } ) + if qty_by_packaging: + data["qty_by_packaging"] = record.product_id.product_qty_by_packaging( + record.product_uom_qty + ) return data def move_lines(self, records, **kw): diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 6e97fa3525..868821378e 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -69,41 +69,33 @@ def picking(self): }, } - def move_line(self, with_packaging=False): - return { + def move_line(self, with_packaging=False, qty_by_packaging=False): + schema = { "id": {"type": "integer", "required": True}, "qty_done": {"type": "float", "required": True}, "quantity": {"type": "float", "required": True}, - "product": {"type": "dict", "required": True, "schema": self.product()}, - "lot": { - "type": "dict", - "required": False, - "nullable": True, - "schema": self.lot(), - }, - "package_src": { - "type": "dict", - "required": True, + "product": self._schema_dict_of(self.product(), required=True), + "lot": self._schema_dict_of(self.lot()), + "package_src": self._schema_dict_of( + self.package(with_packaging=with_packaging), required=True + ), + "package_dest": self._schema_dict_of( + self.package(with_packaging=with_packaging) + ), + "location_src": self._schema_dict_of( + self.location(), required=True, nullable=False + ), + "location_dest": self._schema_dict_of( + self.location(), required=True, nullable=False + ), + } + if qty_by_packaging: + schema["qty_by_packaging"] = { + "type": "list", "nullable": True, - "schema": self.package(with_packaging=with_packaging), - }, - "package_dest": { - "type": "dict", "required": False, - "nullable": True, - "schema": self.package(with_packaging=with_packaging), - }, - "location_src": { - "type": "dict", - "required": True, - "schema": self.location(), - }, - "location_dest": { - "type": "dict", - "required": True, - "schema": self.location(), - }, - } + } + return schema def product(self): return { diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index b83f076c0b..c5a933997b 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -138,6 +138,7 @@ def setUpClassBaseData(cls): "name": "Box", "product_id": cls.product_a.id, "barcode": "ProductABox", + "qty": 40, } ) ) @@ -162,6 +163,7 @@ def setUpClassBaseData(cls): "name": "Box", "product_id": cls.product_b.id, "barcode": "ProductBBox", + "qty": 30, } ) ) @@ -186,6 +188,7 @@ def setUpClassBaseData(cls): "name": "Box", "product_id": cls.product_b.id, "barcode": "ProductCBox", + "qty": 20, } ) ) @@ -210,6 +213,7 @@ def setUpClassBaseData(cls): "name": "Box", "product_id": cls.product_d.id, "barcode": "ProductDBox", + "qty": 10, } ) ) diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index d0612fe1ee..55fbb39b54 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -29,7 +29,7 @@ def setUpClassBaseData(cls): (cls.product_a, 10), (cls.product_b, 10), (cls.product_c, 10), - (cls.product_d, 10), + (cls.product_d, 25), ] ) # put product A in a package @@ -231,6 +231,24 @@ def test_data_move_line_raw(self): } self.assertDictEqual(data, expected) + def test_data_move_line_with_qty_by_packaging(self): + move_line = self.move_d.move_line_ids + data = self.data.move_line(move_line, qty_by_packaging=True) + self.assert_schema(self.schema.move_line(qty_by_packaging=True), data) + expected = { + "id": move_line.id, + "qty_done": 0.0, + "quantity": move_line.product_uom_qty, + "product": self._expected_product(self.product_d), + "lot": None, + "package_src": None, + "package_dest": None, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), + "qty_by_packaging": [(2, "Box"), (5, "Units")], + } + self.assertDictEqual(data, expected) + class ActionsDataCaseBatchPicking(ActionsDataCaseBase, PickingBatchMixin): @classmethod From d3e294ed2f5130b0ed464534977805de03a6d350 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 27 May 2020 09:13:59 +0200 Subject: [PATCH 232/940] Add docstring in base test class --- shopfloor/tests/common.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index c5a933997b..f508b8b9bc 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -25,11 +25,29 @@ def __eq__(self, other): class CommonCase(SavepointCase, ComponentMixin): + """Base class for writing Shopfloor tests + + All tests are run as normal stock user by default, to check that all the + services work without manager permissions. + + The consequences on writing tests: + + * Records created or written in a test setup must use sudo() + if the user has no permission on these models. + * Tests setUps should not extend setUpClass but setUpClassVars + and setUpClassBaseData, which already have an environment using + the stock user. + * Be wary of creating records before setUpClassUsers is called, because + it their "env.user" would be admin and could lead to inconsistencies + in tests. + + This class provides several helpers which are used throughout all the tests. + """ # by default disable tracking suite-wise, it's a time saver :) tracking_disable = True - ANY = AnyObject() + ANY = AnyObject() # allow accepting anything in assert_response() maxDiff = None From f2a080c69dc2c89acb6c64be412530a5b46ba8fc Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 27 May 2020 09:34:05 +0200 Subject: [PATCH 233/940] backend: add qty by packaging to cluster picking --- shopfloor/services/cluster_picking.py | 11 ++++----- shopfloor/tests/test_actions_data.py | 2 +- shopfloor/tests/test_actions_data_detail.py | 2 +- shopfloor/tests/test_cluster_picking_base.py | 8 +++---- .../test_cluster_picking_change_pack_lot.py | 4 ++-- shopfloor/tests/test_cluster_picking_scan.py | 24 +++++++++++-------- .../tests/test_cluster_picking_select.py | 2 +- shopfloor/tests/test_cluster_picking_skip.py | 4 +++- .../tests/test_cluster_picking_stock_issue.py | 2 +- .../tests/test_cluster_picking_unload.py | 2 +- 10 files changed, 33 insertions(+), 28 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 881b56931e..05a485a5bf 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -98,13 +98,13 @@ def _response_for_manual_selection(self, batches, message=None): def _response_for_start_line(self, move_line, message=None, popup=None): return self._response( next_state="start_line", - data=self._data_move_line(move_line), + data=self._data_move_line(move_line, qty_by_packaging=True), message=message, popup=popup, ) def _response_for_scan_destination(self, move_line, message=None): - data = self._data_move_line(move_line) + data = self._data_move_line(move_line, qty_by_packaging=True) last_picked_line = self._last_picked_line(move_line.picking_id) if last_picked_line: # suggest pack to be used for the next line @@ -116,7 +116,7 @@ def _response_for_scan_destination(self, move_line, message=None): def _response_for_change_pack_lot(self, move_line, message=None): return self._response( next_state="change_pack_lot", - data=self._data_move_line(move_line), + data=self._data_move_line(move_line, qty_by_packaging=True), message=message, ) @@ -379,7 +379,7 @@ def _data_move_line(self, line, **kw): picking = line.picking_id batch = picking.batch_id product = line.product_id - data = self.data_struct.move_line(line) + data = self.data_struct.move_line(line, **kw) # additional values # Ensure destination pack is never proposed on the frontend. # This should happen only as proposal on `scan_destination` @@ -391,7 +391,6 @@ def _data_move_line(self, line, **kw): data["product"]["qty_available"] = product.with_context( location=line.location_id.id ).qty_available - data.update(kw) return data def unassign(self, picking_batch_id): @@ -1577,7 +1576,7 @@ def _schema_for_batch_details(self): @property def _schema_for_single_line_details(self): - schema = self.schemas.move_line() + schema = self.schemas.move_line(qty_by_packaging=True) schema["picking"] = self.schemas._schema_dict_of(self.schemas.picking()) schema["batch"] = self.schemas._schema_dict_of(self.schemas.picking_batch()) return schema diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 55fbb39b54..f5482d075d 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -122,7 +122,7 @@ def test_data_picking(self): "name": self.picking.name, "note": "read me", "origin": "created by test", - "weight": 110.0, + "weight": 155.0, "partner": {"id": self.customer.id, "name": self.customer.name}, } self.assertDictEqual(data, expected) diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index 6f3c85d62d..81be39f882 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -171,7 +171,7 @@ def test_data_picking(self): "name": picking.name, "note": "read me", "origin": "created by test", - "weight": 110.0, + "weight": 155.0, "partner": {"id": self.customer.id, "name": self.customer.name}, "priority": "Very Urgent", "operation_type": { diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py index 2fd44ecb46..57ad3b68cc 100644 --- a/shopfloor/tests/test_cluster_picking_base.py +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -20,11 +20,11 @@ def setUp(self): with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="cluster_picking") - def _line_data(self, move_line, qty=None, package_dest=False): + def _line_data(self, move_line, qty=None, package_dest=False, **kw): picking = move_line.picking_id # A package exists on the move line, because the quant created # by ``_simulate_batch_selected`` has a package. - data = self.data.move_line(move_line) + data = self.data.move_line(move_line, **kw) if not package_dest: data["package_dest"] = None if qty: @@ -65,6 +65,6 @@ def setUpClassBaseData(cls, *args, **kwargs): [[cls.BatchProduct(product=cls.product_a, quantity=1)]] ) - def _line_data(self, move_line, qty=1.0): + def _line_data(self, move_line, qty=1.0, **kw): # just force qty to 1.0 - return super()._line_data(move_line, qty=qty) + return super()._line_data(move_line, qty=qty, **kw) diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py index f68772726b..37a6f6be64 100644 --- a/shopfloor/tests/test_cluster_picking_change_pack_lot.py +++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py @@ -48,14 +48,14 @@ def _test_change_pack_lot(self, line, barcode, success=True, message=None): response, message=message, next_state="scan_destination", - data=self._line_data(line), + data=self._line_data(line, qty_by_packaging=True), ) else: self.assert_response( response, message=message, next_state="change_pack_lot", - data=self._line_data(line), + data=self._line_data(line, qty_by_packaging=True), ) def _skip_line(self, line, next_line=None): diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index b67471d560..ec6494967b 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -25,7 +25,9 @@ def _scan_line_ok(self, line, scanned): }, ) self.assert_response( - response, next_state="scan_destination", data=self._line_data(line) + response, + next_state="scan_destination", + data=self._line_data(line, qty_by_packaging=True), ) def _scan_line_error(self, line, scanned, message): @@ -41,7 +43,7 @@ def _scan_line_error(self, line, scanned, message): self.assert_response( response, next_state="start_line", - data=self._line_data(line), + data=self._line_data(line, qty_by_packaging=True), message=message, ) @@ -324,7 +326,7 @@ def test_scan_destination_pack_ok(self): self.assert_response( response, next_state="start_line", - data=self._line_data(next_line), + data=self._line_data(next_line, qty_by_packaging=True), message={ "message_type": "success", "body": "{} {} put in {}".format( @@ -385,7 +387,9 @@ def test_scan_destination_pack_not_empty_same_picking(self): response, next_state="start_line", # we did not pick this line, so it should go there - data=self._line_data(self.one_line_picking.move_line_ids), + data=self._line_data( + self.one_line_picking.move_line_ids, qty_by_packaging=True + ), message=self.ANY, ) @@ -409,7 +413,7 @@ def test_scan_destination_pack_not_empty_different_picking(self): self.assert_response( response, next_state="scan_destination", - data=self._line_data(line), + data=self._line_data(line, qty_by_packaging=True), message={ "message_type": "error", "body": "The destination bin {} is not empty," @@ -433,7 +437,7 @@ def test_scan_destination_pack_bin_not_found(self): self.assert_response( response, next_state="scan_destination", - data=self._line_data(line), + data=self._line_data(line, qty_by_packaging=True), message={ "message_type": "error", "body": "Bin {} doesn't exist".format("⌿"), @@ -455,7 +459,7 @@ def test_scan_destination_pack_quantity_more(self): self.assert_response( response, next_state="scan_destination", - data=self._line_data(line), + data=self._line_data(line, qty_by_packaging=True), message={ "message_type": "error", "body": "You must not pick more than {} units.".format( @@ -493,7 +497,7 @@ def test_scan_destination_pack_quantity_less(self): self.assert_response( response, next_state="start_line", - data=self._line_data(new_line), + data=self._line_data(new_line, qty_by_packaging=True), message={ "message_type": "success", "body": "{} {} put in {}".format( @@ -580,7 +584,7 @@ def test_is_zero_is_empty(self): self.assert_response( response, next_state="start_line", - data=self._line_data(self.next_line), + data=self._line_data(self.next_line, qty_by_packaging=True), message={ "message_type": "success", "body": "{} {} put in {}".format( @@ -616,7 +620,7 @@ def test_is_zero_is_not_empty(self): self.assert_response( response, next_state="start_line", - data=self._line_data(self.next_line), + data=self._line_data(self.next_line, qty_by_packaging=True), message={ "message_type": "success", "body": "{} {} put in {}".format( diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py index 1b86cdf690..e74b272247 100644 --- a/shopfloor/tests/test_cluster_picking_select.py +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -277,7 +277,7 @@ def test_confirm_start_ok(self): response = self.service.dispatch( "confirm_start", params={"picking_batch_id": self.batch.id} ) - data = self.data.move_line(first_move_line) + data = self.data.move_line(first_move_line, qty_by_packaging=True) data["package_dest"] = None data["picking"] = self.data.picking(picking) data["batch"] = self.data.picking_batch(batch) diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py index a0054f741e..9ebfb12dac 100644 --- a/shopfloor/tests/test_cluster_picking_skip.py +++ b/shopfloor/tests/test_cluster_picking_skip.py @@ -32,7 +32,9 @@ def _skip_line(self, line, next_line=None): ) if next_line: self.assert_response( - response, next_state="start_line", data=self._line_data(next_line) + response, + next_state="start_line", + data=self._line_data(next_line, qty_by_packaging=True), ) return response diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py index 8d1eaa0a83..ce08643e3e 100644 --- a/shopfloor/tests/test_cluster_picking_stock_issue.py +++ b/shopfloor/tests/test_cluster_picking_stock_issue.py @@ -41,7 +41,7 @@ def _stock_issue(self, line, next_line_func=None): self.assert_response( response, next_state="start_line", - data=self._line_data(next_line_func()), + data=self._line_data(next_line_func(), qty_by_packaging=True), ) else: self.assert_response( diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index 6528eafe12..b91922591e 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -213,7 +213,7 @@ def test_set_destination_all_remaining_lines(self): # the remaining move line still needs to be picked response, next_state="start_line", - data=self._line_data(move_lines[2]), + data=self._line_data(move_lines[2], qty_by_packaging=True), message={"body": "Batch Transfer line done", "message_type": "success"}, ) From b9cb74c91a8c57d4a60e0eb6946e78eb8a7a2a8b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 27 May 2020 12:53:47 +0200 Subject: [PATCH 234/940] Revert "backend: add qty by packaging to cluster picking" This reverts commit f16d585789e906b281bed919af033b4537f30d6d. --- shopfloor/services/cluster_picking.py | 11 +++++---- shopfloor/tests/test_actions_data.py | 2 +- shopfloor/tests/test_actions_data_detail.py | 2 +- shopfloor/tests/test_cluster_picking_base.py | 8 +++---- .../test_cluster_picking_change_pack_lot.py | 4 ++-- shopfloor/tests/test_cluster_picking_scan.py | 24 ++++++++----------- .../tests/test_cluster_picking_select.py | 2 +- shopfloor/tests/test_cluster_picking_skip.py | 4 +--- .../tests/test_cluster_picking_stock_issue.py | 2 +- .../tests/test_cluster_picking_unload.py | 2 +- 10 files changed, 28 insertions(+), 33 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 05a485a5bf..881b56931e 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -98,13 +98,13 @@ def _response_for_manual_selection(self, batches, message=None): def _response_for_start_line(self, move_line, message=None, popup=None): return self._response( next_state="start_line", - data=self._data_move_line(move_line, qty_by_packaging=True), + data=self._data_move_line(move_line), message=message, popup=popup, ) def _response_for_scan_destination(self, move_line, message=None): - data = self._data_move_line(move_line, qty_by_packaging=True) + data = self._data_move_line(move_line) last_picked_line = self._last_picked_line(move_line.picking_id) if last_picked_line: # suggest pack to be used for the next line @@ -116,7 +116,7 @@ def _response_for_scan_destination(self, move_line, message=None): def _response_for_change_pack_lot(self, move_line, message=None): return self._response( next_state="change_pack_lot", - data=self._data_move_line(move_line, qty_by_packaging=True), + data=self._data_move_line(move_line), message=message, ) @@ -379,7 +379,7 @@ def _data_move_line(self, line, **kw): picking = line.picking_id batch = picking.batch_id product = line.product_id - data = self.data_struct.move_line(line, **kw) + data = self.data_struct.move_line(line) # additional values # Ensure destination pack is never proposed on the frontend. # This should happen only as proposal on `scan_destination` @@ -391,6 +391,7 @@ def _data_move_line(self, line, **kw): data["product"]["qty_available"] = product.with_context( location=line.location_id.id ).qty_available + data.update(kw) return data def unassign(self, picking_batch_id): @@ -1576,7 +1577,7 @@ def _schema_for_batch_details(self): @property def _schema_for_single_line_details(self): - schema = self.schemas.move_line(qty_by_packaging=True) + schema = self.schemas.move_line() schema["picking"] = self.schemas._schema_dict_of(self.schemas.picking()) schema["batch"] = self.schemas._schema_dict_of(self.schemas.picking_batch()) return schema diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index f5482d075d..55fbb39b54 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -122,7 +122,7 @@ def test_data_picking(self): "name": self.picking.name, "note": "read me", "origin": "created by test", - "weight": 155.0, + "weight": 110.0, "partner": {"id": self.customer.id, "name": self.customer.name}, } self.assertDictEqual(data, expected) diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index 81be39f882..6f3c85d62d 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -171,7 +171,7 @@ def test_data_picking(self): "name": picking.name, "note": "read me", "origin": "created by test", - "weight": 155.0, + "weight": 110.0, "partner": {"id": self.customer.id, "name": self.customer.name}, "priority": "Very Urgent", "operation_type": { diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py index 57ad3b68cc..2fd44ecb46 100644 --- a/shopfloor/tests/test_cluster_picking_base.py +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -20,11 +20,11 @@ def setUp(self): with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="cluster_picking") - def _line_data(self, move_line, qty=None, package_dest=False, **kw): + def _line_data(self, move_line, qty=None, package_dest=False): picking = move_line.picking_id # A package exists on the move line, because the quant created # by ``_simulate_batch_selected`` has a package. - data = self.data.move_line(move_line, **kw) + data = self.data.move_line(move_line) if not package_dest: data["package_dest"] = None if qty: @@ -65,6 +65,6 @@ def setUpClassBaseData(cls, *args, **kwargs): [[cls.BatchProduct(product=cls.product_a, quantity=1)]] ) - def _line_data(self, move_line, qty=1.0, **kw): + def _line_data(self, move_line, qty=1.0): # just force qty to 1.0 - return super()._line_data(move_line, qty=qty, **kw) + return super()._line_data(move_line, qty=qty) diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py index 37a6f6be64..f68772726b 100644 --- a/shopfloor/tests/test_cluster_picking_change_pack_lot.py +++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py @@ -48,14 +48,14 @@ def _test_change_pack_lot(self, line, barcode, success=True, message=None): response, message=message, next_state="scan_destination", - data=self._line_data(line, qty_by_packaging=True), + data=self._line_data(line), ) else: self.assert_response( response, message=message, next_state="change_pack_lot", - data=self._line_data(line, qty_by_packaging=True), + data=self._line_data(line), ) def _skip_line(self, line, next_line=None): diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index ec6494967b..b67471d560 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -25,9 +25,7 @@ def _scan_line_ok(self, line, scanned): }, ) self.assert_response( - response, - next_state="scan_destination", - data=self._line_data(line, qty_by_packaging=True), + response, next_state="scan_destination", data=self._line_data(line) ) def _scan_line_error(self, line, scanned, message): @@ -43,7 +41,7 @@ def _scan_line_error(self, line, scanned, message): self.assert_response( response, next_state="start_line", - data=self._line_data(line, qty_by_packaging=True), + data=self._line_data(line), message=message, ) @@ -326,7 +324,7 @@ def test_scan_destination_pack_ok(self): self.assert_response( response, next_state="start_line", - data=self._line_data(next_line, qty_by_packaging=True), + data=self._line_data(next_line), message={ "message_type": "success", "body": "{} {} put in {}".format( @@ -387,9 +385,7 @@ def test_scan_destination_pack_not_empty_same_picking(self): response, next_state="start_line", # we did not pick this line, so it should go there - data=self._line_data( - self.one_line_picking.move_line_ids, qty_by_packaging=True - ), + data=self._line_data(self.one_line_picking.move_line_ids), message=self.ANY, ) @@ -413,7 +409,7 @@ def test_scan_destination_pack_not_empty_different_picking(self): self.assert_response( response, next_state="scan_destination", - data=self._line_data(line, qty_by_packaging=True), + data=self._line_data(line), message={ "message_type": "error", "body": "The destination bin {} is not empty," @@ -437,7 +433,7 @@ def test_scan_destination_pack_bin_not_found(self): self.assert_response( response, next_state="scan_destination", - data=self._line_data(line, qty_by_packaging=True), + data=self._line_data(line), message={ "message_type": "error", "body": "Bin {} doesn't exist".format("⌿"), @@ -459,7 +455,7 @@ def test_scan_destination_pack_quantity_more(self): self.assert_response( response, next_state="scan_destination", - data=self._line_data(line, qty_by_packaging=True), + data=self._line_data(line), message={ "message_type": "error", "body": "You must not pick more than {} units.".format( @@ -497,7 +493,7 @@ def test_scan_destination_pack_quantity_less(self): self.assert_response( response, next_state="start_line", - data=self._line_data(new_line, qty_by_packaging=True), + data=self._line_data(new_line), message={ "message_type": "success", "body": "{} {} put in {}".format( @@ -584,7 +580,7 @@ def test_is_zero_is_empty(self): self.assert_response( response, next_state="start_line", - data=self._line_data(self.next_line, qty_by_packaging=True), + data=self._line_data(self.next_line), message={ "message_type": "success", "body": "{} {} put in {}".format( @@ -620,7 +616,7 @@ def test_is_zero_is_not_empty(self): self.assert_response( response, next_state="start_line", - data=self._line_data(self.next_line, qty_by_packaging=True), + data=self._line_data(self.next_line), message={ "message_type": "success", "body": "{} {} put in {}".format( diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py index e74b272247..1b86cdf690 100644 --- a/shopfloor/tests/test_cluster_picking_select.py +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -277,7 +277,7 @@ def test_confirm_start_ok(self): response = self.service.dispatch( "confirm_start", params={"picking_batch_id": self.batch.id} ) - data = self.data.move_line(first_move_line, qty_by_packaging=True) + data = self.data.move_line(first_move_line) data["package_dest"] = None data["picking"] = self.data.picking(picking) data["batch"] = self.data.picking_batch(batch) diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py index 9ebfb12dac..a0054f741e 100644 --- a/shopfloor/tests/test_cluster_picking_skip.py +++ b/shopfloor/tests/test_cluster_picking_skip.py @@ -32,9 +32,7 @@ def _skip_line(self, line, next_line=None): ) if next_line: self.assert_response( - response, - next_state="start_line", - data=self._line_data(next_line, qty_by_packaging=True), + response, next_state="start_line", data=self._line_data(next_line) ) return response diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py index ce08643e3e..8d1eaa0a83 100644 --- a/shopfloor/tests/test_cluster_picking_stock_issue.py +++ b/shopfloor/tests/test_cluster_picking_stock_issue.py @@ -41,7 +41,7 @@ def _stock_issue(self, line, next_line_func=None): self.assert_response( response, next_state="start_line", - data=self._line_data(next_line_func(), qty_by_packaging=True), + data=self._line_data(next_line_func()), ) else: self.assert_response( diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index b91922591e..6528eafe12 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -213,7 +213,7 @@ def test_set_destination_all_remaining_lines(self): # the remaining move line still needs to be picked response, next_state="start_line", - data=self._line_data(move_lines[2], qty_by_packaging=True), + data=self._line_data(move_lines[2]), message={"body": "Batch Transfer line done", "message_type": "success"}, ) From 773751693c921120ed1253a56d35e62e16393c9f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 27 May 2020 12:53:54 +0200 Subject: [PATCH 235/940] Revert "backend: add move line prod qty by packaging" This reverts commit 2f05235ef352dd5d4df3ad6defbac91e51d2fe33. --- shopfloor/__manifest__.py | 3 -- shopfloor/actions/data.py | 6 +--- shopfloor/services/schema.py | 52 ++++++++++++++++------------ shopfloor/tests/common.py | 4 --- shopfloor/tests/test_actions_data.py | 20 +---------- 5 files changed, 32 insertions(+), 53 deletions(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 7e0f0bddc7..f9e2462919 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -35,9 +35,6 @@ # TODO: used for picking.carrier_id detail info. # This must be an optional dep "delivery", - # TODO: used for calculating qty by packaging to pick - # This must be an optional dep - "stock_packaging_calculator", ], "data": [ "security/ir.model.access.csv", diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index b16a6538fe..0d987e0da0 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -112,7 +112,7 @@ def lots(self, record, **kw): def _lot_parser(self): return ["id", "name", "ref"] - def move_line(self, record, qty_by_packaging=False, **kw): + def move_line(self, record, **kw): record = record.with_context(location=record.location_id.id) data = self._jsonify(record, self._move_line_parser) if data: @@ -128,10 +128,6 @@ def move_line(self, record, qty_by_packaging=False, **kw): ), } ) - if qty_by_packaging: - data["qty_by_packaging"] = record.product_id.product_qty_by_packaging( - record.product_uom_qty - ) return data def move_lines(self, records, **kw): diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 868821378e..6e97fa3525 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -69,33 +69,41 @@ def picking(self): }, } - def move_line(self, with_packaging=False, qty_by_packaging=False): - schema = { + def move_line(self, with_packaging=False): + return { "id": {"type": "integer", "required": True}, "qty_done": {"type": "float", "required": True}, "quantity": {"type": "float", "required": True}, - "product": self._schema_dict_of(self.product(), required=True), - "lot": self._schema_dict_of(self.lot()), - "package_src": self._schema_dict_of( - self.package(with_packaging=with_packaging), required=True - ), - "package_dest": self._schema_dict_of( - self.package(with_packaging=with_packaging) - ), - "location_src": self._schema_dict_of( - self.location(), required=True, nullable=False - ), - "location_dest": self._schema_dict_of( - self.location(), required=True, nullable=False - ), - } - if qty_by_packaging: - schema["qty_by_packaging"] = { - "type": "list", + "product": {"type": "dict", "required": True, "schema": self.product()}, + "lot": { + "type": "dict", + "required": False, "nullable": True, + "schema": self.lot(), + }, + "package_src": { + "type": "dict", + "required": True, + "nullable": True, + "schema": self.package(with_packaging=with_packaging), + }, + "package_dest": { + "type": "dict", "required": False, - } - return schema + "nullable": True, + "schema": self.package(with_packaging=with_packaging), + }, + "location_src": { + "type": "dict", + "required": True, + "schema": self.location(), + }, + "location_dest": { + "type": "dict", + "required": True, + "schema": self.location(), + }, + } def product(self): return { diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index f508b8b9bc..1f83cde1eb 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -156,7 +156,6 @@ def setUpClassBaseData(cls): "name": "Box", "product_id": cls.product_a.id, "barcode": "ProductABox", - "qty": 40, } ) ) @@ -181,7 +180,6 @@ def setUpClassBaseData(cls): "name": "Box", "product_id": cls.product_b.id, "barcode": "ProductBBox", - "qty": 30, } ) ) @@ -206,7 +204,6 @@ def setUpClassBaseData(cls): "name": "Box", "product_id": cls.product_b.id, "barcode": "ProductCBox", - "qty": 20, } ) ) @@ -231,7 +228,6 @@ def setUpClassBaseData(cls): "name": "Box", "product_id": cls.product_d.id, "barcode": "ProductDBox", - "qty": 10, } ) ) diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 55fbb39b54..d0612fe1ee 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -29,7 +29,7 @@ def setUpClassBaseData(cls): (cls.product_a, 10), (cls.product_b, 10), (cls.product_c, 10), - (cls.product_d, 25), + (cls.product_d, 10), ] ) # put product A in a package @@ -231,24 +231,6 @@ def test_data_move_line_raw(self): } self.assertDictEqual(data, expected) - def test_data_move_line_with_qty_by_packaging(self): - move_line = self.move_d.move_line_ids - data = self.data.move_line(move_line, qty_by_packaging=True) - self.assert_schema(self.schema.move_line(qty_by_packaging=True), data) - expected = { - "id": move_line.id, - "qty_done": 0.0, - "quantity": move_line.product_uom_qty, - "product": self._expected_product(self.product_d), - "lot": None, - "package_src": None, - "package_dest": None, - "location_src": self._expected_location(move_line.location_id), - "location_dest": self._expected_location(move_line.location_dest_id), - "qty_by_packaging": [(2, "Box"), (5, "Units")], - } - self.assertDictEqual(data, expected) - class ActionsDataCaseBatchPicking(ActionsDataCaseBase, PickingBatchMixin): @classmethod From 7108b262033a74aa5ddef20cbc47484cd6009c82 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 28 May 2020 08:34:12 +0200 Subject: [PATCH 236/940] checkout: clean some stuff * use data_struct and msg_store properties as in other workflows * put the _response_for_ methods first --- shopfloor/services/checkout.py | 238 ++++++++++++++++----------------- 1 file changed, 113 insertions(+), 125 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 73cb1395d2..f427115d00 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -34,6 +34,98 @@ class Checkout(Component): _usage = "checkout" _description = __doc__ + @property + def data_struct(self): + return self.actions_for("data") + + @property + def msg_store(self): + return self.actions_for("message") + + def _response_for_select_line(self, picking, message=None): + if all(line.shopfloor_checkout_done for line in picking.move_line_ids): + return self._response_for_summary(picking, message=message) + return self._response( + next_state="select_line", + data={"picking": self._data_for_stock_picking(picking)}, + message=message, + ) + + def _response_for_summary(self, picking, need_confirm=False, message=None): + return self._response( + next_state="summary" if not need_confirm else "confirm_done", + data={ + "picking": self._data_for_stock_picking(picking, done=True), + "all_processed": not bool(self._lines_to_pack(picking)), + }, + message=message, + ) + + def _response_for_select_document(self, message=None): + return self._response(next_state="select_document", message=message) + + def _response_for_manual_selection(self, message=None): + pickings = self.env["stock.picking"].search( + self._domain_for_list_stock_picking(), + order=self._order_for_list_stock_picking(), + ) + data = {"pickings": self.data_struct.pickings(pickings)} + return self._response(next_state="manual_selection", data=data, message=message) + + def _response_for_select_package(self, lines, message=None): + picking = lines.mapped("picking_id") + return self._response( + next_state="select_package", + data={ + "selected_move_lines": self._data_for_move_lines(lines.sorted()), + "picking": self.data_struct.picking(picking), + }, + message=message, + ) + + def _response_for_select_dest_package(self, picking, move_lines, message=None): + packages = picking.mapped("move_line_ids.package_id") | picking.mapped( + "move_line_ids.result_package_id" + ) + if not packages: + return self._response_for_select_package( + move_lines, + message={ + "message_type": "warning", + "body": _("No valid package to select."), + }, + ) + picking_data = self.data_struct.picking(picking) + packages_data = self.data_struct.packages( + packages.sorted(), picking=picking, with_packaging=True + ) + return self._response( + next_state="select_dest_package", + data={ + "picking": picking_data, + "packages": packages_data, + "selected_move_lines": self._data_for_move_lines(move_lines.sorted()), + }, + message=message, + ) + + def _response_for_change_packaging(self, picking, package, packaging_list): + if not package: + return self._response_for_summary( + picking, message=self.msg_store.record_not_found() + ) + + return self._response( + next_state="change_packaging", + data={ + "picking": self.data_struct.picking(picking), + "package": self.data_struct.package( + package, picking=picking, with_packaging=True + ), + "packagings": self.data_struct.packagings(packaging_list.sorted()), + }, + ) + def scan_document(self, barcode): """Scan a package, a stock.picking or a location @@ -57,7 +149,6 @@ def scan_document(self, barcode): destination pack set """ search = self.actions_for("search") - message = self.actions_for("message") picking = search.picking_from_scan(barcode) if not picking: location = search.location_from_scan(barcode) @@ -66,7 +157,7 @@ def scan_document(self, barcode): self.picking_types.mapped("default_location_src_id") ): return self._response_for_select_document( - message=message.location_not_allowed() + message=self.msg_store.location_not_allowed() ) lines = location.source_move_line_ids pickings = lines.mapped("picking_id") @@ -103,62 +194,37 @@ def scan_document(self, barcode): return self._select_picking(picking, "select_document") def _select_picking(self, picking, state_for_error): - message = self.actions_for("message") if not picking: if state_for_error == "manual_selection": return self._response_for_manual_selection( - message=message.stock_picking_not_found() + message=self.msg_store.stock_picking_not_found() ) return self._response_for_select_document( - message=message.barcode_not_found() + message=self.msg_store.barcode_not_found() ) if picking.picking_type_id not in self.picking_types: if state_for_error == "manual_selection": return self._response_for_manual_selection( - message=message.cannot_move_something_in_picking_type() + message=self.msg_store.cannot_move_something_in_picking_type() ) return self._response_for_select_document( - message=message.cannot_move_something_in_picking_type() + message=self.msg_store.cannot_move_something_in_picking_type() ) if picking.state != "assigned": if state_for_error == "manual_selection": return self._response_for_manual_selection( - message=message.stock_picking_not_available(picking) + message=self.msg_store.stock_picking_not_available(picking) ) return self._response_for_select_document( - message=message.stock_picking_not_available(picking) + message=self.msg_store.stock_picking_not_available(picking) ) return self._response_for_select_line(picking) - def _response_for_select_line(self, picking, message=None): - if all(line.shopfloor_checkout_done for line in picking.move_line_ids): - return self._response_for_summary(picking, message=message) - return self._response( - next_state="select_line", - data={"picking": self._data_for_stock_picking(picking)}, - message=message, - ) - - def _response_for_summary(self, picking, need_confirm=False, message=None): - return self._response( - next_state="summary" if not need_confirm else "confirm_done", - data={ - "picking": self._data_for_stock_picking(picking, done=True), - "all_processed": not bool(self._lines_to_pack(picking)), - }, - message=message, - ) - - def _response_for_select_document(self, message=None): - return self._response(next_state="select_document", message=message) - def _data_for_move_lines(self, lines, **kw): - data_struct = self.actions_for("data") - return data_struct.move_lines(lines, **kw) + return self.data_struct.move_lines(lines, **kw) def _data_for_stock_picking(self, picking, done=False): - data_struct = self.actions_for("data") - data = data_struct.picking(picking) + data = self.data_struct.picking(picking) line_picker = self._lines_checkout_done if done else self._lines_to_pack data.update( { @@ -195,15 +261,6 @@ def list_stock_picking(self): """ return self._response_for_manual_selection() - def _response_for_manual_selection(self, message=None): - pickings = self.env["stock.picking"].search( - self._domain_for_list_stock_picking(), - order=self._order_for_list_stock_picking(), - ) - data_struct = self.actions_for("data") - data = {"pickings": data_struct.pickings(pickings)} - return self._response(next_state="manual_selection", data=data, message=message) - def select(self, picking_id): """Select a stock picking for the scenario @@ -225,18 +282,6 @@ def select(self, picking_id): picking = self.env["stock.picking"].browse(picking_id).exists() return self._select_picking(picking, "manual_selection") - def _response_for_select_package(self, lines, message=None): - data_struct = self.actions_for("data") - picking = lines.mapped("picking_id") - return self._response( - next_state="select_package", - data={ - "selected_move_lines": self._data_for_move_lines(lines.sorted()), - "picking": data_struct.picking(picking), - }, - message=message, - ) - def _select_lines(self, lines): for line in lines: if line.shopfloor_checkout_done: @@ -274,7 +319,6 @@ def scan_line(self, picking_id, barcode): return self._response_stock_picking_does_not_exist() search = self.actions_for("search") - message = self.actions_for("message") selection_lines = self._lines_to_pack(picking) if not selection_lines: @@ -293,7 +337,7 @@ def scan_line(self, picking_id, barcode): return self._select_lines_from_lot(picking, selection_lines, lot) return self._response_for_select_line( - picking, message=message.barcode_not_found() + picking, message=self.msg_store.barcode_not_found() ) def _select_lines_from_package(self, picking, selection_lines, package): @@ -312,10 +356,9 @@ def _select_lines_from_package(self, picking, selection_lines, package): return self._response_for_select_package(lines) def _select_lines_from_product(self, picking, selection_lines, product): - message = self.actions_for("message") if product.tracking in ("lot", "serial"): return self._response_for_select_line( - picking, message=message.scan_lot_on_product_tracked_by_lot() + picking, message=self.msg_store.scan_lot_on_product_tracked_by_lot() ) lines = selection_lines.filtered(lambda l: l.product_id == product) @@ -339,7 +382,7 @@ def _select_lines_from_product(self, picking, selection_lines, product): # package. if packages and len({l.package_id for l in lines}) > 1: return self._response_for_select_line( - picking, message=message.product_multiple_packages_scan_package() + picking, message=self.msg_store.product_multiple_packages_scan_package() ) elif packages: # Select all the lines of the package when we scan a product in a @@ -360,7 +403,6 @@ def _select_lines_from_lot(self, picking, selection_lines, lot): }, ) - message = self.actions_for("message") # When lots are as units outside of packages, we can select them for # packing, but if they are in a package, we want the user to scan the packages. # If the product is only in one package though, scanning the lot selects @@ -372,7 +414,7 @@ def _select_lines_from_lot(self, picking, selection_lines, lot): # package. if packages and len({l.package_id for l in lines}) > 1: return self._response_for_select_line( - picking, message=message.lot_multiple_packages_scan_package() + picking, message=self.msg_store.lot_multiple_packages_scan_package() ) elif packages: # Select all the lines of the package when we scan a lot in a @@ -384,17 +426,15 @@ def _select_lines_from_lot(self, picking, selection_lines, lot): def _select_line_package(self, picking, selection_lines, package): if not package: - message = self.actions_for("message") return self._response_for_select_line( - picking, message=message.record_not_found() + picking, message=self.msg_store.record_not_found() ) return self._select_lines_from_package(picking, selection_lines, package) def _select_line_move_line(self, picking, selection_lines, move_line): if not move_line: - message = self.actions_for("message") return self._response_for_select_line( - picking, message=message.record_not_found() + picking, message=self.msg_store.record_not_found() ) # normally, the client should sent only move lines out of packages, but # in case there is a package, handle it as a package @@ -445,13 +485,11 @@ def _change_line_qty( if not picking.exists(): return self._response_stock_picking_does_not_exist() - message_directory = self.actions_for("message") - move_lines = self.env["stock.move.line"].browse(move_line_ids).exists() message = None if not move_lines: - message = message_directory.record_not_found() + message = self.msg_store.record_not_found() for move_line in move_lines: qty_done = quantity_func(move_line) if qty_done > move_line.product_uom_qty: @@ -641,7 +679,6 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): if not picking.exists(): return self._response_stock_picking_does_not_exist() search = self.actions_for("search") - message = self.actions_for("message") selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() @@ -649,7 +686,8 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): if product: if product.tracking in ("lot", "serial"): return self._response_for_select_package( - selected_lines, message=message.scan_lot_on_product_tracked_by_lot() + selected_lines, + message=self.msg_store.scan_lot_on_product_tracked_by_lot(), ) product_lines = selected_lines.filtered(lambda l: l.product_id == product) return self._switch_line_qty_done(picking, selected_lines, product_lines) @@ -670,7 +708,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): ) return self._response_for_select_package( - selected_lines, message=message.barcode_not_found() + selected_lines, message=self.msg_store.barcode_not_found() ) def new_package(self, picking_id, selected_line_ids): @@ -734,34 +772,6 @@ def list_dest_package(self, picking_id, selected_line_ids): lines = self.env["stock.move.line"].browse(selected_line_ids).exists() return self._response_for_select_dest_package(picking, lines) - def _response_for_select_dest_package(self, picking, move_lines, message=None): - packages = picking.mapped("move_line_ids.package_id") | picking.mapped( - "move_line_ids.result_package_id" - ) - if not packages: - return self._response_for_select_package( - move_lines, - message={ - "message_type": "warning", - "body": _("No valid package to select."), - }, - ) - data_struct = self.actions_for("data") - picking_data = data_struct.picking(picking) - packages_data = data_struct.packages( - packages.sorted(), picking=picking, with_packaging=True - ) - data_struct = self.actions_for("data") - return self._response( - next_state="select_dest_package", - data={ - "picking": picking_data, - "packages": packages_data, - "selected_move_lines": self._data_for_move_lines(move_lines.sorted()), - }, - message=message, - ) - def _set_dest_package_from_selection(self, picking, selected_lines, package): if not package: return self._response_for_select_dest_package(picking, selected_lines) @@ -852,25 +862,6 @@ def list_packaging(self, picking_id, package_id): packaging_list = self._get_allowed_packaging() return self._response_for_change_packaging(picking, package, packaging_list) - def _response_for_change_packaging(self, picking, package, packaging_list): - message = self.actions_for("message") - data_struct = self.actions_for("data") - if not package: - return self._response_for_summary( - picking, message=message.record_not_found() - ) - - return self._response( - next_state="change_packaging", - data={ - "picking": data_struct.picking(picking), - "package": data_struct.package( - package, picking=picking, with_packaging=True - ), - "packagings": data_struct.packagings(packaging_list.sorted()), - }, - ) - def set_packaging(self, picking_id, package_id, packaging_id): """Set a package type on a package @@ -878,8 +869,6 @@ def set_packaging(self, picking_id, package_id, packaging_id): * change_packaging: in case of error * summary """ - message = self.actions_for("message") - picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): return self._response_stock_picking_does_not_exist() @@ -888,7 +877,7 @@ def set_packaging(self, picking_id, package_id, packaging_id): packaging = self.env["product.packaging"].browse(packaging_id).exists() if not (package and packaging): return self._response_for_summary( - picking, message=message.record_not_found() + picking, message=self.msg_store.record_not_found() ) package.product_packaging_id = packaging return self._response_for_summary( @@ -915,7 +904,6 @@ def cancel_line(self, picking_id, package_id=None, line_id=None): Transitions: * summary """ - message = self.actions_for("message") picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): return self._response_stock_picking_does_not_exist() @@ -924,7 +912,7 @@ def cancel_line(self, picking_id, package_id=None, line_id=None): line = self.env["stock.move.line"].browse(line_id).exists() if not package and not line: return self._response_for_summary( - picking, message=message.record_not_found() + picking, message=self.msg_store.record_not_found() ) if package: From 7f271074709be665398bc439df07a0f12d6c78d1 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 29 May 2020 08:53:59 +0200 Subject: [PATCH 237/940] cluster picking: fix reservation inconsistency When we pick a good and pick less than the reserved quantity, the endpoint splits the move line in 2 parts: one with the qty_done set to the quantity passed to the endpoint, another move line for the remaining. When the copy of the move line is created, the reserved quantity on the move line doesn't change, but when the quantity is reduced on the current line, the reserved quantity on the quant is automatically reduced accordingly. We don't want this, because we didn't reduced the total reserved quantity, only moved it to another move line. Before: ------- * We have a quant of 40 product A in Shelf 1 * We reserve a move line of 20 product A in Shelf 1, the reserved quantity of the quant is 20 * We start the batch transfer, using the barcode scanner app, we scan the product and update the quantity to pick 13 products only * A new line of 7 is created, quant's reserved quantity is still 20 * Quantity of the current move line is set to 13, the reserved quantity of the quant is now 13 Result: we have a desynchronization between the move lines (20 reserved) and the quants (13 reserved). If we try to unreserve a move line, we have an error: "It is not possible to unreserve more products of %s than you have in stock." After: ------ Same steps as before, but we ignore the update of the quant at the last step. Result: both the move lines and quants have 20 units reserved. --- shopfloor/services/cluster_picking.py | 7 ++++- shopfloor/tests/test_cluster_picking_scan.py | 29 ++++++++++++++------ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 881b56931e..e40d981491 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -601,7 +601,12 @@ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantit # contained less items than expected) remaining = move_line.product_uom_qty - quantity new_line = move_line.copy({"product_uom_qty": remaining, "qty_done": 0}) - move_line.product_uom_qty = quantity + # if we didn't bypass reservation update, the quant reservation + # would be reduced as much as the deduced quantity, which is wrong + # as we only moved the quantity to a new move line + move_line.with_context( + bypass_reservation_update=True + ).product_uom_qty = quantity search = self.actions_for("search") bin_package = search.package_from_scan(barcode) diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index b67471d560..11909cccf0 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -467,6 +467,15 @@ def test_scan_destination_pack_quantity_more(self): def test_scan_destination_pack_quantity_less(self): """Pick less units than expected""" line = self.one_line_picking.move_line_ids + quant = self.env["stock.quant"].search( + [ + ("location_id", "=", line.location_id.id), + ("product_id", "=", line.product_id.id), + ] + ) + quant.ensure_one() + self.assertRecordValues(quant, [{"quantity": 40.0, "reserved_quantity": 20.0}]) + # when we pick less quantity than expected, the line is split # and the user is proposed to pick the next line for the remaining # quantity @@ -479,16 +488,7 @@ def test_scan_destination_pack_quantity_less(self): "quantity": line.product_uom_qty - 3, }, ) - - self.assertRecordValues( - line, - [{"qty_done": 7, "result_package_id": self.bin1.id, "product_uom_qty": 7}], - ) new_line = self.one_line_picking.move_line_ids - line - self.assertRecordValues( - new_line, - [{"qty_done": 0, "result_package_id": False, "product_uom_qty": 3}], - ) self.assert_response( response, @@ -502,6 +502,17 @@ def test_scan_destination_pack_quantity_less(self): }, ) + self.assertRecordValues( + line, + [{"qty_done": 7, "result_package_id": self.bin1.id, "product_uom_qty": 7}], + ) + self.assertRecordValues( + new_line, + [{"qty_done": 0, "result_package_id": False, "product_uom_qty": 3}], + ) + # the reserved quantity on the quant must stay the same + self.assertRecordValues(quant, [{"quantity": 40.0, "reserved_quantity": 20.0}]) + def test_scan_destination_pack_zero_check(self): """Location will be emptied, have to go to zero check""" line = self.one_line_picking.move_line_ids From 411acc9bf7d3fb50b7fbb179a5796240bb1f6157 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 28 May 2020 16:12:41 +0200 Subject: [PATCH 238/940] Log shopfloor requests in a table --- shopfloor/__manifest__.py | 3 + shopfloor/data/ir_config_parameter_data.xml | 7 ++ shopfloor/data/ir_cron_data.xml | 15 +++ shopfloor/models/__init__.py | 1 + shopfloor/models/shopfloor_log.py | 58 +++++++++ shopfloor/readme/CONFIGURE.rst | 9 ++ shopfloor/security/ir.model.access.csv | 5 +- shopfloor/services/service.py | 48 +++++++- shopfloor/views/menus.xml | 7 ++ shopfloor/views/shopfloor_log_views.xml | 125 ++++++++++++++++++++ 10 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 shopfloor/data/ir_config_parameter_data.xml create mode 100644 shopfloor/data/ir_cron_data.xml create mode 100644 shopfloor/models/shopfloor_log.py create mode 100644 shopfloor/views/shopfloor_log_views.xml diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index f9e2462919..2d0d521699 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -37,12 +37,15 @@ "delivery", ], "data": [ + "data/ir_config_parameter_data.xml", + "data/ir_cron_data.xml", "security/ir.model.access.csv", "views/shopfloor_menu.xml", "views/stock_picking_type.xml", "views/stock_location.xml", "views/stock_move_line.xml", "views/shopfloor_profile_views.xml", + "views/shopfloor_log_views.xml", "views/menus.xml", ], "demo": [ diff --git a/shopfloor/data/ir_config_parameter_data.xml b/shopfloor/data/ir_config_parameter_data.xml new file mode 100644 index 0000000000..2127ea5cbe --- /dev/null +++ b/shopfloor/data/ir_config_parameter_data.xml @@ -0,0 +1,7 @@ + + + + shopfloor.log.retention.days + 30 + + diff --git a/shopfloor/data/ir_cron_data.xml b/shopfloor/data/ir_cron_data.xml new file mode 100644 index 0000000000..865d533988 --- /dev/null +++ b/shopfloor/data/ir_cron_data.xml @@ -0,0 +1,15 @@ + + + + AutoVacuum Shopfloor Logs + + + + 1 + days + -1 + + code + model.autovacuum() + + diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index 7f9b986965..1bfd3ece74 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -1,4 +1,5 @@ from . import shopfloor_menu +from . import shopfloor_log from . import stock_picking_type from . import shopfloor_profile from . import stock_inventory diff --git a/shopfloor/models/shopfloor_log.py b/shopfloor/models/shopfloor_log.py new file mode 100644 index 0000000000..a17e9a1432 --- /dev/null +++ b/shopfloor/models/shopfloor_log.py @@ -0,0 +1,58 @@ +import logging +from datetime import datetime, timedelta + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + + +class ShopfloorLog(models.Model): + _name = "shopfloor.log" + _description = "Shopfloor Logging" + _order = "id desc" + + DEFAULT_RETENTION = 30 # days + + request_url = fields.Char(readonly=True, string="Request URL") + request_method = fields.Char(readonly=True) + params = fields.Text(readonly=True) + headers = fields.Text(readonly=True) + result = fields.Text(readonly=True) + error = fields.Text(readonly=True) + state = fields.Selection( + selection=[("success", "Success"), ("failed", "Failed")], readonly=True, + ) + + def _logs_retention_days(self): + retention = self.DEFAULT_RETENTION + param = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("shopfloor.log.retention.days") + ) + if param: + try: + retention = int(param) + except ValueError: + _logger.exception( + "Could not convert System Parameter" + " 'shopfloor.log.retention.days' to integer," + " reverting to the" + " default configuration." + ) + return retention + + def logging_active(self): + retention = self._logs_retention_days() + return retention > 0 + + def autovacuum(self): + """Delete logs which have exceeded their retention duration + + Called from a cron. + """ + deadline = datetime.now() - timedelta(days=self._logs_retention_days()) + logs = self.search([("create_date", "<=", deadline)]) + if logs: + logs.unlink() + return True diff --git a/shopfloor/readme/CONFIGURE.rst b/shopfloor/readme/CONFIGURE.rst index 8442c179fd..84321ab431 100644 --- a/shopfloor/readme/CONFIGURE.rst +++ b/shopfloor/readme/CONFIGURE.rst @@ -1 +1,10 @@ writeme + +Logs retention +-------------- + +By default, Shopfloor logs are kept 30 days. +You can change the duration of the retention by changing +the System Parameter ``shopfloor.log.retention.days``. + +If the value is set to 0, the logs are not stored at all. diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv index 6aca8c733c..bdf728b8e5 100644 --- a/shopfloor/security/ir.model.access.csv +++ b/shopfloor/security/ir.model.access.csv @@ -1,5 +1,6 @@ "id","name","model_id/id","group_id/id","perm_read","perm_write","perm_create","perm_unlink" -"access_shopfloor_menu_users","shopfloor menu","model_shopfloor_menu",,1,0,0,0 +"access_shopfloor_menu_users","shopfloor menu","model_shopfloor_menu","stock.group_stock_user",1,0,0,0 "access_shopfloor_menu_stock_manager","shopfloor menu inventory manager","model_shopfloor_menu","stock.group_stock_manager",1,1,1,1 -"access_shopfloor_profile_users","shopfloor profile","model_shopfloor_profile",,1,0,0,0 +"access_shopfloor_profile_users","shopfloor profile","model_shopfloor_profile","stock.group_stock_user",1,0,0,0 "access_shopfloor_profile_stock_manager","shopfloor profile inventory manager","model_shopfloor_profile","stock.group_stock_manager",1,1,1,1 +"access_shopfloor_log","access_shopfloor_log","model_shopfloor_log","stock.group_stock_manager",1,0,0,0 diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 3841079f56..fea0a43b69 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -1,5 +1,6 @@ -from odoo import _, exceptions +from odoo import _, exceptions, registry from odoo.exceptions import MissingError +from odoo.http import request from odoo.osv import expression from odoo.addons.base_rest.controllers.main import _PseudoCollection @@ -23,6 +24,51 @@ class BaseShopfloorService(AbstractComponent): _actions_collection_name = "shopfloor.action" _expose_model = None + def dispatch(self, method_name, _id=None, params=None): + try: + result = super().dispatch(method_name, _id=_id, params=params) + except Exception as err: + self.env.cr.rollback() + with registry(self.env.cr.dbname).cursor() as cr: + env = self.env(cr=cr) + self._log_call_in_db(env, _id, params, error=err) + raise + self._log_call_in_db(self.env, _id, params, result=result) + return result + + @property + def _log_call_header_strip(self): + return ("Cookie", "Api-Key") + + def _log_call_in_db_values(self, _id, params, result=None, error=None): + if not request: + # In tests, we have no http request + return + httprequest = request.httprequest + headers = dict(httprequest.headers) + for header_key in self._log_call_header_strip: + if header_key in headers: + headers[header_key] = "" + if _id: + params = dict(params, _id=_id) + return { + "request_url": httprequest.url, + "request_method": httprequest.method, + "params": params, + "headers": headers, + "result": result, + "error": error, + "state": "success" if result else "failed", + } + + def _log_call_in_db(self, env, _id, params, result=None, error=None): + if not env["shopfloor.log"].logging_active(): + return + values = self._log_call_in_db_values(_id, params, result=result, error=error) + if not values: + return + env["shopfloor.log"].sudo().create(values) + def _get(self, _id): domain = expression.normalize_domain(self._get_base_search_domain()) domain = expression.AND([domain, [("id", "=", _id)]]) diff --git a/shopfloor/views/menus.xml b/shopfloor/views/menus.xml index 29876a3a77..f1a619ed6b 100644 --- a/shopfloor/views/menus.xml +++ b/shopfloor/views/menus.xml @@ -18,4 +18,11 @@ parent="menu_shopfloor_settings" sequence="20" /> + diff --git a/shopfloor/views/shopfloor_log_views.xml b/shopfloor/views/shopfloor_log_views.xml new file mode 100644 index 0000000000..6ebb576f7c --- /dev/null +++ b/shopfloor/views/shopfloor_log_views.xml @@ -0,0 +1,125 @@ + + + + shopfloor.log tree + shopfloor.log + + + + + + + + + + + + shopfloor.log form + shopfloor.log + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + shopfloor.log search + shopfloor.log + + + + + + + + + + + + + + + + + + + Shopfloor Logs + shopfloor.log + ir.actions.act_window + tree,form + +
From 295d14143eb5516bc3e267902d44850630aedab7 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 29 May 2020 10:56:00 +0200 Subject: [PATCH 239/940] backend: fix packaging handling --- shopfloor/actions/data.py | 20 +- shopfloor/services/checkout.py | 238 ++++++++++---------- shopfloor/services/schema.py | 9 +- shopfloor/tests/test_actions_data.py | 15 +- shopfloor/tests/test_actions_data_detail.py | 3 +- 5 files changed, 155 insertions(+), 130 deletions(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 0d987e0da0..9d53aa23bf 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -18,6 +18,9 @@ def _jsonify(self, recordset, parser, multi=False, **kw): return res[0] if res else None return res + def _simple_record_parser(self): + return ["id", "name"] + def partner(self, record, **kw): return self._jsonify(record, self._partner_parser, **kw) @@ -26,7 +29,7 @@ def partners(self, record, **kw): @property def _partner_parser(self): - return ["id", "name"] + return self._simple_record_parser() def location(self, record, **kw): return self._jsonify( @@ -95,12 +98,12 @@ def _package_packaging_parser(self): def packaging(self, record, **kw): return self._jsonify(record, self._packaging_parser, **kw) - def packagings(self, record, **kw): + def packaging_list(self, record, **kw): return self.packaging(record, multi=True) @property def _packaging_parser(self): - return ["id", "name"] + return self._simple_record_parser() + ["qty"] def lot(self, record, **kw): return self._jsonify(record, self._lot_parser, **kw) @@ -110,7 +113,7 @@ def lots(self, record, **kw): @property def _lot_parser(self): - return ["id", "name", "ref"] + return self._simple_record_parser() + ["ref"] def move_line(self, record, **kw): record = record.with_context(location=record.location_id.id) @@ -153,7 +156,14 @@ def products(self, record, **kw): @property def _product_parser(self): - return ["id", "name", "display_name", "default_code", "barcode"] + return [ + "id", + "name", + "display_name", + "default_code", + "barcode", + ("packaging_ids:packaging", self._packaging_parser), + ] def picking_batch(self, record, with_pickings=False, **kw): parser = self._picking_batch_parser diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index f427115d00..fab2f37847 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -34,98 +34,6 @@ class Checkout(Component): _usage = "checkout" _description = __doc__ - @property - def data_struct(self): - return self.actions_for("data") - - @property - def msg_store(self): - return self.actions_for("message") - - def _response_for_select_line(self, picking, message=None): - if all(line.shopfloor_checkout_done for line in picking.move_line_ids): - return self._response_for_summary(picking, message=message) - return self._response( - next_state="select_line", - data={"picking": self._data_for_stock_picking(picking)}, - message=message, - ) - - def _response_for_summary(self, picking, need_confirm=False, message=None): - return self._response( - next_state="summary" if not need_confirm else "confirm_done", - data={ - "picking": self._data_for_stock_picking(picking, done=True), - "all_processed": not bool(self._lines_to_pack(picking)), - }, - message=message, - ) - - def _response_for_select_document(self, message=None): - return self._response(next_state="select_document", message=message) - - def _response_for_manual_selection(self, message=None): - pickings = self.env["stock.picking"].search( - self._domain_for_list_stock_picking(), - order=self._order_for_list_stock_picking(), - ) - data = {"pickings": self.data_struct.pickings(pickings)} - return self._response(next_state="manual_selection", data=data, message=message) - - def _response_for_select_package(self, lines, message=None): - picking = lines.mapped("picking_id") - return self._response( - next_state="select_package", - data={ - "selected_move_lines": self._data_for_move_lines(lines.sorted()), - "picking": self.data_struct.picking(picking), - }, - message=message, - ) - - def _response_for_select_dest_package(self, picking, move_lines, message=None): - packages = picking.mapped("move_line_ids.package_id") | picking.mapped( - "move_line_ids.result_package_id" - ) - if not packages: - return self._response_for_select_package( - move_lines, - message={ - "message_type": "warning", - "body": _("No valid package to select."), - }, - ) - picking_data = self.data_struct.picking(picking) - packages_data = self.data_struct.packages( - packages.sorted(), picking=picking, with_packaging=True - ) - return self._response( - next_state="select_dest_package", - data={ - "picking": picking_data, - "packages": packages_data, - "selected_move_lines": self._data_for_move_lines(move_lines.sorted()), - }, - message=message, - ) - - def _response_for_change_packaging(self, picking, package, packaging_list): - if not package: - return self._response_for_summary( - picking, message=self.msg_store.record_not_found() - ) - - return self._response( - next_state="change_packaging", - data={ - "picking": self.data_struct.picking(picking), - "package": self.data_struct.package( - package, picking=picking, with_packaging=True - ), - "packagings": self.data_struct.packagings(packaging_list.sorted()), - }, - ) - def scan_document(self, barcode): """Scan a package, a stock.picking or a location @@ -149,6 +57,7 @@ def scan_document(self, barcode): destination pack set """ search = self.actions_for("search") + message = self.actions_for("message") picking = search.picking_from_scan(barcode) if not picking: location = search.location_from_scan(barcode) @@ -157,7 +66,7 @@ def scan_document(self, barcode): self.picking_types.mapped("default_location_src_id") ): return self._response_for_select_document( - message=self.msg_store.location_not_allowed() + message=message.location_not_allowed() ) lines = location.source_move_line_ids pickings = lines.mapped("picking_id") @@ -194,37 +103,62 @@ def scan_document(self, barcode): return self._select_picking(picking, "select_document") def _select_picking(self, picking, state_for_error): + message = self.actions_for("message") if not picking: if state_for_error == "manual_selection": return self._response_for_manual_selection( - message=self.msg_store.stock_picking_not_found() + message=message.stock_picking_not_found() ) return self._response_for_select_document( - message=self.msg_store.barcode_not_found() + message=message.barcode_not_found() ) if picking.picking_type_id not in self.picking_types: if state_for_error == "manual_selection": return self._response_for_manual_selection( - message=self.msg_store.cannot_move_something_in_picking_type() + message=message.cannot_move_something_in_picking_type() ) return self._response_for_select_document( - message=self.msg_store.cannot_move_something_in_picking_type() + message=message.cannot_move_something_in_picking_type() ) if picking.state != "assigned": if state_for_error == "manual_selection": return self._response_for_manual_selection( - message=self.msg_store.stock_picking_not_available(picking) + message=message.stock_picking_not_available(picking) ) return self._response_for_select_document( - message=self.msg_store.stock_picking_not_available(picking) + message=message.stock_picking_not_available(picking) ) return self._response_for_select_line(picking) + def _response_for_select_line(self, picking, message=None): + if all(line.shopfloor_checkout_done for line in picking.move_line_ids): + return self._response_for_summary(picking, message=message) + return self._response( + next_state="select_line", + data={"picking": self._data_for_stock_picking(picking)}, + message=message, + ) + + def _response_for_summary(self, picking, need_confirm=False, message=None): + return self._response( + next_state="summary" if not need_confirm else "confirm_done", + data={ + "picking": self._data_for_stock_picking(picking, done=True), + "all_processed": not bool(self._lines_to_pack(picking)), + }, + message=message, + ) + + def _response_for_select_document(self, message=None): + return self._response(next_state="select_document", message=message) + def _data_for_move_lines(self, lines, **kw): - return self.data_struct.move_lines(lines, **kw) + data_struct = self.actions_for("data") + return data_struct.move_lines(lines, **kw) def _data_for_stock_picking(self, picking, done=False): - data = self.data_struct.picking(picking) + data_struct = self.actions_for("data") + data = data_struct.picking(picking) line_picker = self._lines_checkout_done if done else self._lines_to_pack data.update( { @@ -261,6 +195,15 @@ def list_stock_picking(self): """ return self._response_for_manual_selection() + def _response_for_manual_selection(self, message=None): + pickings = self.env["stock.picking"].search( + self._domain_for_list_stock_picking(), + order=self._order_for_list_stock_picking(), + ) + data_struct = self.actions_for("data") + data = {"pickings": data_struct.pickings(pickings)} + return self._response(next_state="manual_selection", data=data, message=message) + def select(self, picking_id): """Select a stock picking for the scenario @@ -282,6 +225,18 @@ def select(self, picking_id): picking = self.env["stock.picking"].browse(picking_id).exists() return self._select_picking(picking, "manual_selection") + def _response_for_select_package(self, lines, message=None): + data_struct = self.actions_for("data") + picking = lines.mapped("picking_id") + return self._response( + next_state="select_package", + data={ + "selected_move_lines": self._data_for_move_lines(lines.sorted()), + "picking": data_struct.picking(picking), + }, + message=message, + ) + def _select_lines(self, lines): for line in lines: if line.shopfloor_checkout_done: @@ -319,6 +274,7 @@ def scan_line(self, picking_id, barcode): return self._response_stock_picking_does_not_exist() search = self.actions_for("search") + message = self.actions_for("message") selection_lines = self._lines_to_pack(picking) if not selection_lines: @@ -337,7 +293,7 @@ def scan_line(self, picking_id, barcode): return self._select_lines_from_lot(picking, selection_lines, lot) return self._response_for_select_line( - picking, message=self.msg_store.barcode_not_found() + picking, message=message.barcode_not_found() ) def _select_lines_from_package(self, picking, selection_lines, package): @@ -356,9 +312,10 @@ def _select_lines_from_package(self, picking, selection_lines, package): return self._response_for_select_package(lines) def _select_lines_from_product(self, picking, selection_lines, product): + message = self.actions_for("message") if product.tracking in ("lot", "serial"): return self._response_for_select_line( - picking, message=self.msg_store.scan_lot_on_product_tracked_by_lot() + picking, message=message.scan_lot_on_product_tracked_by_lot() ) lines = selection_lines.filtered(lambda l: l.product_id == product) @@ -382,7 +339,7 @@ def _select_lines_from_product(self, picking, selection_lines, product): # package. if packages and len({l.package_id for l in lines}) > 1: return self._response_for_select_line( - picking, message=self.msg_store.product_multiple_packages_scan_package() + picking, message=message.product_multiple_packages_scan_package() ) elif packages: # Select all the lines of the package when we scan a product in a @@ -403,6 +360,7 @@ def _select_lines_from_lot(self, picking, selection_lines, lot): }, ) + message = self.actions_for("message") # When lots are as units outside of packages, we can select them for # packing, but if they are in a package, we want the user to scan the packages. # If the product is only in one package though, scanning the lot selects @@ -414,7 +372,7 @@ def _select_lines_from_lot(self, picking, selection_lines, lot): # package. if packages and len({l.package_id for l in lines}) > 1: return self._response_for_select_line( - picking, message=self.msg_store.lot_multiple_packages_scan_package() + picking, message=message.lot_multiple_packages_scan_package() ) elif packages: # Select all the lines of the package when we scan a lot in a @@ -426,15 +384,17 @@ def _select_lines_from_lot(self, picking, selection_lines, lot): def _select_line_package(self, picking, selection_lines, package): if not package: + message = self.actions_for("message") return self._response_for_select_line( - picking, message=self.msg_store.record_not_found() + picking, message=message.record_not_found() ) return self._select_lines_from_package(picking, selection_lines, package) def _select_line_move_line(self, picking, selection_lines, move_line): if not move_line: + message = self.actions_for("message") return self._response_for_select_line( - picking, message=self.msg_store.record_not_found() + picking, message=message.record_not_found() ) # normally, the client should sent only move lines out of packages, but # in case there is a package, handle it as a package @@ -485,11 +445,13 @@ def _change_line_qty( if not picking.exists(): return self._response_stock_picking_does_not_exist() + message_directory = self.actions_for("message") + move_lines = self.env["stock.move.line"].browse(move_line_ids).exists() message = None if not move_lines: - message = self.msg_store.record_not_found() + message = message_directory.record_not_found() for move_line in move_lines: qty_done = quantity_func(move_line) if qty_done > move_line.product_uom_qty: @@ -679,6 +641,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): if not picking.exists(): return self._response_stock_picking_does_not_exist() search = self.actions_for("search") + message = self.actions_for("message") selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() @@ -686,8 +649,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): if product: if product.tracking in ("lot", "serial"): return self._response_for_select_package( - selected_lines, - message=self.msg_store.scan_lot_on_product_tracked_by_lot(), + selected_lines, message=message.scan_lot_on_product_tracked_by_lot() ) product_lines = selected_lines.filtered(lambda l: l.product_id == product) return self._switch_line_qty_done(picking, selected_lines, product_lines) @@ -708,7 +670,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): ) return self._response_for_select_package( - selected_lines, message=self.msg_store.barcode_not_found() + selected_lines, message=message.barcode_not_found() ) def new_package(self, picking_id, selected_line_ids): @@ -772,6 +734,34 @@ def list_dest_package(self, picking_id, selected_line_ids): lines = self.env["stock.move.line"].browse(selected_line_ids).exists() return self._response_for_select_dest_package(picking, lines) + def _response_for_select_dest_package(self, picking, move_lines, message=None): + packages = picking.mapped("move_line_ids.package_id") | picking.mapped( + "move_line_ids.result_package_id" + ) + if not packages: + return self._response_for_select_package( + move_lines, + message={ + "message_type": "warning", + "body": _("No valid package to select."), + }, + ) + data_struct = self.actions_for("data") + picking_data = data_struct.picking(picking) + packages_data = data_struct.packages( + packages.sorted(), picking=picking, with_packaging=True + ) + data_struct = self.actions_for("data") + return self._response( + next_state="select_dest_package", + data={ + "picking": picking_data, + "packages": packages_data, + "selected_move_lines": self._data_for_move_lines(move_lines.sorted()), + }, + message=message, + ) + def _set_dest_package_from_selection(self, picking, selected_lines, package): if not package: return self._response_for_select_dest_package(picking, selected_lines) @@ -862,6 +852,25 @@ def list_packaging(self, picking_id, package_id): packaging_list = self._get_allowed_packaging() return self._response_for_change_packaging(picking, package, packaging_list) + def _response_for_change_packaging(self, picking, package, packaging_list): + message = self.actions_for("message") + data_struct = self.actions_for("data") + if not package: + return self._response_for_summary( + picking, message=message.record_not_found() + ) + + return self._response( + next_state="change_packaging", + data={ + "picking": data_struct.picking(picking), + "package": data_struct.package( + package, picking=picking, with_packaging=True + ), + "packagings": data_struct.packaging_list(packaging_list.sorted()), + }, + ) + def set_packaging(self, picking_id, package_id, packaging_id): """Set a package type on a package @@ -869,6 +878,8 @@ def set_packaging(self, picking_id, package_id, packaging_id): * change_packaging: in case of error * summary """ + message = self.actions_for("message") + picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): return self._response_stock_picking_does_not_exist() @@ -877,7 +888,7 @@ def set_packaging(self, picking_id, package_id, packaging_id): packaging = self.env["product.packaging"].browse(packaging_id).exists() if not (package and packaging): return self._response_for_summary( - picking, message=self.msg_store.record_not_found() + picking, message=message.record_not_found() ) package.product_packaging_id = packaging return self._response_for_summary( @@ -904,6 +915,7 @@ def cancel_line(self, picking_id, package_id=None, line_id=None): Transitions: * summary """ + message = self.actions_for("message") picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): return self._response_stock_picking_does_not_exist() @@ -912,7 +924,7 @@ def cancel_line(self, picking_id, package_id=None, line_id=None): line = self.env["stock.move.line"].browse(line_id).exists() if not package and not line: return self._response_for_summary( - picking, message=self.msg_store.record_not_found() + picking, message=message.record_not_found() ) if package: diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 6e97fa3525..ebe1523a29 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -112,6 +112,7 @@ def product(self): "display_name": {"type": "string", "nullable": False, "required": True}, "default_code": {"type": "string", "nullable": False, "required": True}, "barcode": {"type": "string", "nullable": True, "required": False}, + "packaging": self._schema_list_of(self.packaging()), } def package(self, with_packaging=False): @@ -122,12 +123,7 @@ def package(self, with_packaging=False): "move_line_count": {"required": False, "nullable": True, "type": "integer"}, } if with_packaging: - schema["packaging"] = { - "type": "dict", - "required": True, - "nullable": True, - "schema": self.packaging(), - } + schema["packaging"] = self._schema_dict_of(self.packaging()) return schema def lot(self): @@ -148,6 +144,7 @@ def packaging(self): return { "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, + "qty": {"type": "float", "required": True}, } def picking_batch(self, with_pickings=False): diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index d0612fe1ee..395de60b80 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -64,6 +64,14 @@ def _expected_product(self, record, **kw): "display_name": record.display_name, "default_code": record.default_code, "barcode": record.barcode, + "packaging": [self._expected_packaging(x) for x in record.packaging_ids], + } + + def _expected_packaging(self, record, **kw): + return { + "id": record.id, + "name": record.name, + "qty": record.qty, } @@ -71,8 +79,7 @@ class ActionsDataCase(ActionsDataCaseBase): def test_data_packaging(self): data = self.data.packaging(self.packaging) self.assert_schema(self.schema.packaging(), data) - expected = {"id": self.packaging.id, "name": self.packaging.name} - self.assertDictEqual(data, expected) + self.assertDictEqual(data, self._expected_packaging(self.packaging)) def test_data_location(self): location = self.stock_location @@ -107,8 +114,8 @@ def test_data_package(self): "id": package.id, "name": package.name, "move_line_count": 1, - "packaging": self.data.packaging(package.product_packaging_id), - "weight": 0, + "packaging": self._expected_packaging(package.product_packaging_id), + "weight": 0.0, } self.assertDictEqual(data, expected) diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index 6f3c85d62d..d715d4aefa 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -97,8 +97,7 @@ def test_data_location(self): def test_data_packaging(self): data = self.data_detail.packaging(self.packaging) self.assert_schema(self.schema_detail.packaging(), data) - expected = {"id": self.packaging.id, "name": self.packaging.name} - self.assertDictEqual(data, expected) + self.assertDictEqual(data, self._expected_packaging(self.packaging)) def test_data_lot(self): lot = self.env["stock.production.lot"].create( From 74f8a34f274e105a8e6bd4bd62ab7e37565bfb89 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 29 May 2020 11:51:20 +0200 Subject: [PATCH 240/940] backend: fix test packagings naming --- shopfloor/tests/test_checkout_change_packaging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py index b7d4165837..0fc93b7674 100644 --- a/shopfloor/tests/test_checkout_change_packaging.py +++ b/shopfloor/tests/test_checkout_change_packaging.py @@ -65,7 +65,7 @@ def test_list_packaging_ok(self): data={ "picking": self._picking_summary_data(self.picking), "package": self._package_data(self.package, self.picking), - "packagings": [ + "packaging": [ self._packaging_data(packaging) for packaging in self.packaging_inner_box + self.packaging_box From 0a31b21091d4538fc34dac3de3b389669c8823b2 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 29 May 2020 12:22:46 +0200 Subject: [PATCH 241/940] backend: fix single pack transfer tests --- shopfloor/tests/test_single_pack_transfer.py | 30 +++----------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index bebde9dcc5..dd4dca4695 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -69,32 +69,10 @@ def _response_package_level_data(self, package_level): return { "id": package_level.id, "name": package_level.package_id.name, - "location_src": { - "id": self.shelf1.id, - "name": self.shelf1.name, - "barcode": self.shelf1.barcode, - }, - "location_dest": { - "id": self.shelf2.id, - "name": self.shelf2.name, - "barcode": self.shelf2.barcode, - }, - "picking": { - "id": self.picking.id, - "name": self.picking.name, - "note": None, - "origin": None, - "partner": None, - "move_line_count": len(self.picking.move_line_ids), - "weight": 2.0, - }, - "product": { - "id": self.product_a.id, - "name": self.product_a.name, - "default_code": self.product_a.default_code, - "barcode": self.product_a.barcode, - "display_name": self.product_a.display_name, - }, + "location_src": self.data.location(self.shelf1), + "location_dest": self.data.location(self.shelf2), + "picking": self.data.picking(self.picking), + "product": self.data.product(self.product_a), } def test_start(self): From 03ba0771d2266f67e0bf8170a35223b54b0ea739 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 2 Jun 2020 12:04:11 +0200 Subject: [PATCH 242/940] fixup! Log shopfloor requests in a table --- shopfloor/data/ir_cron_data.xml | 2 +- shopfloor/readme/CONFIGURE.rst | 11 +++++++++-- shopfloor/services/service.py | 34 ++++++++++++++++++++++----------- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/shopfloor/data/ir_cron_data.xml b/shopfloor/data/ir_cron_data.xml index 865d533988..7c5809584b 100644 --- a/shopfloor/data/ir_cron_data.xml +++ b/shopfloor/data/ir_cron_data.xml @@ -1,7 +1,7 @@ - AutoVacuum Shopfloor Logs + Auto-vacuum Shopfloor Logs diff --git a/shopfloor/readme/CONFIGURE.rst b/shopfloor/readme/CONFIGURE.rst index 84321ab431..fe0cc8cdee 100644 --- a/shopfloor/readme/CONFIGURE.rst +++ b/shopfloor/readme/CONFIGURE.rst @@ -3,8 +3,15 @@ writeme Logs retention -------------- +Logs are kept in database for every REST requests made by a client application. +They can be used for debugging and monitoring of the activity. + +The Logs menu is shown only with Developer tools (``?debug=1``) activated. + By default, Shopfloor logs are kept 30 days. -You can change the duration of the retention by changing -the System Parameter ``shopfloor.log.retention.days``. +You can change the duration of the retention by changing the System Parameter +``shopfloor.log.retention.days``. If the value is set to 0, the logs are not stored at all. + +Logged data is: request URL and method, parameters, headers, result or error. diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index fea0a43b69..51465f1f16 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -23,28 +23,40 @@ class BaseShopfloorService(AbstractComponent): _collection = "shopfloor.service" _actions_collection_name = "shopfloor.action" _expose_model = None + # can be overridden to disable logging of requests to DB + _log_calls_in_db = True def dispatch(self, method_name, _id=None, params=None): + if not self._db_logging_active(): + return super().dispatch(method_name, _id=_id, params=params) + return self._dispatch_with_db_logging(method_name, _id=_id, params=params) + + def _db_logging_active(self): + return ( + request + and self._log_calls_in_db + and self.env["shopfloor.log"].logging_active() + ) + + # TODO logging to DB should be an extra module for base_rest + def _dispatch_with_db_logging(self, method_name, _id=None, params=None): try: result = super().dispatch(method_name, _id=_id, params=params) except Exception as err: self.env.cr.rollback() with registry(self.env.cr.dbname).cursor() as cr: env = self.env(cr=cr) - self._log_call_in_db(env, _id, params, error=err) + self._log_call_in_db(env, request, _id, params, error=err) raise - self._log_call_in_db(self.env, _id, params, result=result) + self._log_call_in_db(self.env, request, _id, params, result=result) return result @property def _log_call_header_strip(self): return ("Cookie", "Api-Key") - def _log_call_in_db_values(self, _id, params, result=None, error=None): - if not request: - # In tests, we have no http request - return - httprequest = request.httprequest + def _log_call_in_db_values(self, _request, _id, params, result=None, error=None): + httprequest = _request.httprequest headers = dict(httprequest.headers) for header_key in self._log_call_header_strip: if header_key in headers: @@ -61,10 +73,10 @@ def _log_call_in_db_values(self, _id, params, result=None, error=None): "state": "success" if result else "failed", } - def _log_call_in_db(self, env, _id, params, result=None, error=None): - if not env["shopfloor.log"].logging_active(): - return - values = self._log_call_in_db_values(_id, params, result=result, error=error) + def _log_call_in_db(self, env, _request, _id, params, result=None, error=None): + values = self._log_call_in_db_values( + _request, _id, params, result=result, error=error + ) if not values: return env["shopfloor.log"].sudo().create(values) From bcd0b9f809c17ff3b308762c2fa993553623495d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 2 Jun 2020 12:13:12 +0200 Subject: [PATCH 243/940] backend: expose product unit of measure --- shopfloor/actions/data.py | 1 + shopfloor/services/schema.py | 12 ++++++++++-- shopfloor/tests/test_actions_data.py | 6 ++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 9d53aa23bf..c884cfa71c 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -163,6 +163,7 @@ def _product_parser(self): "default_code", "barcode", ("packaging_ids:packaging", self._packaging_parser), + ("uom_id:uom", self._simple_record_parser() + ["factor", "rounding"]), ] def picking_batch(self, record, with_pickings=False, **kw): diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index ebe1523a29..f894f82250 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -24,11 +24,13 @@ def _schema_list_of(self, schema, **kw): schema.update(kw) return schema - def _simple_record(self): - return { + def _simple_record(self, **kw): + schema = { "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, } + schema.update(kw) + return schema def _schema_dict_of(self, schema, **kw): schema = { @@ -113,6 +115,12 @@ def product(self): "default_code": {"type": "string", "nullable": False, "required": True}, "barcode": {"type": "string", "nullable": True, "required": False}, "packaging": self._schema_list_of(self.packaging()), + "uom": self._schema_dict_of( + self._simple_record( + factor={"required": True, "nullable": True, "type": "float"}, + rounding={"required": True, "nullable": True, "type": "float"}, + ) + ), } def package(self, with_packaging=False): diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 395de60b80..53deab34d3 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -65,6 +65,12 @@ def _expected_product(self, record, **kw): "default_code": record.default_code, "barcode": record.barcode, "packaging": [self._expected_packaging(x) for x in record.packaging_ids], + "uom": { + "factor": record.uom_id.factor, + "id": record.uom_id.id, + "name": record.uom_id.name, + "rounding": record.uom_id.rounding, + }, } def _expected_packaging(self, record, **kw): From 74f591f10f23c0084345ed731721bd810ca63503 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 2 Apr 2020 11:39:23 +0200 Subject: [PATCH 244/940] delivery: implement /scan_deliver --- shopfloor/models/stock_move_line.py | 4 + shopfloor/models/stock_quant_package.py | 1 - shopfloor/services/delivery.py | 193 +++++++++++++++++- shopfloor/tests/__init__.py | 1 + shopfloor/tests/common.py | 12 ++ shopfloor/tests/test_delivery_base.py | 11 + shopfloor/tests/test_delivery_scan_deliver.py | 145 +++++++++++++ 7 files changed, 360 insertions(+), 7 deletions(-) create mode 100644 shopfloor/tests/test_delivery_scan_deliver.py diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py index ce6d28630d..29c96da23d 100644 --- a/shopfloor/models/stock_move_line.py +++ b/shopfloor/models/stock_move_line.py @@ -16,3 +16,7 @@ class StockMoveLine(models.Model): # we search lines based on their location in some workflows location_id = fields.Many2one(index=True) + package_id = fields.Many2one(index=True) + + # allow domain on picking_id.xxx without too much perf penalty + picking_id = fields.Many2one(auto_join=True) diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py index 41547158cf..c3bc49511d 100644 --- a/shopfloor/models/stock_quant_package.py +++ b/shopfloor/models/stock_quant_package.py @@ -10,7 +10,6 @@ class StockQuantPackage(models.Model): readonly=True, help="Technical field. Move lines moving this package.", ) - planned_move_line_ids = fields.One2many( comodel_name="stock.move.line", inverse_name="result_package_id", diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 9c59f4df34..45143f373e 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -1,3 +1,6 @@ +from odoo import fields +from odoo.osv import expression + from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -30,7 +33,15 @@ class Delivery(Component): _usage = "delivery" _description = __doc__ - def scan_deliver(self, barcode): + def _data_for_stock_picking(self, picking): + data_struct = self.actions_for("data") + data = data_struct.picking_summary(picking) + data.update( + {"move_lines": [data_struct.move_line(ml) for ml in picking.move_line_ids]} + ) + return data + + def scan_deliver(self, barcode, picking_id=None): """Scan a stock picking or a package/product/lot When a stock picking is scanned and is partially or fully available, it @@ -54,11 +65,173 @@ def scan_deliver(self, barcode): When all the available move lines of the stock picking are done, the stock picking is set to done. + The ``picking_id`` parameter is used to be stateless: if the client + sends a wrong barcode, it allows to stay on the last picking with + updated data (and we really want to refresh data because several + users may work on the same transfer). + Transitions: - * deliver: always return here with the data for the last touched picking - or no picking if the picking has been set to done + * deliver: always return here with the data for the last touched + picking or no picking if the picking has been set to done """ - return self._response() + search = self.actions_for("search") + message = self.actions_for("message") + picking = search.stock_picking_from_scan(barcode) + if picking: + if picking.state == "done": + return self._response_for_deliver(message=message.already_done()) + if picking.state not in ("assigned", "partially_available"): + return self._response_for_deliver( + message=message.stock_picking_not_available(picking) + ) + if picking.picking_type_id != self.picking_type: + return self._response_for_deliver( + message=message.cannot_move_something_in_picking_type() + ) + return self._response_for_deliver(picking=picking) + + # We should have only a picking_id because the client was working + # on it already, so no need to validate the picking type + if picking_id: + picking = self.env["stock.picking"].browse(picking_id).exists() + + package = search.package_from_scan(barcode) + if package: + return self._deliver_package(picking, package) + + product = search.product_from_scan(barcode) + if product: + return self._deliver_product(picking, product) + + lot = search.lot_from_scan(barcode) + if lot: + return self._deliver_lot(picking, lot) + + return self._response_for_deliver( + picking=picking, message=message.barcode_not_found() + ) + + def _set_lines_done(self, lines): + for line in lines: + # note: the package level is automatically set to "is_done" when + # the qty_done is full + line.qty_done = line.product_uom_qty + + def _deliver_package(self, picking, package): + message = self.actions_for("message") + lines = package.move_line_ids + lines = lines.filtered( + lambda l: l.state in ("assigned", "partially_available") + and l.picking_id.picking_type_id == self.picking_type + ) + if not lines: + # TODO tests + return self._response_for_deliver( + picking=picking, message=message.cannot_move_something_in_picking_type() + ) + # TODO add a message if any of the lines already had a qty_done > 0 + self._set_lines_done(lines) + new_picking = fields.first(lines.mapped("picking_id")) + return self._response_for_deliver(picking=new_picking) + + def _lines_base_domain(self): + return [ + # we added auto_join for this, otherwise, the ORM would search all pickings + # in the picking type, and then use IN (ids) + ("picking_id.picking_type_id", "=", self.picking_type.id), + ("qty_done", "=", 0), + ] + + def _lines_from_lot_domain(self, product): + return expression.AND( + [self._lines_base_domain(), [("lot_id", "=", product.id)]] + ) + + def _lines_from_product_domain(self, product): + return expression.AND( + [self._lines_base_domain(), [("product_id", "=", product.id)]] + ) + + def _deliver_product(self, picking, product): + message = self.actions_for("message") + if product.tracking in ("lot", "serial"): + # TODO test + return self._response_deliver( + picking, message=message.scan_lot_on_product_tracked_by_lot() + ) + + lines = self.env["stock.move.line"].search( + self._lines_from_product_domain(product) + ) + if not lines: + # TODO not found + pass + + new_picking = fields.first(lines.mapped("picking_id")) + # When products are as units outside of packages, we can select them for + # packing, but if they are in a package, we want the user to scan the packages. + # If the product is only in one package though, scanning the product selects + # the package. + packages = lines.mapped("package_id") + # Do not use mapped here: we want to see if we have more than one package, + # but also if we have one product as a package and the same product as + # a unit in another line. In both cases, we want the user to scan the + # package. + if packages and len({l.package_id for l in lines}) > 1: + return self._response_for_deliver( + new_picking, message=message.product_multiple_packages_scan_package() + ) + elif packages: + # we have 1 package + # TODO if the package contain more than one product, set them to moved + # as well or forbid it (maybe could be done in _set_lines_done) + pass + self._set_lines_done(lines) + return self._response_for_deliver(new_picking) + + def _deliver_lot(self, picking, lot): + message = self.actions_for("message") + lines = self.env["stock.move.line"].search(self._lines_from_lot_domain(lot)) + if not lines: + # TODO not found + pass + + new_picking = fields.first(lines.mapped("picking_id")) + + # When lots are as units outside of packages, we can select them for + # packing, but if they are in a package, we want the user to scan the packages. + # If the product is only in one package though, scanning the lot selects + # the package. + packages = lines.mapped("package_id") + # Do not use mapped here: we want to see if we have more than one + # package, but also if we have one lot as a package and the same lot as + # a unit in another line. In both cases, we want the user to scan the + # package. + if packages and len({l.package_id for l in lines}) > 1: + return self._response_for_deliver( + new_picking, message=message.lot_multiple_packages_scan_package() + ) + elif packages: + # we have 1 package + # TODO if the package contain more than one product, set them to moved + # as well or forbid it (maybe could be done in _set_lines_done) + pass + + self._set_lines_done(lines) + return self._response_for_deliver(new_picking) + + def _response_for_deliver(self, picking=None, message=None): + """Transition to the 'deliver' state + + If no picking is passed, the screen shows an empty screen + """ + return self._response( + next_state="deliver", + data={ + "picking": self._data_for_stock_picking(picking) if picking else None + }, + message=message, + ) def list_stock_picking(self): """Return the list of stock pickings for the picking type @@ -143,7 +316,15 @@ class ShopfloorDeliveryValidator(Component): _usage = "delivery.validator" def scan_deliver(self): - return {"barcode": {"required": True, "type": "string"}} + return { + "barcode": {"required": True, "type": "string"}, + "picking_id": { + "coerce": to_int, + "required": False, + "nullable": True, + "type": "integer", + }, + } def list_stock_picking(self): return {} @@ -211,7 +392,7 @@ def _schema_deliver(self): } } ) - return {"picking": {"type": "dict", "required": False, "schema": schema}} + return {"picking": {"type": "dict", "nullable": True, "schema": schema}} @property def _schema_selection_list(self): diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 640b06ae81..7e1381878b 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -29,3 +29,4 @@ from . import test_checkout_cancel_line from . import test_checkout_done from . import test_delivery_base +from . import test_delivery_scan_deliver diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 1f83cde1eb..97806a081f 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -231,6 +231,18 @@ def setUpClassBaseData(cls): } ) ) + cls.product_e = cls.env["product.product"].create( + { + "name": "Product E", + "type": "product", + "default_code": "E", + "barcode": "E", + "weight": 3, + } + ) + cls.product_e_packaging = cls.env["product.packaging"].create( + {"name": "Box", "product_id": cls.product_d.id, "barcode": "ProductEBox"} + ) def assert_response( self, response, next_state=None, message=None, data=None, popup=None diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py index a96ff00841..3a6fdae03e 100644 --- a/shopfloor/tests/test_delivery_base.py +++ b/shopfloor/tests/test_delivery_base.py @@ -19,3 +19,14 @@ def setUp(self): super().setUp() with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="delivery") + + def _stock_picking_data(self, picking): + return self.service._data_for_stock_picking(picking) + + def assert_response_deliver(self, response, picking=None, message=None): + self.assert_response( + response, + next_state="deliver", + data={"picking": self._stock_picking_data(picking) if picking else None}, + message=message, + ) diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py new file mode 100644 index 0000000000..d02c16ae41 --- /dev/null +++ b/shopfloor/tests/test_delivery_scan_deliver.py @@ -0,0 +1,145 @@ +from .test_delivery_base import DeliveryCommonCase + + +class DeliveryScanDeliverCase(DeliveryCommonCase): + """Tests for /scan_deliver""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.product_e.tracking = "lot" + cls.picking = picking = cls._create_picking( + lines=[ + # we'll put A and B in a single package + (cls.product_a, 10), + (cls.product_b, 10), + # C alone in a package + (cls.product_c, 10), + # D as raw product + (cls.product_d, 10), + # E as raw product with a lot + (cls.product_e, 10), + ] + ) + cls.pack1_moves = picking.move_lines[:2] + cls.pack2_move = picking.move_lines[2] + cls.raw_move = picking.move_lines[3] + cls.raw_lot_move = picking.move_lines[4] + cls._fill_stock_for_moves(cls.pack1_moves, in_package=True) + cls._fill_stock_for_moves(cls.pack2_move, in_package=True) + cls._fill_stock_for_moves(cls.raw_move) + cls._fill_stock_for_moves(cls.raw_lot_move, in_lot=True) + picking.action_assign() + + def test_scan_deliver_scan_picking_ok(self): + response = self.service.dispatch( + "scan_deliver", params={"barcode": self.picking.name, "picking_id": None} + ) + self.assert_response_deliver(response, picking=self.picking) + + def test_scan_deliver_error_barcode_not_found(self): + response = self.service.dispatch( + "scan_deliver", params={"barcode": "NO VALID BARCODE", "picking_id": None} + ) + self.assert_response_deliver( + response, message={"message_type": "error", "message": "Barcode not found"} + ) + + def test_scan_deliver_error_barcode_not_found_keep_picking(self): + response = self.service.dispatch( + "scan_deliver", + params={"barcode": "NO VALID BARCODE", "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + # if the client was working on a picking (it sends picking_id, then + # send refreshed data) + picking=self.picking, + message={"message_type": "error", "message": "Barcode not found"}, + ) + + def assert_qty_done(self, move_lines): + self.assertRecordValues( + move_lines, [{"qty_done": line.product_uom_qty} for line in move_lines] + ) + package_level = move_lines.package_level_id + if package_level: + # we have a package level only when there is a package + self.assertRecordValues(package_level, [{"is_done": True}]) + + def _test_scan_set_done_ok(self, move_lines, barcode): + response = self.service.dispatch("scan_deliver", params={"barcode": barcode}) + self.assert_qty_done(move_lines) + self.assert_response_deliver(response, picking=self.picking) + + def test_scan_deliver_scan_package_ok(self): + move_lines = self.pack1_moves.mapped("move_line_ids") + package = move_lines.mapped("package_id") + self._test_scan_set_done_ok(move_lines, package.name) + + def test_scan_deliver_scan_product_in_package_ok(self): + self._test_scan_set_done_ok( + self.pack2_move.mapped("move_line_ids"), self.product_c.barcode + ) + + def test_scan_deliver_scan_raw_product_ok(self): + self._test_scan_set_done_ok( + self.raw_move.mapped("move_line_ids"), self.product_d.barcode + ) + + def test_scan_deliver_scan_lot_ok(self): + move_lines = self.raw_lot_move.move_line_ids + lot = move_lines.lot_id + self._test_scan_set_done_ok(move_lines, lot.name) + + # TODO test for product in different packages + # TODO test for product in one package but the package contains a product + # in different packages + + +class DeliveryScanDeliverSpecialCase(DeliveryCommonCase): + """Special cases with different setup for /scan_deliver""" + + def test_scan_deliver_error_picking_wrong_type(self): + picking = self._create_picking( + picking_type=self.wh.out_type_id, lines=[(self.product_a, 10)] + ) + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + response = self.service.dispatch( + "scan_deliver", params={"barcode": picking.name} + ) + self.assert_response_deliver( + response, + message={ + "message_type": "error", + "message": "You cannot move this using this menu.", + }, + ) + + def test_scan_deliver_error_picking_unavailable(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + response = self.service.dispatch( + "scan_deliver", params={"barcode": picking.name} + ) + self.assert_response_deliver( + response, + message={ + "message_type": "error", + "message": "Transfer {} is not available.".format(picking.name), + }, + ) + + def test_scan_deliver_error_picking_already_done(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + picking.move_line_ids.qty_done = picking.move_line_ids.product_uom_qty + picking.action_done() + response = self.service.dispatch( + "scan_deliver", params={"barcode": picking.name} + ) + self.assert_response_deliver( + response, + message={"message_type": "info", "message": "Operation already processed."}, + ) From c61a75fc2be6e88986dad2a3df79f088c395ba9b Mon Sep 17 00:00:00 2001 From: sebalix Date: Wed, 27 May 2020 15:36:30 +0200 Subject: [PATCH 245/940] delivery: update code to fit last changes --- shopfloor/services/delivery.py | 75 +++++++++++-------- shopfloor/tests/common.py | 12 --- shopfloor/tests/test_delivery_base.py | 24 ++++++ shopfloor/tests/test_delivery_scan_deliver.py | 14 ++-- 4 files changed, 73 insertions(+), 52 deletions(-) diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 45143f373e..8d16752070 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -33,11 +33,35 @@ class Delivery(Component): _usage = "delivery" _description = __doc__ + @property + def data_struct(self): + return self.actions_for("data") + + @property + def msg_store(self): + return self.actions_for("message") + + def _response_for_deliver(self, picking=None, message=None): + """Transition to the 'deliver' state + + If no picking is passed, the screen shows an empty screen + """ + return self._response( + next_state="deliver", + data={ + "picking": self._data_for_stock_picking(picking) if picking else None + }, + message=message, + ) + def _data_for_stock_picking(self, picking): - data_struct = self.actions_for("data") - data = data_struct.picking_summary(picking) + data = self.data_struct.picking(picking) data.update( - {"move_lines": [data_struct.move_line(ml) for ml in picking.move_line_ids]} + { + "move_lines": [ + self.data_struct.move_line(ml) for ml in picking.move_line_ids + ] + } ) return data @@ -75,18 +99,17 @@ def scan_deliver(self, barcode, picking_id=None): picking or no picking if the picking has been set to done """ search = self.actions_for("search") - message = self.actions_for("message") - picking = search.stock_picking_from_scan(barcode) + picking = search.picking_from_scan(barcode) if picking: if picking.state == "done": - return self._response_for_deliver(message=message.already_done()) + return self._response_for_deliver(message=self.msg_store.already_done()) if picking.state not in ("assigned", "partially_available"): return self._response_for_deliver( - message=message.stock_picking_not_available(picking) + message=self.msg_store.stock_picking_not_available(picking) ) - if picking.picking_type_id != self.picking_type: + if picking.picking_type_id not in self.picking_types: return self._response_for_deliver( - message=message.cannot_move_something_in_picking_type() + message=self.msg_store.cannot_move_something_in_picking_type() ) return self._response_for_deliver(picking=picking) @@ -108,7 +131,7 @@ def scan_deliver(self, barcode, picking_id=None): return self._deliver_lot(picking, lot) return self._response_for_deliver( - picking=picking, message=message.barcode_not_found() + picking=picking, message=self.msg_store.barcode_not_found() ) def _set_lines_done(self, lines): @@ -118,16 +141,16 @@ def _set_lines_done(self, lines): line.qty_done = line.product_uom_qty def _deliver_package(self, picking, package): - message = self.actions_for("message") lines = package.move_line_ids lines = lines.filtered( lambda l: l.state in ("assigned", "partially_available") - and l.picking_id.picking_type_id == self.picking_type + and l.picking_id.picking_type_id in self.picking_types ) if not lines: # TODO tests return self._response_for_deliver( - picking=picking, message=message.cannot_move_something_in_picking_type() + picking=picking, + message=self.msg_store.cannot_move_something_in_picking_type(), ) # TODO add a message if any of the lines already had a qty_done > 0 self._set_lines_done(lines) @@ -138,7 +161,7 @@ def _lines_base_domain(self): return [ # we added auto_join for this, otherwise, the ORM would search all pickings # in the picking type, and then use IN (ids) - ("picking_id.picking_type_id", "=", self.picking_type.id), + ("picking_id.picking_type_id", "in", self.picking_types.ids), ("qty_done", "=", 0), ] @@ -153,11 +176,10 @@ def _lines_from_product_domain(self, product): ) def _deliver_product(self, picking, product): - message = self.actions_for("message") if product.tracking in ("lot", "serial"): # TODO test - return self._response_deliver( - picking, message=message.scan_lot_on_product_tracked_by_lot() + return self._response_for_deliver( + picking, message=self.msg_store.scan_lot_on_product_tracked_by_lot() ) lines = self.env["stock.move.line"].search( @@ -179,7 +201,8 @@ def _deliver_product(self, picking, product): # package. if packages and len({l.package_id for l in lines}) > 1: return self._response_for_deliver( - new_picking, message=message.product_multiple_packages_scan_package() + new_picking, + message=self.msg_store.product_multiple_packages_scan_package(), ) elif packages: # we have 1 package @@ -190,7 +213,6 @@ def _deliver_product(self, picking, product): return self._response_for_deliver(new_picking) def _deliver_lot(self, picking, lot): - message = self.actions_for("message") lines = self.env["stock.move.line"].search(self._lines_from_lot_domain(lot)) if not lines: # TODO not found @@ -209,7 +231,7 @@ def _deliver_lot(self, picking, lot): # package. if packages and len({l.package_id for l in lines}) > 1: return self._response_for_deliver( - new_picking, message=message.lot_multiple_packages_scan_package() + new_picking, message=self.msg_store.lot_multiple_packages_scan_package() ) elif packages: # we have 1 package @@ -220,19 +242,6 @@ def _deliver_lot(self, picking, lot): self._set_lines_done(lines) return self._response_for_deliver(new_picking) - def _response_for_deliver(self, picking=None, message=None): - """Transition to the 'deliver' state - - If no picking is passed, the screen shows an empty screen - """ - return self._response( - next_state="deliver", - data={ - "picking": self._data_for_stock_picking(picking) if picking else None - }, - message=message, - ) - def list_stock_picking(self): """Return the list of stock pickings for the picking type diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 97806a081f..1f83cde1eb 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -231,18 +231,6 @@ def setUpClassBaseData(cls): } ) ) - cls.product_e = cls.env["product.product"].create( - { - "name": "Product E", - "type": "product", - "default_code": "E", - "barcode": "E", - "weight": 3, - } - ) - cls.product_e_packaging = cls.env["product.packaging"].create( - {"name": "Box", "product_id": cls.product_d.id, "barcode": "ProductEBox"} - ) def assert_response( self, response, next_state=None, message=None, data=None, popup=None diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py index 3a6fdae03e..8fb4e27169 100644 --- a/shopfloor/tests/test_delivery_base.py +++ b/shopfloor/tests/test_delivery_base.py @@ -14,6 +14,30 @@ def setUpClassVars(cls, *args, **kwargs): def setUpClassBaseData(cls, *args, **kwargs): super().setUpClassBaseData(*args, **kwargs) cls.wh.sudo().delivery_steps = "pick_pack_ship" + cls.product_e = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product E", + "type": "product", + "default_code": "E", + "barcode": "E", + "weight": 3, + } + ) + ) + cls.product_e_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box", + "product_id": cls.product_e.id, + "barcode": "ProductEBox", + } + ) + ) def setUp(self): super().setUp() diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py index d02c16ae41..12445e7538 100644 --- a/shopfloor/tests/test_delivery_scan_deliver.py +++ b/shopfloor/tests/test_delivery_scan_deliver.py @@ -5,8 +5,8 @@ class DeliveryScanDeliverCase(DeliveryCommonCase): """Tests for /scan_deliver""" @classmethod - def setUpClass(cls): - super().setUpClass() + def setUpClassBaseData(cls): + super().setUpClassBaseData() cls.product_e.tracking = "lot" cls.picking = picking = cls._create_picking( lines=[ @@ -42,7 +42,7 @@ def test_scan_deliver_error_barcode_not_found(self): "scan_deliver", params={"barcode": "NO VALID BARCODE", "picking_id": None} ) self.assert_response_deliver( - response, message={"message_type": "error", "message": "Barcode not found"} + response, message={"message_type": "error", "body": "Barcode not found"} ) def test_scan_deliver_error_barcode_not_found_keep_picking(self): @@ -55,7 +55,7 @@ def test_scan_deliver_error_barcode_not_found_keep_picking(self): # if the client was working on a picking (it sends picking_id, then # send refreshed data) picking=self.picking, - message={"message_type": "error", "message": "Barcode not found"}, + message={"message_type": "error", "body": "Barcode not found"}, ) def assert_qty_done(self, move_lines): @@ -113,7 +113,7 @@ def test_scan_deliver_error_picking_wrong_type(self): response, message={ "message_type": "error", - "message": "You cannot move this using this menu.", + "body": "You cannot move this using this menu.", }, ) @@ -126,7 +126,7 @@ def test_scan_deliver_error_picking_unavailable(self): response, message={ "message_type": "error", - "message": "Transfer {} is not available.".format(picking.name), + "body": "Transfer {} is not available.".format(picking.name), }, ) @@ -141,5 +141,5 @@ def test_scan_deliver_error_picking_already_done(self): ) self.assert_response_deliver( response, - message={"message_type": "info", "message": "Operation already processed."}, + message={"message_type": "info", "body": "Operation already processed."}, ) From a886f03880a96c868f67e713cc1365c121a174b2 Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 28 May 2020 11:18:38 +0200 Subject: [PATCH 246/940] delivery: complete 'scan_deliver' endpoint and improve test coverage --- shopfloor/actions/message.py | 36 +++++- shopfloor/services/delivery.py | 36 +++--- shopfloor/tests/test_delivery_base.py | 24 ++++ shopfloor/tests/test_delivery_scan_deliver.py | 106 +++++++++++++++++- 4 files changed, 182 insertions(+), 20 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 30bc8e10fd..9454fdd7f5 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -209,7 +209,35 @@ def product_multiple_packages_scan_package(self): return { "message_type": "warning", "body": _( - "This product is part of multiple packages, please scan a package." + _("This product is part of multiple packages, please scan a package.") + ), + } + + def product_mixed_package_scan_package(self): + return { + "message_type": "warning", + "body": _( + _( + "This product is part of a package with other products, " + "please scan a package." + ) + ), + } + + def product_not_found_in_pickings(self): + return { + "message_type": "warning", + "body": _("No product found among current transfers."), + } + + def lot_mixed_package_scan_package(self): + return { + "message_type": "warning", + "body": _( + _( + "This lot is part of a package with other products, " + "please scan a package." + ) ), } @@ -219,6 +247,12 @@ def lot_multiple_packages_scan_package(self): "body": _("This lot is part of multiple packages, please scan a package."), } + def lot_not_found_in_pickings(self): + return { + "message_type": "warning", + "body": _("No lot found among current transfers."), + } + def batch_transfer_complete(self): return { "message_type": "success", diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 8d16752070..d370fdc6e4 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -147,7 +147,6 @@ def _deliver_package(self, picking, package): and l.picking_id.picking_type_id in self.picking_types ) if not lines: - # TODO tests return self._response_for_deliver( picking=picking, message=self.msg_store.cannot_move_something_in_picking_type(), @@ -165,10 +164,8 @@ def _lines_base_domain(self): ("qty_done", "=", 0), ] - def _lines_from_lot_domain(self, product): - return expression.AND( - [self._lines_base_domain(), [("lot_id", "=", product.id)]] - ) + def _lines_from_lot_domain(self, lot): + return expression.AND([self._lines_base_domain(), [("lot_id", "=", lot.id)]]) def _lines_from_product_domain(self, product): return expression.AND( @@ -177,7 +174,6 @@ def _lines_from_product_domain(self, product): def _deliver_product(self, picking, product): if product.tracking in ("lot", "serial"): - # TODO test return self._response_for_deliver( picking, message=self.msg_store.scan_lot_on_product_tracked_by_lot() ) @@ -186,8 +182,9 @@ def _deliver_product(self, picking, product): self._lines_from_product_domain(product) ) if not lines: - # TODO not found - pass + return self._response_for_deliver( + picking, message=self.msg_store.product_not_found_in_pickings() + ) new_picking = fields.first(lines.mapped("picking_id")) # When products are as units outside of packages, we can select them for @@ -206,17 +203,21 @@ def _deliver_product(self, picking, product): ) elif packages: # we have 1 package - # TODO if the package contain more than one product, set them to moved - # as well or forbid it (maybe could be done in _set_lines_done) - pass + # abort the operation if the package contain more than one product + if len(packages.mapped("quant_ids.product_id")) > 1: + return self._response_for_deliver( + new_picking, + message=self.msg_store.product_mixed_package_scan_package(), + ) self._set_lines_done(lines) return self._response_for_deliver(new_picking) def _deliver_lot(self, picking, lot): lines = self.env["stock.move.line"].search(self._lines_from_lot_domain(lot)) if not lines: - # TODO not found - pass + return self._response_for_deliver( + picking, message=self.msg_store.lot_not_found_in_pickings() + ) new_picking = fields.first(lines.mapped("picking_id")) @@ -235,9 +236,12 @@ def _deliver_lot(self, picking, lot): ) elif packages: # we have 1 package - # TODO if the package contain more than one product, set them to moved - # as well or forbid it (maybe could be done in _set_lines_done) - pass + # abort the operation if the package contain more than one product + if len(packages.quant_ids) > 1: + return self._response_for_deliver( + new_picking, + message=self.msg_store.lot_mixed_package_scan_package(), + ) self._set_lines_done(lines) return self._response_for_deliver(new_picking) diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py index 8fb4e27169..7864982565 100644 --- a/shopfloor/tests/test_delivery_base.py +++ b/shopfloor/tests/test_delivery_base.py @@ -38,6 +38,30 @@ def setUpClassBaseData(cls, *args, **kwargs): } ) ) + cls.product_f = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product F", + "type": "product", + "default_code": "F", + "barcode": "F", + "weight": 3, + } + ) + ) + cls.product_f_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box", + "product_id": cls.product_f.id, + "barcode": "ProductFBox", + } + ) + ) def setUp(self): super().setUp() diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py index 12445e7538..024feebd01 100644 --- a/shopfloor/tests/test_delivery_scan_deliver.py +++ b/shopfloor/tests/test_delivery_scan_deliver.py @@ -19,17 +19,53 @@ def setUpClassBaseData(cls): (cls.product_d, 10), # E as raw product with a lot (cls.product_e, 10), + # F in two different packages + (cls.product_f, 10), ] ) cls.pack1_moves = picking.move_lines[:2] cls.pack2_move = picking.move_lines[2] + cls.pack3_move = picking.move_lines[5] cls.raw_move = picking.move_lines[3] cls.raw_lot_move = picking.move_lines[4] cls._fill_stock_for_moves(cls.pack1_moves, in_package=True) cls._fill_stock_for_moves(cls.pack2_move, in_package=True) cls._fill_stock_for_moves(cls.raw_move) cls._fill_stock_for_moves(cls.raw_lot_move, in_lot=True) + # Set a lot for A for the mixed package (A + B) + cls.product_a_lot = cls.env["stock.production.lot"].create( + {"product_id": cls.product_a.id, "company_id": cls.env.company.id} + ) + cls.product_a_quant = cls.env["stock.quant"].search( + [("product_id", "=", cls.product_a.id)] + ) + cls.product_a_quant.sudo().lot_id = cls.product_a_lot + # Fill stock for F moves (two packages) + for __ in range(2): + product_f_pkg = cls.env["stock.quant.package"].create({}) + cls._update_qty_in_location( + cls.pack3_move.location_id, + cls.pack3_move.product_id, + 5, + package=product_f_pkg, + ) picking.action_assign() + # Some records not related at all to the processed picking + cls.free_package = cls.env["stock.quant.package"].create( + {"name": "FREE_PACKAGE"} + ) + cls.free_lot = cls.env["stock.production.lot"].create( + { + "name": "FREE_LOT", + "product_id": cls.product_a.id, + "company_id": cls.env.company.id, + } + ) + cls.free_product = ( + cls.env["product.product"] + .sudo() + .create({"name": "FREE_PRODUCT", "barcode": "FREE_PRODUCT"}) + ) def test_scan_deliver_scan_picking_ok(self): response = self.service.dispatch( @@ -77,24 +113,88 @@ def test_scan_deliver_scan_package_ok(self): package = move_lines.mapped("package_id") self._test_scan_set_done_ok(move_lines, package.name) + def test_scan_deliver_scan_package_no_move_lines(self): + response = self.service.dispatch( + "scan_deliver", + params={"barcode": self.free_package.name, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.cannot_move_something_in_picking_type(), + ) + def test_scan_deliver_scan_product_in_package_ok(self): self._test_scan_set_done_ok( self.pack2_move.mapped("move_line_ids"), self.product_c.barcode ) + def test_scan_deliver_scan_product_in_multiple_packages(self): + response = self.service.dispatch( + "scan_deliver", + params={"barcode": self.product_f.barcode, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.product_multiple_packages_scan_package(), + ) + + def test_scan_deliver_scan_product_in_mixed_package(self): + response = self.service.dispatch( + "scan_deliver", + params={"barcode": self.product_a.barcode, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.product_mixed_package_scan_package(), + ) + + def test_scan_deliver_scan_product_tracked_by_lot(self): + response = self.service.dispatch( + "scan_deliver", + params={"barcode": self.product_e.barcode, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.scan_lot_on_product_tracked_by_lot(), + ) + def test_scan_deliver_scan_raw_product_ok(self): self._test_scan_set_done_ok( self.raw_move.mapped("move_line_ids"), self.product_d.barcode ) + def test_scan_deliver_scan_product_not_found(self): + response = self.service.dispatch( + "scan_deliver", params={"barcode": self.free_product.barcode} + ) + self.assert_response_deliver( + response, message=self.service.msg_store.product_not_found_in_pickings(), + ) + def test_scan_deliver_scan_lot_ok(self): move_lines = self.raw_lot_move.move_line_ids lot = move_lines.lot_id self._test_scan_set_done_ok(move_lines, lot.name) - # TODO test for product in different packages - # TODO test for product in one package but the package contains a product - # in different packages + def test_scan_deliver_scan_lot_not_found(self): + response = self.service.dispatch("scan_deliver", params={"barcode": "FREE_LOT"}) + self.assert_response_deliver( + response, message=self.service.msg_store.lot_not_found_in_pickings(), + ) + + def test_scan_deliver_scan_lot_in_mixed_package(self): + response = self.service.dispatch( + "scan_deliver", params={"barcode": self.product_a_lot.name} + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.lot_mixed_package_scan_package(), + ) class DeliveryScanDeliverSpecialCase(DeliveryCommonCase): From 8eb63cc8075d2a6390772eccfe1fec253f46e3bd Mon Sep 17 00:00:00 2001 From: sebalix Date: Fri, 29 May 2020 16:17:48 +0200 Subject: [PATCH 247/940] delivery: implement 'set_qty_done_pack' endpoint --- shopfloor/actions/message.py | 6 + shopfloor/services/delivery.py | 64 +++++++-- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_delivery_base.py | 23 +++ shopfloor/tests/test_delivery_scan_deliver.py | 9 -- .../tests/test_delivery_set_qty_done_pack.py | 133 ++++++++++++++++++ 6 files changed, 216 insertions(+), 20 deletions(-) create mode 100644 shopfloor/tests/test_delivery_set_qty_done_pack.py diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 9454fdd7f5..ddb9f7c712 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -75,6 +75,12 @@ def stock_picking_not_found(self): "body": _("This transfer does not exist anymore."), } + def package_not_found(self): + return { + "message_type": "error", + "body": _("This package does not exist anymore."), + } + def record_not_found(self): return { "message_type": "error", diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index d370fdc6e4..1bfacf1ac1 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -65,6 +65,26 @@ def _data_for_stock_picking(self, picking): ) return data + def _check_picking_status(self, picking): + """Check if `picking` can be processed. + + If the picking is already done, canceled or didn't belong to the + expected picking type, a response is returned. + + Transitions: + * deliver: always return here with updated data + """ + if picking.state == "done": + return self._response_for_deliver(message=self.msg_store.already_done()) + if picking.state not in ("assigned", "partially_available"): + return self._response_for_deliver( + message=self.msg_store.stock_picking_not_available(picking) + ) + if picking.picking_type_id not in self.picking_types: + return self._response_for_deliver( + message=self.msg_store.cannot_move_something_in_picking_type() + ) + def scan_deliver(self, barcode, picking_id=None): """Scan a stock picking or a package/product/lot @@ -101,16 +121,9 @@ def scan_deliver(self, barcode, picking_id=None): search = self.actions_for("search") picking = search.picking_from_scan(barcode) if picking: - if picking.state == "done": - return self._response_for_deliver(message=self.msg_store.already_done()) - if picking.state not in ("assigned", "partially_available"): - return self._response_for_deliver( - message=self.msg_store.stock_picking_not_available(picking) - ) - if picking.picking_type_id not in self.picking_types: - return self._response_for_deliver( - message=self.msg_store.cannot_move_something_in_picking_type() - ) + response = self._check_picking_status(picking) + if response: + return response return self._response_for_deliver(picking=picking) # We should have only a picking_id because the client was working @@ -246,6 +259,19 @@ def _deliver_lot(self, picking, lot): self._set_lines_done(lines) return self._response_for_deliver(new_picking) + def _action_picking_done(self, picking): + """Try to validate the stock picking if all its lines have been processed. + + Return `True` if the picking has been validated successfully. + """ + move_lines_done = all( + [line.qty_done >= line.product_uom_qty for line in picking.move_line_ids] + ) + if move_lines_done: + picking.action_done() + return True + return False + def list_stock_picking(self): """Return the list of stock pickings for the picking type @@ -276,7 +302,23 @@ def set_qty_done_pack(self, picking_id, package_id): Transitions: * deliver: always return here with updated data """ - return self._response() + picking = self.env["stock.picking"].browse(picking_id).exists() + if picking: + response = self._check_picking_status(picking) + if response: + return response + else: + return self._response_for_deliver( + message=self.msg_store.stock_picking_not_found() + ) + package = self.env["stock.quant.package"].browse(package_id).exists() + if package: + response = self._deliver_package(picking, package) + self._action_picking_done(picking) + return response + return self._response_for_deliver( + picking=picking, message=self.msg_store.package_not_found() + ) def set_qty_done_line(self, picking_id, line_id): """Set a move line to "Done" diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 7e1381878b..5a24485bdf 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -30,3 +30,4 @@ from . import test_checkout_done from . import test_delivery_base from . import test_delivery_scan_deliver +from . import test_delivery_set_qty_done_pack diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py index 7864982565..36f9225190 100644 --- a/shopfloor/tests/test_delivery_base.py +++ b/shopfloor/tests/test_delivery_base.py @@ -78,3 +78,26 @@ def assert_response_deliver(self, response, picking=None, message=None): data={"picking": self._stock_picking_data(picking) if picking else None}, message=message, ) + + def assert_qty_done(self, move_lines, qties=None): + """Ensure that the quantities done are the expected ones. + + If `qties` is not defined, the expected qties are `product_uom_qty` + of the move lines. + `qties` parameter is a list of move lines qty (same order). + """ + if qties: + assert len(move_lines) == len(qties), "'qties' doesn't match 'move_lines'" + expected_qties = [] + for qty in qties: + expected_qties.append({"qty_done": qty}) + else: + expected_qties = [{"qty_done": line.product_uom_qty} for line in move_lines] + self.assertRecordValues(move_lines, expected_qties) + package_level = move_lines.package_level_id + if package_level: + values = [{"is_done": True}] + if qties: + values = [{"is_done": bool(qty)} for qty in qties] + # we have a package level only when there is a package + self.assertRecordValues(package_level, values) diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py index 024feebd01..e75e285201 100644 --- a/shopfloor/tests/test_delivery_scan_deliver.py +++ b/shopfloor/tests/test_delivery_scan_deliver.py @@ -94,15 +94,6 @@ def test_scan_deliver_error_barcode_not_found_keep_picking(self): message={"message_type": "error", "body": "Barcode not found"}, ) - def assert_qty_done(self, move_lines): - self.assertRecordValues( - move_lines, [{"qty_done": line.product_uom_qty} for line in move_lines] - ) - package_level = move_lines.package_level_id - if package_level: - # we have a package level only when there is a package - self.assertRecordValues(package_level, [{"is_done": True}]) - def _test_scan_set_done_ok(self, move_lines, barcode): response = self.service.dispatch("scan_deliver", params={"barcode": barcode}) self.assert_qty_done(move_lines) diff --git a/shopfloor/tests/test_delivery_set_qty_done_pack.py b/shopfloor/tests/test_delivery_set_qty_done_pack.py new file mode 100644 index 0000000000..aaee4f9a6d --- /dev/null +++ b/shopfloor/tests/test_delivery_set_qty_done_pack.py @@ -0,0 +1,133 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_delivery_base import DeliveryCommonCase + + +class DeliverySetQtyDonePackCase(DeliveryCommonCase): + """Tests for /set_qty_done_pack""" + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = picking = cls._create_picking( + lines=[ + # we'll put A and B in a single package + (cls.product_a, 10), + (cls.product_b, 10), + # C alone in a package + (cls.product_c, 10), + # D in two different packages + (cls.product_d, 10), + ] + ) + cls.pack1_moves = picking.move_lines[:2] + cls.pack2_move = picking.move_lines[2] + cls.pack3_move = picking.move_lines[3] + cls._fill_stock_for_moves(cls.pack1_moves, in_package=True) + cls._fill_stock_for_moves(cls.pack2_move, in_package=True) + # Fill stock for D moves (two packages) + for __ in range(2): + product_d_pkg = cls.env["stock.quant.package"].create({}) + cls._update_qty_in_location( + cls.pack3_move.location_id, + cls.pack3_move.product_id, + 5, + package=product_d_pkg, + ) + picking.action_assign() + + def _test_set_qty_done_pack_ok(self, move_lines, package, qties=None): + response = self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package.id, "picking_id": self.picking.id}, + ) + self.assert_qty_done(move_lines, qties=qties) + self.assert_response_deliver(response, picking=self.picking) + + def test_set_qty_done_pack_picking_not_found(self): + move_lines = self.pack1_moves.mapped("move_line_ids") + package = move_lines.mapped("package_id") + response = self.service.dispatch( + "set_qty_done_pack", params={"package_id": package.id, "picking_id": -1} + ) + self.assert_response_deliver( + response, message=self.service.msg_store.stock_picking_not_found() + ) + + def test_set_qty_done_pack_picking_canceled(self): + move_lines = self.pack1_moves.mapped("move_line_ids") + package = move_lines.mapped("package_id") + self.picking.action_cancel() + response = self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + message=self.service.msg_store.stock_picking_not_available(self.picking), + ) + + def test_set_qty_done_pack_package_not_found(self): + response = self.service.dispatch( + "set_qty_done_pack", + params={"package_id": -1, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.package_not_found(), + ) + + def test_set_qty_done_pack_multiple_product_ok(self): + move_lines = self.pack1_moves.mapped("move_line_ids") + package = move_lines.mapped("package_id") + self._test_set_qty_done_pack_ok(move_lines, package) + + def test_set_qty_done_pack_one_product_ok(self): + move_lines = self.pack2_move.mapped("move_line_ids") + package = move_lines.mapped("package_id") + self._test_set_qty_done_pack_ok(move_lines, package) + + def test_set_qty_done_pack_product_in_multiple_packages_ok(self): + move_lines = self.pack3_move.mapped("move_line_ids") + first_package = move_lines.mapped("package_id")[0] + self._test_set_qty_done_pack_ok( + move_lines, + # first_package done, not the second + first_package, + qties=[5, 0], + ) + + def test_set_qty_done_pack_picking_done(self): + pack1_move_lines = self.pack1_moves.mapped("move_line_ids") + package1 = pack1_move_lines.mapped("package_id") + pack2_move_lines = self.pack2_move.mapped("move_line_ids") + package2 = pack2_move_lines.mapped("package_id") + pack3_move_lines = self.pack3_move.mapped("move_line_ids") + packages3 = pack3_move_lines.mapped("package_id") + # process first package + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package1.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "assigned") + # process second package + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package2.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "assigned") + # process third package + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": packages3[0].id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "assigned") + # process last package + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": packages3[1].id, "picking_id": self.picking.id}, + ) + # picking is done once all its moves have been processed + self.assertEqual(self.picking.state, "done") From 8998779323867aee3e30e7ecd9ca28b94ad838ec Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 1 Jun 2020 13:25:45 +0200 Subject: [PATCH 248/940] delivery: implement 'set_qty_done_line' endpoint --- shopfloor/services/delivery.py | 30 ++++++- shopfloor/tests/__init__.py | 1 + .../tests/test_delivery_set_qty_done_line.py | 89 +++++++++++++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 shopfloor/tests/test_delivery_set_qty_done_line.py diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 1bfacf1ac1..05a237bd21 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -1,4 +1,4 @@ -from odoo import fields +from odoo import _, fields from odoo.osv import expression from odoo.addons.base_rest.components.service import to_int @@ -320,7 +320,7 @@ def set_qty_done_pack(self, picking_id, package_id): picking=picking, message=self.msg_store.package_not_found() ) - def set_qty_done_line(self, picking_id, line_id): + def set_qty_done_line(self, picking_id, move_line_id): """Set a move line to "Done" Should be called only for lines of raw products, /set_qty_done_pack @@ -332,7 +332,31 @@ def set_qty_done_line(self, picking_id, line_id): Transitions: * deliver: always return here with updated data """ - return self._response() + picking = self.env["stock.picking"].browse(picking_id).exists() + if picking: + response = self._check_picking_status(picking) + if response: + return response + else: + return self._response_for_deliver( + message=self.msg_store.stock_picking_not_found() + ) + line = self.env["stock.move.line"].browse(move_line_id).exists() + if line: + if line.package_id: + msg = { + "message_type": "warning", + "body": _( + "This line has a package, please select the package instead." + ), + } + return self._response_for_deliver(picking=picking, message=msg) + self._set_lines_done(line) + self._action_picking_done(picking) + return self._response_for_deliver(picking) + return self._response_for_deliver( + picking=picking, message=self.msg_store.record_not_found(), + ) def reset_qty_done_pack(self, picking_id, package_id): """Remove "Done" on a package diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 5a24485bdf..9ad84048c0 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -31,3 +31,4 @@ from . import test_delivery_base from . import test_delivery_scan_deliver from . import test_delivery_set_qty_done_pack +from . import test_delivery_set_qty_done_line diff --git a/shopfloor/tests/test_delivery_set_qty_done_line.py b/shopfloor/tests/test_delivery_set_qty_done_line.py new file mode 100644 index 0000000000..6b07dc5a16 --- /dev/null +++ b/shopfloor/tests/test_delivery_set_qty_done_line.py @@ -0,0 +1,89 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_delivery_base import DeliveryCommonCase + + +class DeliverySetQtyDoneLineCase(DeliveryCommonCase): + """Tests for /set_qty_done_line""" + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = picking = cls._create_picking( + lines=[ + # put A in a package + (cls.product_a, 10), + # B as raw product + (cls.product_b, 10), + ] + ) + cls.pack1_move = picking.move_lines[0] + cls.raw_move = picking.move_lines[1] + cls._fill_stock_for_moves(cls.pack1_move, in_package=True) + cls._fill_stock_for_moves(cls.raw_move) + picking.action_assign() + + def _test_set_qty_done_line_ok(self, move_line): + response = self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + self.assert_qty_done(move_line) + self.assert_response_deliver(response, picking=self.picking) + + def test_set_qty_done_line_picking_not_found(self): + move_line = self.pack1_move.mapped("move_line_ids") + response = self.service.dispatch( + "set_qty_done_line", params={"move_line_id": move_line.id, "picking_id": -1} + ) + self.assert_response_deliver( + response, message=self.service.msg_store.stock_picking_not_found() + ) + + def test_set_qty_done_line_picking_canceled(self): + move_line = self.pack1_move.mapped("move_line_ids") + self.picking.action_cancel() + response = self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + message=self.service.msg_store.stock_picking_not_available(self.picking), + ) + + def test_set_qty_done_line_line_not_found(self): + response = self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": -1, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.record_not_found(), + ) + + def test_set_qty_done_line_ok(self): + move_line = self.raw_move.mapped("move_line_ids") + self._test_set_qty_done_line_ok(move_line) + # picking is still assigned as only one move line have been processed + self.assertEqual(self.picking.state, "assigned") + + def test_set_qty_done_line_picking_done(self): + # process the first move line with a package + move_line = self.pack1_move.mapped("move_line_ids") + package = move_line.mapped("package_id") + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "assigned") + # process the remaining move line + move_line = self.raw_move.mapped("move_line_ids") + self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + # picking is done once all its moves have been processed + self.assertEqual(self.picking.state, "done") From 36d1b2776d2323ed78bbd08b6b443030aa744da3 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 1 Jun 2020 14:54:36 +0200 Subject: [PATCH 249/940] delivery: implement 'list_stock_picking' endpoint --- shopfloor/services/delivery.py | 53 ++++++++++--------- shopfloor/services/validator.py | 4 ++ shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_delivery_base.py | 2 +- .../tests/test_delivery_list_stock_picking.py | 35 ++++++++++++ 5 files changed, 68 insertions(+), 27 deletions(-) create mode 100644 shopfloor/tests/test_delivery_list_stock_picking.py diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 05a237bd21..97fb9bc5ec 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -35,7 +35,7 @@ class Delivery(Component): @property def data_struct(self): - return self.actions_for("data") + return self.actions_for("data_detail") @property def msg_store(self): @@ -49,21 +49,25 @@ def _response_for_deliver(self, picking=None, message=None): return self._response( next_state="deliver", data={ - "picking": self._data_for_stock_picking(picking) if picking else None + "picking": self.data_struct.picking_detail(picking) if picking else None }, message=message, ) - def _data_for_stock_picking(self, picking): - data = self.data_struct.picking(picking) - data.update( - { - "move_lines": [ - self.data_struct.move_line(ml) for ml in picking.move_line_ids - ] - } + def _response_for_manual_selection(self, pickings, message=None): + """Transition to the 'manual_selection' state + + If no picking is passed, the screen shows an empty screen + """ + return self._response( + next_state="manual_selection", + data={ + "pickings": [ + self.data_struct.picking_detail(picking) for picking in pickings + ], + }, + message=message, ) - return data def _check_picking_status(self, picking): """Check if `picking` can be processed. @@ -273,14 +277,21 @@ def _action_picking_done(self, picking): return False def list_stock_picking(self): - """Return the list of stock pickings for the picking type + """Return the list of stock pickings for the picking types It returns only stock picking available or partially available. Transitions: * manual_selection: next state to show the list of stock pickings """ - return self._response() + pickings = self.env["stock.picking"].search(self._pickings_domain(), order="id") + return self._response_for_manual_selection(pickings) + + def _pickings_domain(self): + return [ + ("picking_type_id", "in", self.picking_types.ids), + ("state", "not in", ["cancel", "done", "waiting", "draft"]), + ] def select(self, picking_id): """Select a stock picking from its ID (found using /list_stock_picking) @@ -462,24 +473,14 @@ def _states(self): @property def _schema_deliver(self): - schema = self.schemas.picking() - schema.update( - { - "move_lines": { - "type": "list", - "schema": {"type": "dict", "schema": self.schemas.move_line()}, - } - } - ) + schema = self.schemas_detail.picking_detail() return {"picking": {"type": "dict", "nullable": True, "schema": schema}} @property def _schema_selection_list(self): + schema = self.schemas_detail.picking_detail() return { - "pickings": { - "type": "list", - "schema": {"type": "dict", "schema": self.schemas.picking()}, - } + "pickings": {"type": "list", "schema": {"type": "dict", "schema": schema}} } def scan_deliver(self): diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py index c484c1514b..8bdf4d75cf 100644 --- a/shopfloor/services/validator.py +++ b/shopfloor/services/validator.py @@ -70,6 +70,10 @@ def _states(self): def schemas(self): return self.component(usage="schema") + @property + def schemas_detail(self): + return self.component(usage="schema_detail") + def _response_schema(self, data_schema=None, next_states=None): """Schema for the return validator diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 9ad84048c0..6e2a597fc7 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -32,3 +32,4 @@ from . import test_delivery_scan_deliver from . import test_delivery_set_qty_done_pack from . import test_delivery_set_qty_done_line +from . import test_delivery_list_stock_picking diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py index 36f9225190..b0e6310de8 100644 --- a/shopfloor/tests/test_delivery_base.py +++ b/shopfloor/tests/test_delivery_base.py @@ -69,7 +69,7 @@ def setUp(self): self.service = work.component(usage="delivery") def _stock_picking_data(self, picking): - return self.service._data_for_stock_picking(picking) + return self.service.data_struct.picking_detail(picking) def assert_response_deliver(self, response, picking=None, message=None): self.assert_response( diff --git a/shopfloor/tests/test_delivery_list_stock_picking.py b/shopfloor/tests/test_delivery_list_stock_picking.py new file mode 100644 index 0000000000..a44ac8b6d3 --- /dev/null +++ b/shopfloor/tests/test_delivery_list_stock_picking.py @@ -0,0 +1,35 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_delivery_base import DeliveryCommonCase + + +class DeliveryListStockPickingCase(DeliveryCommonCase): + """Tests for /list_stock_picking""" + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking2 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + + def assert_response_manual_selection(self, response, pickings=None, message=None): + self.assert_response( + response, + next_state="manual_selection", + data={ + "pickings": [self._stock_picking_data(picking) for picking in pickings] + }, + message=message, + ) + + def test_list_stock_picking_ok(self): + pickings = self.picking1 | self.picking2 + response = self.service.dispatch("list_stock_picking", params={}) + self.assert_response_manual_selection( + response, pickings=pickings, + ) From 7bc18b05750d6ecea386cfb078f2ef1ff51437b8 Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 2 Jun 2020 09:46:16 +0200 Subject: [PATCH 250/940] delivery: implement 'select' endpoint --- shopfloor/services/delivery.py | 10 ++++-- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_delivery_base.py | 10 ++++++ .../tests/test_delivery_list_stock_picking.py | 10 ------ shopfloor/tests/test_delivery_select.py | 33 +++++++++++++++++++ 5 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 shopfloor/tests/test_delivery_select.py diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 97fb9bc5ec..93890db574 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -302,7 +302,13 @@ def select(self, picking_id): * manual_selection: the selected stock picking is no longer valid * deliver: with information about the stock.picking """ - return self._response() + picking = self.env["stock.picking"].browse(picking_id).exists() + if picking: + return self._response_for_deliver(picking) + response = self.list_stock_picking() + return self._response( + response, message=self.msg_store.stock_picking_not_found() + ) def set_qty_done_pack(self, picking_id, package_id): """Set a package to "Done" @@ -490,7 +496,7 @@ def list_stock_picking(self): return self._response_schema(next_states={"manual_selection"}) def select(self): - return self._response_schema(next_states={"deliver"}) + return self._response_schema(next_states={"deliver", "manual_selection"}) def set_qty_done_pack(self): return self._response_schema(next_states={"deliver"}) diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 6e2a597fc7..87bc146904 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -33,3 +33,4 @@ from . import test_delivery_set_qty_done_pack from . import test_delivery_set_qty_done_line from . import test_delivery_list_stock_picking +from . import test_delivery_select diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py index b0e6310de8..1ab4a35fb7 100644 --- a/shopfloor/tests/test_delivery_base.py +++ b/shopfloor/tests/test_delivery_base.py @@ -79,6 +79,16 @@ def assert_response_deliver(self, response, picking=None, message=None): message=message, ) + def assert_response_manual_selection(self, response, pickings=None, message=None): + self.assert_response( + response, + next_state="manual_selection", + data={ + "pickings": [self._stock_picking_data(picking) for picking in pickings] + }, + message=message, + ) + def assert_qty_done(self, move_lines, qties=None): """Ensure that the quantities done are the expected ones. diff --git a/shopfloor/tests/test_delivery_list_stock_picking.py b/shopfloor/tests/test_delivery_list_stock_picking.py index a44ac8b6d3..545a4db435 100644 --- a/shopfloor/tests/test_delivery_list_stock_picking.py +++ b/shopfloor/tests/test_delivery_list_stock_picking.py @@ -17,16 +17,6 @@ def setUpClassBaseData(cls): lines=[(cls.product_a, 10), (cls.product_b, 10)] ) - def assert_response_manual_selection(self, response, pickings=None, message=None): - self.assert_response( - response, - next_state="manual_selection", - data={ - "pickings": [self._stock_picking_data(picking) for picking in pickings] - }, - message=message, - ) - def test_list_stock_picking_ok(self): pickings = self.picking1 | self.picking2 response = self.service.dispatch("list_stock_picking", params={}) diff --git a/shopfloor/tests/test_delivery_select.py b/shopfloor/tests/test_delivery_select.py new file mode 100644 index 0000000000..14b214ef82 --- /dev/null +++ b/shopfloor/tests/test_delivery_select.py @@ -0,0 +1,33 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_delivery_base import DeliveryCommonCase + + +class DeliverySelectCase(DeliveryCommonCase): + """Tests for /select""" + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking2 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + + def test_select_ok(self): + response = self.service.dispatch( + "select", params={"picking_id": self.picking1.id} + ) + self.assert_response_deliver(response, picking=self.picking1) + + def test_select_not_found(self): + pickings = self.picking1 | self.picking2 + response = self.service.dispatch("select", params={"picking_id": -1}) + self.assert_response_manual_selection( + response, + pickings=pickings, + message=self.service.msg_store.stock_picking_not_found(), + ) From 5a088a16f655e3e1a8bd16abf1001fbf115cc132 Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 2 Jun 2020 15:33:57 +0200 Subject: [PATCH 251/940] delivery: implement 'reset_qty_done_pack' endpoint --- shopfloor/actions/message.py | 8 ++ shopfloor/services/delivery.py | 56 ++++++++-- .../test_delivery_reset_qty_done_pack.py | 105 ++++++++++++++++++ 3 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 shopfloor/tests/test_delivery_reset_qty_done_pack.py diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index ddb9f7c712..fce3f88107 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -81,6 +81,14 @@ def package_not_found(self): "body": _("This package does not exist anymore."), } + def package_not_available_in_picking(self, package, picking): + return { + "message_type": "warning", + "body": _("Package {} is not available in transfer {}.").format( + package.name, picking.name + ), + } + def record_not_found(self): return { "message_type": "error", diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 93890db574..982383e16b 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -157,6 +157,12 @@ def _set_lines_done(self, lines): # the qty_done is full line.qty_done = line.product_uom_qty + def _reset_lines(self, lines): + for line in lines: + # note: the package level "is_done" field is automatically unset + # when the qty_done is not full + line.qty_done = 0 + def _deliver_package(self, picking, package): lines = package.move_line_ids lines = lines.filtered( @@ -173,20 +179,29 @@ def _deliver_package(self, picking, package): new_picking = fields.first(lines.mapped("picking_id")) return self._response_for_deliver(picking=new_picking) - def _lines_base_domain(self): - return [ + def _lines_base_domain(self, no_qty_done=True): + domain = [ # we added auto_join for this, otherwise, the ORM would search all pickings # in the picking type, and then use IN (ids) ("picking_id.picking_type_id", "in", self.picking_types.ids), - ("qty_done", "=", 0), ] + if no_qty_done: + domain.append(("qty_done", "=", 0)) + return domain - def _lines_from_lot_domain(self, lot): - return expression.AND([self._lines_base_domain(), [("lot_id", "=", lot.id)]]) + def _lines_from_lot_domain(self, lot, no_qty_done=True): + return expression.AND( + [self._lines_base_domain(no_qty_done), [("lot_id", "=", lot.id)]] + ) + + def _lines_from_product_domain(self, product, no_qty_done=True): + return expression.AND( + [self._lines_base_domain(no_qty_done), [("product_id", "=", product.id)]] + ) - def _lines_from_product_domain(self, product): + def _lines_from_package_domain(self, package, no_qty_done=True): return expression.AND( - [self._lines_base_domain(), [("product_id", "=", product.id)]] + [self._lines_base_domain(no_qty_done), [("package_id", "=", package.id)]] ) def _deliver_product(self, picking, product): @@ -381,7 +396,32 @@ def reset_qty_done_pack(self, picking_id, package_id): Transitions: * deliver: always return here with updated data """ - return self._response() + picking = self.env["stock.picking"].browse(picking_id).exists() + if picking: + response = self._check_picking_status(picking) + if response: + return response + else: + return self._response_for_deliver( + message=self.msg_store.stock_picking_not_found() + ) + package = self.env["stock.quant.package"].browse(package_id).exists() + if package: + lines = self.env["stock.move.line"].search( + self._lines_from_package_domain(package, no_qty_done=False) + ) + if not lines: + return self._response_for_deliver( + picking, + message=self.msg_store.package_not_available_in_picking( + package, picking + ), + ) + self._reset_lines(lines) + return self._response_for_deliver(picking) + return self._response_for_deliver( + picking=picking, message=self.msg_store.package_not_found() + ) def reset_qty_done_line(self, picking_id, line_id): """Remove "Done" on a move line diff --git a/shopfloor/tests/test_delivery_reset_qty_done_pack.py b/shopfloor/tests/test_delivery_reset_qty_done_pack.py new file mode 100644 index 0000000000..99048b6d5c --- /dev/null +++ b/shopfloor/tests/test_delivery_reset_qty_done_pack.py @@ -0,0 +1,105 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_delivery_base import DeliveryCommonCase + + +class DeliveryResetQtyDonePackCase(DeliveryCommonCase): + """Tests for /reset_qty_done_pack""" + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = picking = cls._create_picking( + lines=[ + # we'll put A and B in a single package + (cls.product_a, 10), + (cls.product_b, 10), + # C alone in a package + (cls.product_c, 10), + ] + ) + cls.pack1_moves = picking.move_lines[:2] + cls.pack2_move = picking.move_lines[2] + cls._fill_stock_for_moves(cls.pack1_moves, in_package=True) + cls._fill_stock_for_moves(cls.pack2_move, in_package=True) + picking.action_assign() + # Some records not related at all to the processed picking + cls.free_package = cls.env["stock.quant.package"].create( + {"name": "FREE_PACKAGE"} + ) + + def test_reset_qty_done_pack_picking_not_found(self): + move_lines = self.pack1_moves.mapped("move_line_ids") + package = move_lines.mapped("package_id") + response = self.service.dispatch( + "reset_qty_done_pack", params={"package_id": package.id, "picking_id": -1} + ) + self.assert_response_deliver( + response, message=self.service.msg_store.stock_picking_not_found() + ) + + def test_reset_qty_done_pack_package_not_found(self): + response = self.service.dispatch( + "reset_qty_done_pack", + params={"package_id": -1, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.package_not_found(), + ) + + def test_reset_qty_done_pack_package_not_available_in_picking(self): + response = self.service.dispatch( + "reset_qty_done_pack", + params={"package_id": self.free_package.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.package_not_available_in_picking( + self.free_package, self.picking + ), + ) + + def test_reset_qty_done_pack_ok(self): + move_lines = self.pack1_moves.mapped("move_line_ids") + package = move_lines.mapped("package_id") + # Set qty done on a package, related move lines are "done" + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package.id, "picking_id": self.picking.id}, + ) + self.assertTrue(all(ml.qty_done == ml.product_uom_qty for ml in move_lines)) + # Reset it, no related move lines are "done" + response = self.service.dispatch( + "reset_qty_done_pack", + params={"package_id": package.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver(response, picking=self.picking) + self.assertFalse(any(ml.qty_done > 0 for ml in move_lines)) + + def test_reset_qty_done_pack_picking_status(self): + package1 = self.pack1_moves.mapped("move_line_ids").mapped("package_id") + package2 = self.pack2_move.mapped("move_line_ids").mapped("package_id") + # Set qty done for all lines (all linked to packages here), picking is + # automatically set to done + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package1.id, "picking_id": self.picking.id}, + ) + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package2.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "done") + # Try to reset one of them => picking already processed + response = self.service.dispatch( + "reset_qty_done_pack", + params={"package_id": package1.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, message=self.service.msg_store.already_done() + ) + self.assertEqual(self.picking.state, "done") From b71b4f8cd4375c81eb62cdc37d31f89ee07aa1fd Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 2 Jun 2020 18:09:57 +0200 Subject: [PATCH 252/940] delivery: implement 'reset_qty_done_line' endpoint --- shopfloor/actions/message.py | 14 +++ shopfloor/services/delivery.py | 42 +++++-- .../test_delivery_reset_qty_done_line.py | 117 ++++++++++++++++++ 3 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 shopfloor/tests/test_delivery_reset_qty_done_line.py diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index fce3f88107..ae2e1fafd7 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -89,6 +89,14 @@ def package_not_available_in_picking(self, package, picking): ), } + def line_not_available_in_picking(self, picking): + return { + "message_type": "warning", + "body": _("This line is not available in transfer {}.").format( + picking.name + ), + } + def record_not_found(self): return { "message_type": "error", @@ -219,6 +227,12 @@ def stock_picking_not_available(self, picking): "body": _("Transfer {} is not available.").format(picking.name), } + def line_has_package_scan_package(self): + return { + "message_type": "warning", + "body": _("This line has a package, please select the package instead."), + } + def product_multiple_packages_scan_package(self): return { "message_type": "warning", diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 982383e16b..9073010009 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -1,4 +1,4 @@ -from odoo import _, fields +from odoo import fields from odoo.osv import expression from odoo.addons.base_rest.components.service import to_int @@ -376,13 +376,10 @@ def set_qty_done_line(self, picking_id, move_line_id): line = self.env["stock.move.line"].browse(move_line_id).exists() if line: if line.package_id: - msg = { - "message_type": "warning", - "body": _( - "This line has a package, please select the package instead." - ), - } - return self._response_for_deliver(picking=picking, message=msg) + return self._response_for_deliver( + picking=picking, + message=self.msg_store.line_has_package_scan_package(), + ) self._set_lines_done(line) self._action_picking_done(picking) return self._response_for_deliver(picking) @@ -423,7 +420,7 @@ def reset_qty_done_pack(self, picking_id, package_id): picking=picking, message=self.msg_store.package_not_found() ) - def reset_qty_done_line(self, picking_id, line_id): + def reset_qty_done_line(self, picking_id, move_line_id): """Remove "Done" on a move line Should be called only for lines of raw products, /set_qty_done_pack @@ -432,7 +429,32 @@ def reset_qty_done_line(self, picking_id, line_id): Transitions: * deliver: always return here with updated data """ - return self._response() + picking = self.env["stock.picking"].browse(picking_id).exists() + if picking: + response = self._check_picking_status(picking) + if response: + return response + else: + return self._response_for_deliver( + message=self.msg_store.stock_picking_not_found() + ) + line = self.env["stock.move.line"].browse(move_line_id).exists() + if line: + if line.picking_id != picking: + return self._response_for_deliver( + picking=picking, + message=self.msg_store.line_not_available_in_picking(picking), + ) + if line.package_id: + return self._response_for_deliver( + picking=picking, + message=self.msg_store.line_has_package_scan_package(), + ) + self._reset_lines(line) + return self._response_for_deliver(picking) + return self._response_for_deliver( + picking=picking, message=self.msg_store.record_not_found(), + ) def done(self, picking_id): """Set the stock picking to done diff --git a/shopfloor/tests/test_delivery_reset_qty_done_line.py b/shopfloor/tests/test_delivery_reset_qty_done_line.py new file mode 100644 index 0000000000..ca627ec9e1 --- /dev/null +++ b/shopfloor/tests/test_delivery_reset_qty_done_line.py @@ -0,0 +1,117 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_delivery_base import DeliveryCommonCase + + +class DeliveryResetQtyDoneLineCase(DeliveryCommonCase): + """Tests for /reset_qty_done_line""" + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = picking = cls._create_picking( + lines=[ + # we'll put A and B in a single package + (cls.product_a, 10), + (cls.product_b, 10), + # C as raw product + (cls.product_c, 10), + ] + ) + cls.pack1_moves = picking.move_lines[:2] + cls.raw_move = picking.move_lines[2] + cls._fill_stock_for_moves(cls.pack1_moves, in_package=True) + cls._fill_stock_for_moves(cls.raw_move) + picking.action_assign() + # Some records not related at all to the processed picking + cls.free_picking = cls._create_picking(lines=[(cls.product_d, 10)]) + cls.free_raw_move = cls.free_picking.move_lines[0] + cls._fill_stock_for_moves(cls.free_raw_move) + cls.free_picking.action_assign() + + def test_reset_qty_done_line_picking_not_found(self): + move_lines = self.pack1_moves.mapped("move_line_ids") + response = self.service.dispatch( + "reset_qty_done_line", + params={"move_line_id": move_lines[0].id, "picking_id": -1}, + ) + self.assert_response_deliver( + response, message=self.service.msg_store.stock_picking_not_found() + ) + + def test_reset_qty_done_line_line_not_found(self): + response = self.service.dispatch( + "reset_qty_done_line", + params={"move_line_id": -1, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.record_not_found(), + ) + + def test_reset_qty_done_line_line_not_available_in_picking(self): + move_line = self.free_raw_move.mapped("move_line_ids") + response = self.service.dispatch( + "reset_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.line_not_available_in_picking(self.picking), + ) + + def test_reset_qty_done_line_ok(self): + move_line = self.raw_move.mapped("move_line_ids") + # Set qty done on a line + self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + self.assertTrue(move_line.qty_done == move_line.product_uom_qty) + # Reset it, no related move lines are "done" + response = self.service.dispatch( + "reset_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver(response, picking=self.picking) + self.assertFalse(move_line.qty_done) + + def test_reset_qty_done_line_with_package(self): + move_line = self.pack1_moves[0].mapped("move_line_ids") + response = self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.line_has_package_scan_package(), + ) + + def test_reset_qty_done_pack_picking_status(self): + move_lines = self.picking.move_line_ids + raw_move_line = self.raw_move.mapped("move_line_ids") + # Set qty done for all lines (some are linked to packages here), + # picking is automatically set to done + for package in move_lines.mapped("package_id"): + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package.id, "picking_id": self.picking.id}, + ) + self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": raw_move_line.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "done") + # Try to reset one of them => picking already processed + response = self.service.dispatch( + "reset_qty_done_line", + params={"move_line_id": raw_move_line.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, message=self.service.msg_store.already_done() + ) + self.assertEqual(self.picking.state, "done") From 84b381b4221ad27a303f0ab44139dcc239c4bc3e Mon Sep 17 00:00:00 2001 From: sebalix Date: Wed, 3 Jun 2020 11:45:52 +0200 Subject: [PATCH 253/940] delivery: implement 'done' endpoint --- shopfloor/actions/message.py | 23 ++++++ shopfloor/services/delivery.py | 55 +++++++++++++- shopfloor/tests/test_delivery_done.py | 104 ++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 shopfloor/tests/test_delivery_done.py diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index ae2e1fafd7..143bb83e7b 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -292,3 +292,26 @@ def batch_transfer_line_done(self): "message_type": "success", "body": _("Batch Transfer line done"), } + + def transfer_complete(self, picking): + return { + "message_type": "success", + "body": _("Transfer {} complete").format(picking.name), + } + + def transfer_confirm_done(self): + return { + "message_type": "warning", + "body": _( + "Not all lines have been processed, do you want to " + "confirm partial operation ?" + ), + } + + def transfer_no_qty_done(self): + return { + "message_type": "warning", + "body": _( + "No quantity has been processed, unable to complete the transfer." + ), + } diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 9073010009..ba1f6293ac 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -1,7 +1,8 @@ from odoo import fields from odoo.osv import expression +from odoo.tools.float_utils import float_is_zero -from odoo.addons.base_rest.components.service import to_int +from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component @@ -69,6 +70,16 @@ def _response_for_manual_selection(self, pickings, message=None): message=message, ) + def _response_for_confirm_done(self, picking, message=None): + """Transition to the 'confirm_done' state.""" + return self._response( + next_state="confirm_done", + data={ + "picking": self.data_struct.picking_detail(picking) if picking else None + }, + message=message, + ) + def _check_picking_status(self, picking): """Check if `picking` can be processed. @@ -456,14 +467,47 @@ def reset_qty_done_line(self, picking_id, move_line_id): picking=picking, message=self.msg_store.record_not_found(), ) - def done(self, picking_id): + def done(self, picking_id, confirm=False): """Set the stock picking to done Transitions: * deliver: error during action * confirm_done: when not all lines of the stock.picking are done """ - return self._response() + picking = self.env["stock.picking"].browse(picking_id).exists() + if picking: + response = self._check_picking_status(picking) + if response: + return response + else: + return self._response_for_deliver( + message=self.msg_store.stock_picking_not_found() + ) + if self._action_picking_done(picking): + return self._response_for_deliver( + message=self.msg_store.transfer_complete(picking) + ) + if confirm: + precision_digits = self.env["decimal.precision"].precision_get( + "Product Unit of Measure" + ) + no_quantities_done = all( + float_is_zero(move_line.qty_done, precision_digits=precision_digits) + for move_line in picking.move_line_ids.filtered( + lambda m: m.state not in ("done", "cancel") + ) + ) + if no_quantities_done: + return self._response_for_deliver( + message=self.msg_store.transfer_no_qty_done() + ) + picking.action_done() + return self._response_for_deliver( + message=self.msg_store.transfer_complete(picking) + ) + return self._response_for_confirm_done( + picking, message=self.msg_store.transfer_confirm_done(), + ) class ShopfloorDeliveryValidator(Component): @@ -515,7 +559,10 @@ def reset_qty_done_line(self): } def done(self): - return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}} + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "confirm": {"coerce": to_bool, "required": False, "type": "boolean"}, + } class ShopfloorDeliveryValidatorResponse(Component): diff --git a/shopfloor/tests/test_delivery_done.py b/shopfloor/tests/test_delivery_done.py new file mode 100644 index 0000000000..e2ef593719 --- /dev/null +++ b/shopfloor/tests/test_delivery_done.py @@ -0,0 +1,104 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_delivery_base import DeliveryCommonCase + + +class DeliveryDoneCase(DeliveryCommonCase): + """Tests for /done""" + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = picking = cls._create_picking( + lines=[ + # we'll put A and B in a single package + (cls.product_a, 10), + (cls.product_b, 10), + # C as raw product + (cls.product_c, 10), + ] + ) + cls.pack1_moves = picking.move_lines[:2] + cls.raw_move = cls.picking.move_lines[2] + cls._fill_stock_for_moves(cls.pack1_moves, in_package=True) + cls._fill_stock_for_moves(cls.raw_move) + cls.picking.action_assign() + + def assert_response_confirm_done(self, response, picking=None, message=None): + self.assert_response( + response, + next_state="confirm_done", + data={"picking": self._stock_picking_data(picking) if picking else None}, + message=message, + ) + + def test_done_picking_not_found(self): + response = self.service.dispatch("done", params={"picking_id": -1}) + self.assert_response_deliver( + response, message=self.service.msg_store.stock_picking_not_found() + ) + + def test_done_all_qty_done(self): + # Do not use the /set_qty_done_line endpoint to set done qties to not + # update the picking to 'done' state automatically + for move_line in self.picking.move_line_ids: + move_line.qty_done = move_line.product_uom_qty + response = self.service.dispatch("done", params={"picking_id": self.picking.id}) + self.assert_response_deliver( + response, message=self.service.msg_store.transfer_complete(self.picking), + ) + self.assertEqual(self.picking.state, "done") + + def test_done_no_qty_done(self): + response = self.service.dispatch("done", params={"picking_id": self.picking.id}) + self.assert_response_confirm_done( + response, + picking=self.picking, + message=self.service.msg_store.transfer_confirm_done(), + ) + self.assertEqual(self.picking.state, "assigned") + + def test_done_some_qty_done(self): + move_line = self.raw_move.move_line_ids[0] + self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + response = self.service.dispatch("done", params={"picking_id": self.picking.id}) + self.assert_response_confirm_done( + response, + picking=self.picking, + message=self.service.msg_store.transfer_confirm_done(), + ) + self.assertEqual(self.picking.state, "assigned") + + def test_done_no_qty_done_confirm(self): + self.assertEqual(self.picking.state, "assigned") + response = self.service.dispatch( + "done", params={"picking_id": self.picking.id, "confirm": True} + ) + self.assert_response_deliver( + response, message=self.service.msg_store.transfer_no_qty_done(), + ) + self.assertEqual(self.picking.state, "assigned") + + def test_done_some_qty_done_confirm(self): + move_line = self.raw_move.move_line_ids[0] + self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "assigned") + response = self.service.dispatch( + "done", params={"picking_id": self.picking.id, "confirm": True} + ) + self.assert_response_deliver( + response, message=self.service.msg_store.transfer_complete(self.picking), + ) + self.assertEqual(self.picking.state, "done") + self.assertEqual(self.picking.move_lines, self.raw_move) + backorder = self.picking.backorder_ids + self.assertTrue(backorder) + self.assertEqual(backorder.state, "assigned") + self.assertEqual(self.pack1_moves.picking_id, backorder) From 1e4dd9005b1d2b3e3ee537e070d5b3cbe6faacb7 Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 4 Jun 2020 18:16:44 +0200 Subject: [PATCH 254/940] delivery: try to update transfer to done each time we update lines --- shopfloor/services/delivery.py | 29 ++++++++++-- shopfloor/tests/test_delivery_scan_deliver.py | 47 +++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index ba1f6293ac..420a811bf2 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -163,10 +163,18 @@ def scan_deliver(self, barcode, picking_id=None): ) def _set_lines_done(self, lines): + """Set done quantities on `lines`. + + Once all lines of a picking have been processed, the picking will be + validated automatically. + Return `True` if the related picking has been validated. + """ for line in lines: # note: the package level is automatically set to "is_done" when # the qty_done is full line.qty_done = line.product_uom_qty + picking = fields.first(lines.mapped("picking_id")) + return self._action_picking_done(picking) def _reset_lines(self, lines): for line in lines: @@ -186,8 +194,11 @@ def _deliver_package(self, picking, package): message=self.msg_store.cannot_move_something_in_picking_type(), ) # TODO add a message if any of the lines already had a qty_done > 0 - self._set_lines_done(lines) new_picking = fields.first(lines.mapped("picking_id")) + if self._set_lines_done(lines): + return self._response_for_deliver( + message=self.msg_store.transfer_complete(new_picking) + ) return self._response_for_deliver(picking=new_picking) def _lines_base_domain(self, no_qty_done=True): @@ -252,7 +263,10 @@ def _deliver_product(self, picking, product): new_picking, message=self.msg_store.product_mixed_package_scan_package(), ) - self._set_lines_done(lines) + if self._set_lines_done(lines): + return self._response_for_deliver( + message=self.msg_store.transfer_complete(new_picking) + ) return self._response_for_deliver(new_picking) def _deliver_lot(self, picking, lot): @@ -286,7 +300,10 @@ def _deliver_lot(self, picking, lot): message=self.msg_store.lot_mixed_package_scan_package(), ) - self._set_lines_done(lines) + if self._set_lines_done(lines): + return self._response_for_deliver( + message=self.msg_store.transfer_complete(new_picking) + ) return self._response_for_deliver(new_picking) def _action_picking_done(self, picking): @@ -391,8 +408,10 @@ def set_qty_done_line(self, picking_id, move_line_id): picking=picking, message=self.msg_store.line_has_package_scan_package(), ) - self._set_lines_done(line) - self._action_picking_done(picking) + if self._set_lines_done(line): + return self._response_for_deliver( + message=self.msg_store.transfer_complete(picking) + ) return self._response_for_deliver(picking) return self._response_for_deliver( picking=picking, message=self.msg_store.record_not_found(), diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py index e75e285201..264510337b 100644 --- a/shopfloor/tests/test_delivery_scan_deliver.py +++ b/shopfloor/tests/test_delivery_scan_deliver.py @@ -102,7 +102,9 @@ def _test_scan_set_done_ok(self, move_lines, barcode): def test_scan_deliver_scan_package_ok(self): move_lines = self.pack1_moves.mapped("move_line_ids") package = move_lines.mapped("package_id") + self.assertEqual(self.picking.state, "assigned") self._test_scan_set_done_ok(move_lines, package.name) + self.assertEqual(self.picking.state, "assigned") def test_scan_deliver_scan_package_no_move_lines(self): response = self.service.dispatch( @@ -187,6 +189,51 @@ def test_scan_deliver_scan_lot_in_mixed_package(self): message=self.service.msg_store.lot_mixed_package_scan_package(), ) + def test_scan_deliver_picking_done(self): + # Set qty done for all lines (packages/raw product/lot...), picking is + # automatically set to done when the last line is completed + package1 = self.pack1_moves.mapped("move_line_ids").mapped("package_id") + package2 = self.pack2_move.mapped("move_line_ids").mapped("package_id") + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package1.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "assigned") + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package2.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "assigned") + self.service.dispatch( + "scan_deliver", + params={ + "barcode": self.raw_move.product_id.barcode, + "picking_id": self.picking.id, + }, + ) + self.assertEqual(self.picking.state, "assigned") + lot = self.raw_lot_move.move_line_ids.lot_id + response = self.service.dispatch( + "scan_deliver", params={"barcode": lot.name, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "assigned") + packages_f = self.pack3_move.move_line_ids.mapped("package_id") + # While all lines are not processed, response still returns the picking + self.assert_response_deliver( + response, picking=self.picking, + ) + response = None + # Once all lines are processed, the last response has no picking returned + for package in packages_f: + response = self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "done") + self.assert_response_deliver( + response, message=self.service.msg_store.transfer_complete(self.picking), + ) + class DeliveryScanDeliverSpecialCase(DeliveryCommonCase): """Special cases with different setup for /scan_deliver""" From fc005a29902d77a28b7789288be829dbc782c699 Mon Sep 17 00:00:00 2001 From: sebalix Date: Fri, 5 Jun 2020 10:11:32 +0200 Subject: [PATCH 255/940] backend: new service properties 'data', 'data_detail' and 'msg_store' + update all existing services to use them --- shopfloor/services/checkout.py | 232 ++++++++++----------- shopfloor/services/cluster_picking.py | 38 ++-- shopfloor/services/delivery.py | 14 +- shopfloor/services/scan_anything.py | 16 +- shopfloor/services/service.py | 12 ++ shopfloor/services/single_pack_putaway.py | 52 +++-- shopfloor/services/single_pack_transfer.py | 21 +- shopfloor/tests/test_delivery_base.py | 2 +- 8 files changed, 176 insertions(+), 211 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index fab2f37847..d73ee56489 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -34,6 +34,90 @@ class Checkout(Component): _usage = "checkout" _description = __doc__ + def _response_for_select_line(self, picking, message=None): + if all(line.shopfloor_checkout_done for line in picking.move_line_ids): + return self._response_for_summary(picking, message=message) + return self._response( + next_state="select_line", + data={"picking": self._data_for_stock_picking(picking)}, + message=message, + ) + + def _response_for_summary(self, picking, need_confirm=False, message=None): + return self._response( + next_state="summary" if not need_confirm else "confirm_done", + data={ + "picking": self._data_for_stock_picking(picking, done=True), + "all_processed": not bool(self._lines_to_pack(picking)), + }, + message=message, + ) + + def _response_for_select_document(self, message=None): + return self._response(next_state="select_document", message=message) + + def _response_for_manual_selection(self, message=None): + pickings = self.env["stock.picking"].search( + self._domain_for_list_stock_picking(), + order=self._order_for_list_stock_picking(), + ) + data = {"pickings": self.data.pickings(pickings)} + return self._response(next_state="manual_selection", data=data, message=message) + + def _response_for_select_package(self, lines, message=None): + picking = lines.mapped("picking_id") + return self._response( + next_state="select_package", + data={ + "selected_move_lines": self._data_for_move_lines(lines.sorted()), + "picking": self.data.picking(picking), + }, + message=message, + ) + + def _response_for_select_dest_package(self, picking, move_lines, message=None): + packages = picking.mapped("move_line_ids.package_id") | picking.mapped( + "move_line_ids.result_package_id" + ) + if not packages: + return self._response_for_select_package( + move_lines, + message={ + "message_type": "warning", + "body": _("No valid package to select."), + }, + ) + picking_data = self.data.picking(picking) + packages_data = self.data.packages( + packages.sorted(), picking=picking, with_packaging=True + ) + return self._response( + next_state="select_dest_package", + data={ + "picking": picking_data, + "packages": packages_data, + "selected_move_lines": self._data_for_move_lines(move_lines.sorted()), + }, + message=message, + ) + + def _response_for_change_packaging(self, picking, package, packaging_list): + if not package: + return self._response_for_summary( + picking, message=self.msg_store.record_not_found() + ) + + return self._response( + next_state="change_packaging", + data={ + "picking": self.data.picking(picking), + "package": self.data.package( + package, picking=picking, with_packaging=True + ), + "packaging": self.data.packaging_list(packaging_list.sorted()), + }, + ) + def scan_document(self, barcode): """Scan a package, a stock.picking or a location @@ -57,7 +141,6 @@ def scan_document(self, barcode): destination pack set """ search = self.actions_for("search") - message = self.actions_for("message") picking = search.picking_from_scan(barcode) if not picking: location = search.location_from_scan(barcode) @@ -66,7 +149,7 @@ def scan_document(self, barcode): self.picking_types.mapped("default_location_src_id") ): return self._response_for_select_document( - message=message.location_not_allowed() + message=self.msg_store.location_not_allowed() ) lines = location.source_move_line_ids pickings = lines.mapped("picking_id") @@ -103,62 +186,37 @@ def scan_document(self, barcode): return self._select_picking(picking, "select_document") def _select_picking(self, picking, state_for_error): - message = self.actions_for("message") if not picking: if state_for_error == "manual_selection": return self._response_for_manual_selection( - message=message.stock_picking_not_found() + message=self.msg_store.stock_picking_not_found() ) return self._response_for_select_document( - message=message.barcode_not_found() + message=self.msg_store.barcode_not_found() ) if picking.picking_type_id not in self.picking_types: if state_for_error == "manual_selection": return self._response_for_manual_selection( - message=message.cannot_move_something_in_picking_type() + message=self.msg_store.cannot_move_something_in_picking_type() ) return self._response_for_select_document( - message=message.cannot_move_something_in_picking_type() + message=self.msg_store.cannot_move_something_in_picking_type() ) if picking.state != "assigned": if state_for_error == "manual_selection": return self._response_for_manual_selection( - message=message.stock_picking_not_available(picking) + message=self.msg_store.stock_picking_not_available(picking) ) return self._response_for_select_document( - message=message.stock_picking_not_available(picking) + message=self.msg_store.stock_picking_not_available(picking) ) return self._response_for_select_line(picking) - def _response_for_select_line(self, picking, message=None): - if all(line.shopfloor_checkout_done for line in picking.move_line_ids): - return self._response_for_summary(picking, message=message) - return self._response( - next_state="select_line", - data={"picking": self._data_for_stock_picking(picking)}, - message=message, - ) - - def _response_for_summary(self, picking, need_confirm=False, message=None): - return self._response( - next_state="summary" if not need_confirm else "confirm_done", - data={ - "picking": self._data_for_stock_picking(picking, done=True), - "all_processed": not bool(self._lines_to_pack(picking)), - }, - message=message, - ) - - def _response_for_select_document(self, message=None): - return self._response(next_state="select_document", message=message) - def _data_for_move_lines(self, lines, **kw): - data_struct = self.actions_for("data") - return data_struct.move_lines(lines, **kw) + return self.data.move_lines(lines, **kw) def _data_for_stock_picking(self, picking, done=False): - data_struct = self.actions_for("data") - data = data_struct.picking(picking) + data = self.data.picking(picking) line_picker = self._lines_checkout_done if done else self._lines_to_pack data.update( { @@ -195,15 +253,6 @@ def list_stock_picking(self): """ return self._response_for_manual_selection() - def _response_for_manual_selection(self, message=None): - pickings = self.env["stock.picking"].search( - self._domain_for_list_stock_picking(), - order=self._order_for_list_stock_picking(), - ) - data_struct = self.actions_for("data") - data = {"pickings": data_struct.pickings(pickings)} - return self._response(next_state="manual_selection", data=data, message=message) - def select(self, picking_id): """Select a stock picking for the scenario @@ -225,18 +274,6 @@ def select(self, picking_id): picking = self.env["stock.picking"].browse(picking_id).exists() return self._select_picking(picking, "manual_selection") - def _response_for_select_package(self, lines, message=None): - data_struct = self.actions_for("data") - picking = lines.mapped("picking_id") - return self._response( - next_state="select_package", - data={ - "selected_move_lines": self._data_for_move_lines(lines.sorted()), - "picking": data_struct.picking(picking), - }, - message=message, - ) - def _select_lines(self, lines): for line in lines: if line.shopfloor_checkout_done: @@ -274,7 +311,6 @@ def scan_line(self, picking_id, barcode): return self._response_stock_picking_does_not_exist() search = self.actions_for("search") - message = self.actions_for("message") selection_lines = self._lines_to_pack(picking) if not selection_lines: @@ -293,7 +329,7 @@ def scan_line(self, picking_id, barcode): return self._select_lines_from_lot(picking, selection_lines, lot) return self._response_for_select_line( - picking, message=message.barcode_not_found() + picking, message=self.msg_store.barcode_not_found() ) def _select_lines_from_package(self, picking, selection_lines, package): @@ -312,10 +348,9 @@ def _select_lines_from_package(self, picking, selection_lines, package): return self._response_for_select_package(lines) def _select_lines_from_product(self, picking, selection_lines, product): - message = self.actions_for("message") if product.tracking in ("lot", "serial"): return self._response_for_select_line( - picking, message=message.scan_lot_on_product_tracked_by_lot() + picking, message=self.msg_store.scan_lot_on_product_tracked_by_lot() ) lines = selection_lines.filtered(lambda l: l.product_id == product) @@ -339,7 +374,7 @@ def _select_lines_from_product(self, picking, selection_lines, product): # package. if packages and len({l.package_id for l in lines}) > 1: return self._response_for_select_line( - picking, message=message.product_multiple_packages_scan_package() + picking, message=self.msg_store.product_multiple_packages_scan_package() ) elif packages: # Select all the lines of the package when we scan a product in a @@ -360,7 +395,6 @@ def _select_lines_from_lot(self, picking, selection_lines, lot): }, ) - message = self.actions_for("message") # When lots are as units outside of packages, we can select them for # packing, but if they are in a package, we want the user to scan the packages. # If the product is only in one package though, scanning the lot selects @@ -372,7 +406,7 @@ def _select_lines_from_lot(self, picking, selection_lines, lot): # package. if packages and len({l.package_id for l in lines}) > 1: return self._response_for_select_line( - picking, message=message.lot_multiple_packages_scan_package() + picking, message=self.msg_store.lot_multiple_packages_scan_package() ) elif packages: # Select all the lines of the package when we scan a lot in a @@ -384,17 +418,15 @@ def _select_lines_from_lot(self, picking, selection_lines, lot): def _select_line_package(self, picking, selection_lines, package): if not package: - message = self.actions_for("message") return self._response_for_select_line( - picking, message=message.record_not_found() + picking, message=self.msg_store.record_not_found() ) return self._select_lines_from_package(picking, selection_lines, package) def _select_line_move_line(self, picking, selection_lines, move_line): if not move_line: - message = self.actions_for("message") return self._response_for_select_line( - picking, message=message.record_not_found() + picking, message=self.msg_store.record_not_found() ) # normally, the client should sent only move lines out of packages, but # in case there is a package, handle it as a package @@ -445,13 +477,11 @@ def _change_line_qty( if not picking.exists(): return self._response_stock_picking_does_not_exist() - message_directory = self.actions_for("message") - move_lines = self.env["stock.move.line"].browse(move_line_ids).exists() message = None if not move_lines: - message = message_directory.record_not_found() + message = self.msg_store.record_not_found() for move_line in move_lines: qty_done = quantity_func(move_line) if qty_done > move_line.product_uom_qty: @@ -641,7 +671,6 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): if not picking.exists(): return self._response_stock_picking_does_not_exist() search = self.actions_for("search") - message = self.actions_for("message") selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() @@ -649,7 +678,8 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): if product: if product.tracking in ("lot", "serial"): return self._response_for_select_package( - selected_lines, message=message.scan_lot_on_product_tracked_by_lot() + selected_lines, + message=self.msg_store.scan_lot_on_product_tracked_by_lot(), ) product_lines = selected_lines.filtered(lambda l: l.product_id == product) return self._switch_line_qty_done(picking, selected_lines, product_lines) @@ -670,7 +700,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): ) return self._response_for_select_package( - selected_lines, message=message.barcode_not_found() + selected_lines, message=self.msg_store.barcode_not_found() ) def new_package(self, picking_id, selected_line_ids): @@ -734,34 +764,6 @@ def list_dest_package(self, picking_id, selected_line_ids): lines = self.env["stock.move.line"].browse(selected_line_ids).exists() return self._response_for_select_dest_package(picking, lines) - def _response_for_select_dest_package(self, picking, move_lines, message=None): - packages = picking.mapped("move_line_ids.package_id") | picking.mapped( - "move_line_ids.result_package_id" - ) - if not packages: - return self._response_for_select_package( - move_lines, - message={ - "message_type": "warning", - "body": _("No valid package to select."), - }, - ) - data_struct = self.actions_for("data") - picking_data = data_struct.picking(picking) - packages_data = data_struct.packages( - packages.sorted(), picking=picking, with_packaging=True - ) - data_struct = self.actions_for("data") - return self._response( - next_state="select_dest_package", - data={ - "picking": picking_data, - "packages": packages_data, - "selected_move_lines": self._data_for_move_lines(move_lines.sorted()), - }, - message=message, - ) - def _set_dest_package_from_selection(self, picking, selected_lines, package): if not package: return self._response_for_select_dest_package(picking, selected_lines) @@ -852,25 +854,6 @@ def list_packaging(self, picking_id, package_id): packaging_list = self._get_allowed_packaging() return self._response_for_change_packaging(picking, package, packaging_list) - def _response_for_change_packaging(self, picking, package, packaging_list): - message = self.actions_for("message") - data_struct = self.actions_for("data") - if not package: - return self._response_for_summary( - picking, message=message.record_not_found() - ) - - return self._response( - next_state="change_packaging", - data={ - "picking": data_struct.picking(picking), - "package": data_struct.package( - package, picking=picking, with_packaging=True - ), - "packagings": data_struct.packaging_list(packaging_list.sorted()), - }, - ) - def set_packaging(self, picking_id, package_id, packaging_id): """Set a package type on a package @@ -878,8 +861,6 @@ def set_packaging(self, picking_id, package_id, packaging_id): * change_packaging: in case of error * summary """ - message = self.actions_for("message") - picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): return self._response_stock_picking_does_not_exist() @@ -888,7 +869,7 @@ def set_packaging(self, picking_id, package_id, packaging_id): packaging = self.env["product.packaging"].browse(packaging_id).exists() if not (package and packaging): return self._response_for_summary( - picking, message=message.record_not_found() + picking, message=self.msg_store.record_not_found() ) package.product_packaging_id = packaging return self._response_for_summary( @@ -915,7 +896,6 @@ def cancel_line(self, picking_id, package_id=None, line_id=None): Transitions: * summary """ - message = self.actions_for("message") picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): return self._response_stock_picking_does_not_exist() @@ -924,7 +904,7 @@ def cancel_line(self, picking_id, package_id=None, line_id=None): line = self.env["stock.move.line"].browse(line_id).exists() if not package and not line: return self._response_for_summary( - picking, message=message.record_not_found() + picking, message=self.msg_store.record_not_found() ) if package: @@ -1241,7 +1221,7 @@ def _schema_select_packaging(self): "type": "dict", "schema": self.schemas.package(with_packaging=True), }, - "packagings": { + "packaging": { "type": "list", "schema": {"type": "dict", "schema": self.schemas.packaging()}, }, diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index e40d981491..bda81fc7f1 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -71,26 +71,18 @@ class ClusterPicking(Component): _usage = "cluster_picking" _description = __doc__ - @property - def data_struct(self): - return self.actions_for("data") - - @property - def msg_store(self): - return self.actions_for("message") - def _response_for_start(self, message=None, popup=None): return self._response(next_state="start", message=message, popup=popup) def _response_for_confirm_start(self, batch): return self._response( next_state="confirm_start", - data=self.data_struct.picking_batch(batch, with_pickings=True), + data=self.data.picking_batch(batch, with_pickings=True), ) def _response_for_manual_selection(self, batches, message=None): data = { - "records": self.data_struct.picking_batches(batches), + "records": self.data.picking_batches(batches), "size": len(batches), } return self._response(next_state="manual_selection", data=data, message=message) @@ -108,9 +100,7 @@ def _response_for_scan_destination(self, move_line, message=None): last_picked_line = self._last_picked_line(move_line.picking_id) if last_picked_line: # suggest pack to be used for the next line - data["package_dest"] = self.data_struct.package( - last_picked_line.result_package_id - ) + data["package_dest"] = self.data.package(last_picked_line.result_package_id) return self._response(next_state="scan_destination", data=data, message=message) def _response_for_change_pack_lot(self, move_line, message=None): @@ -123,9 +113,9 @@ def _response_for_change_pack_lot(self, move_line, message=None): def _response_for_zero_check(self, batch, move_line): data = { "id": move_line.id, - "location_src": self.data_struct.location(move_line.location_id), + "location_src": self.data.location(move_line.location_id), } - data["batch"] = self.data_struct.picking_batch(batch) + data["batch"] = self.data.picking_batch(batch) return self._response(next_state="zero_check", data=data) def _response_for_unload_all(self, batch, message=None): @@ -379,14 +369,14 @@ def _data_move_line(self, line, **kw): picking = line.picking_id batch = picking.batch_id product = line.product_id - data = self.data_struct.move_line(line) + data = self.data.move_line(line) # additional values # Ensure destination pack is never proposed on the frontend. # This should happen only as proposal on `scan_destination` # where we set the last used package. data["package_dest"] = None - data["batch"] = self.data_struct.picking_batch(batch) - data["picking"] = self.data_struct.picking(picking) + data["batch"] = self.data.picking_batch(batch) + data["picking"] = self.data.picking(picking) data["postponed"] = line.shopfloor_postponed data["product"]["qty_available"] = product.with_context( location=line.location_id.id @@ -696,21 +686,19 @@ def _data_for_unload_all(self, batch): # all the lines destinations are the same here, it looks # only for the first one first_line = fields.first(lines) - data = self.data_struct.picking_batch(batch) - data.update( - {"location_dest": self.data_struct.location(first_line.location_dest_id)} - ) + data = self.data.picking_batch(batch) + data.update({"location_dest": self.data.location(first_line.location_dest_id)}) return data def _data_for_unload_single(self, batch, package): line = fields.first( package.planned_move_line_ids.filtered(self._filter_for_unload) ) - data = self.data_struct.picking_batch(batch) + data = self.data.picking_batch(batch) data.update( { - "package": self.data_struct.package(package), - "location_dest": self.data_struct.location(line.location_dest_id), + "package": self.data.package(package), + "location_dest": self.data.location(line.location_dest_id), } ) return data diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 420a811bf2..18e9681be0 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -34,14 +34,6 @@ class Delivery(Component): _usage = "delivery" _description = __doc__ - @property - def data_struct(self): - return self.actions_for("data_detail") - - @property - def msg_store(self): - return self.actions_for("message") - def _response_for_deliver(self, picking=None, message=None): """Transition to the 'deliver' state @@ -50,7 +42,7 @@ def _response_for_deliver(self, picking=None, message=None): return self._response( next_state="deliver", data={ - "picking": self.data_struct.picking_detail(picking) if picking else None + "picking": self.data_detail.picking_detail(picking) if picking else None }, message=message, ) @@ -64,7 +56,7 @@ def _response_for_manual_selection(self, pickings, message=None): next_state="manual_selection", data={ "pickings": [ - self.data_struct.picking_detail(picking) for picking in pickings + self.data_detail.picking_detail(picking) for picking in pickings ], }, message=message, @@ -75,7 +67,7 @@ def _response_for_confirm_done(self, picking, message=None): return self._response( next_state="confirm_done", data={ - "picking": self.data_struct.picking_detail(picking) if picking else None + "picking": self.data_detail.picking_detail(picking) if picking else None }, message=message, ) diff --git a/shopfloor/services/scan_anything.py b/shopfloor/services/scan_anything.py index 38371070fd..de8b24dc1f 100644 --- a/shopfloor/services/scan_anything.py +++ b/shopfloor/services/scan_anything.py @@ -71,32 +71,36 @@ def _scan_handlers(self): """ search = self.actions_for("search") - data = self.actions_for("data_detail") schema = self.component(usage="schema_detail") return ( ( "location", search.location_from_scan, - data.location_detail, + self.data_detail.location_detail, schema.location_detail, ), ( "package", search.package_from_scan, - data.package_detail, + self.data_detail.package_detail, schema.package_detail, ), ( "product", search.product_from_scan, - data.product_detail, + self.data_detail.product_detail, schema.product_detail, ), - ("lot", search.lot_from_scan, data.lot_detail, schema.lot_detail), + ( + "lot", + search.lot_from_scan, + self.data_detail.lot_detail, + schema.lot_detail, + ), ( "transfer", search.picking_from_scan, - data.picking_detail, + self.data_detail.picking_detail, schema.picking_detail, ), ) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 51465f1f16..10f9f088d9 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -253,6 +253,18 @@ def _is_public_api_method(self, method_name): return False return super()._is_public_api_method(method_name) + @property + def data(self): + return self.actions_for("data") + + @property + def data_detail(self): + return self.actions_for("data_detail") + + @property + def msg_store(self): + return self.actions_for("message") + class BaseShopfloorProcess(AbstractComponent): """Base class for process rest service""" diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 3345a2f48e..d50be0ba81 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -18,26 +18,27 @@ class SinglePackPutaway(Component): # TODO think about not sending back the state when we already # come from the same state def _response_for_no_picking_type(self): - message = self.actions_for("message") - return self._response(next_state="start", message=message.no_picking_type()) + return self._response( + next_state="start", message=self.msg_store.no_picking_type() + ) def _response_for_several_picking_types(self): - message = self.actions_for("message") return self._response( - next_state="start", message=message.several_picking_types() + next_state="start", message=self.msg_store.several_picking_types() ) def _response_for_package_not_found(self, barcode): - message = self.actions_for("message") return self._response( - next_state="start", message=message.package_not_found_for_barcode(barcode) + next_state="start", + message=self.msg_store.package_not_found_for_barcode(barcode), ) def _response_for_forbidden_package(self, barcode, picking_type): - message = self.actions_for("message") return self._response( next_state="start", - message=message.package_not_allowed_in_src_location(barcode, picking_type), + message=self.msg_store.package_not_allowed_in_src_location( + barcode, picking_type + ), ) def _response_for_forbidden_start(self, existing_operations): @@ -71,18 +72,16 @@ def _data_after_package_scanned(self, move_line, pack): } def _response_for_start_to_confirm(self, move_line, pack): - message = self.actions_for("message") return self._response( data=self._data_after_package_scanned(move_line, pack), next_state="confirm_start", - message=message.already_running_ask_confirmation(), + message=self.msg_store.already_running_ask_confirmation(), ) def _response_for_start_success(self, move_line, pack): - message = self.actions_for("message") return self._response( next_state="scan_location", - message=message.scan_destination(), + message=self.msg_store.scan_destination(), data=self._data_after_package_scanned(move_line, pack), ) @@ -173,44 +172,43 @@ def _prepare_package_level(self, pack, move): ) def _response_for_package_level_not_found(self): - message = self.actions_for("message") - return self._response(next_state="start", message=message.operation_not_found()) + return self._response( + next_state="start", message=self.msg_store.operation_not_found() + ) def _response_for_move_canceled_elsewhere(self): - message = self.actions_for("message") return self._response( - next_state="start", message=message.operation_has_been_canceled_elsewhere() + next_state="start", + message=self.msg_store.operation_has_been_canceled_elsewhere(), ) def _response_for_location_not_found(self, move_line, pack): - message = self.actions_for("message") return self._response( next_state="scan_location", - message=message.no_location_found(), + message=self.msg_store.no_location_found(), data=self._data_after_package_scanned(move_line, pack), ) def _response_for_forbidden_location(self, move_line, pack): - message = self.actions_for("message") return self._response( next_state="scan_location", - message=message.dest_location_not_allowed(), + message=self.msg_store.dest_location_not_allowed(), data=self._data_after_package_scanned(move_line, pack), ) def _response_for_location_need_confirm(self, move_line, pack, to_location): - message = self.actions_for("message") return self._response( next_state="confirm_location", - message=message.confirm_location_changed( + message=self.msg_store.confirm_location_changed( move_line.location_dest_id, to_location ), data=self._data_after_package_scanned(move_line, pack), ) def _response_for_validate_success(self): - message = self.actions_for("message") - return self._response(next_state="start", message=message.confirm_pack_moved()) + return self._response( + next_state="start", message=self.msg_store.confirm_pack_moved() + ) def validate(self, package_level_id, location_barcode, confirmation=False): """Validate the transfer""" @@ -247,13 +245,11 @@ def validate(self, package_level_id, location_barcode, confirmation=False): return self._response_for_validate_success() def _response_for_move_already_processed(self): - message = self.actions_for("message") - return self._response(next_state="start", message=message.already_done()) + return self._response(next_state="start", message=self.msg_store.already_done()) def _response_for_confirm_move_cancellation(self): - message = self.actions_for("message") return self._response( - next_state="start", message=message.confirm_canceled_scan_next_pack() + next_state="start", message=self.msg_store.confirm_canceled_scan_next_pack() ) def cancel(self, package_level_id): diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index adecaf1aed..76b84a3820 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -10,24 +10,16 @@ class SinglePackTransfer(Component): _usage = "single_pack_transfer" _description = __doc__ - @property - def msg_store(self): - return self.actions_for("message") - - @property - def data_struct(self): - return self.actions_for("data") - def _data_after_package_scanned(self, package_level): move_line = package_level.move_line_ids[0] package = package_level.package_id return { "id": package_level.id, "name": package.name, - "location_src": self.data_struct.location(move_line.location_id), - "location_dest": self.data_struct.location(package_level.location_dest_id), - "product": self.data_struct.product(move_line.product_id), - "picking": self.data_struct.picking(move_line.picking_id), + "location_src": self.data.location(move_line.location_id), + "location_dest": self.data.location(package_level.location_dest_id), + "product": self.data.product(move_line.product_id), + "picking": self.data.picking(move_line.picking_id), } def _response_for_start(self, message=None, popup=None): @@ -193,10 +185,11 @@ def _set_destination_and_done(self, move, scanned_location): move._action_done() def cancel(self, package_level_id): - message = self.actions_for("message") package_level = self.env["stock.package_level"].browse(package_level_id) if not package_level.exists(): - return self._response_for_start(message=message.operation_not_found()) + return self._response_for_start( + message=self.msg_store.operation_not_found() + ) # package.move_ids may be empty, it seems move = package_level.move_line_ids.move_id if move.state == "done": diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py index 1ab4a35fb7..4fd5097dc9 100644 --- a/shopfloor/tests/test_delivery_base.py +++ b/shopfloor/tests/test_delivery_base.py @@ -69,7 +69,7 @@ def setUp(self): self.service = work.component(usage="delivery") def _stock_picking_data(self, picking): - return self.service.data_struct.picking_detail(picking) + return self.service.data_detail.picking_detail(picking) def assert_response_deliver(self, response, picking=None, message=None): self.assert_response( From 0ec163bc3d55364fc7da2a109f10fe5a96678cda Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 5 Jun 2020 11:16:14 +0200 Subject: [PATCH 256/940] backend: unify 'move_lines' key on picking data --- shopfloor/actions/data_detail.py | 2 +- shopfloor/services/schema_detail.py | 2 +- shopfloor/tests/test_actions_data_detail.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py index b118e86e93..7e577ba9cc 100644 --- a/shopfloor/actions/data_detail.py +++ b/shopfloor/actions/data_detail.py @@ -46,7 +46,7 @@ def _picking_detail_parser(self): ("picking_type_id:operation_type", ["id", "name"]), ("carrier_id:carrier", ["id", "name"]), ( - "move_line_ids:lines", + "move_line_ids:move_lines", lambda record, fname: self.move_lines(record[fname]), ), ] diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py index 7736db7a8d..03fea1c23a 100644 --- a/shopfloor/services/schema_detail.py +++ b/shopfloor/services/schema_detail.py @@ -38,7 +38,7 @@ def picking_detail(self): }, "operation_type": self._schema_dict_of(self._simple_record()), "carrier": self._schema_dict_of(self._simple_record()), - "lines": self._schema_list_of(self.move_line()), + "move_lines": self._schema_list_of(self.move_line()), } ) return schema diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index d715d4aefa..0fef6ab21f 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -178,7 +178,7 @@ def test_data_picking(self): "name": picking.picking_type_id.name, }, "carrier": {"id": carrier.id, "name": carrier.name}, - "lines": self.data_detail.move_lines(picking.move_line_ids), + "move_lines": self.data_detail.move_lines(picking.move_line_ids), } self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-05-13") self.maxDiff = None From 9ff87327d48d686eef75ad6add0a8fbb09e9be80 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 10 Jun 2020 10:20:56 +0200 Subject: [PATCH 257/940] backend: skip packaging w/out qty --- shopfloor/actions/data.py | 9 +++++- shopfloor/tests/test_actions_data.py | 41 +++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index c884cfa71c..326e19ff91 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -162,10 +162,17 @@ def _product_parser(self): "display_name", "default_code", "barcode", - ("packaging_ids:packaging", self._packaging_parser), + ("packaging_ids:packaging", self._product_packaging), ("uom_id:uom", self._simple_record_parser() + ["factor", "rounding"]), ] + def _product_packaging(self, rec, field): + return self._jsonify( + rec.packaging_ids.filtered(lambda x: x.qty), + self._packaging_parser, + multi=True, + ) + def picking_batch(self, record, with_pickings=False, **kw): parser = self._picking_batch_parser if with_pickings: diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 53deab34d3..e266525036 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -51,20 +51,24 @@ def assert_schema(self, schema, data): self.assertTrue(validator.validate(data), validator.errors) def _expected_location(self, record, **kw): - return { + data = { "id": record.id, "name": record.name, "barcode": record.barcode, } + data.update(kw) + return data def _expected_product(self, record, **kw): - return { + data = { "id": record.id, "name": record.name, "display_name": record.display_name, "default_code": record.default_code, "barcode": record.barcode, - "packaging": [self._expected_packaging(x) for x in record.packaging_ids], + "packaging": [ + self._expected_packaging(x) for x in record.packaging_ids if x.qty + ], "uom": { "factor": record.uom_id.factor, "id": record.uom_id.id, @@ -72,13 +76,17 @@ def _expected_product(self, record, **kw): "rounding": record.uom_id.rounding, }, } + data.update(kw) + return data def _expected_packaging(self, record, **kw): - return { + data = { "id": record.id, "name": record.name, "qty": record.qty, } + data.update(kw) + return data class ActionsDataCase(ActionsDataCaseBase): @@ -140,6 +148,31 @@ def test_data_picking(self): } self.assertDictEqual(data, expected) + def test_data_product(self): + ( + self.env["product.packaging"] + .sudo() + .create( + { + "name": "Box 2", + "product_id": self.product_a.id, + "barcode": "ProductABox2", + } + ) + ) + self.product_a.packaging_ids.write({"qty": 0}) + data = self.data.product(self.product_a) + self.assert_schema(self.schema.product(), data) + # No packaging expected as all qties are zero + expected = self._expected_product(self.product_a) + self.assertDictEqual(data, expected) + # packaging w/ no zero qty are included + self.product_a.packaging_ids[0].write({"qty": 100}) + self.product_a.packaging_ids[1].write({"qty": 20}) + data = self.data.product(self.product_a) + expected = self._expected_product(self.product_a) + self.assertDictEqual(data, expected) + def test_data_move_line_package(self): move_line = self.move_a.move_line_ids result_package = self.env["stock.quant.package"].create( From d685130e62384b581a96b30fa7e8fd32e1f49bef Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 23 Jun 2020 15:46:31 +0200 Subject: [PATCH 258/940] backend: add option on menu to allow move(s) creation --- shopfloor/models/shopfloor_menu.py | 38 +++++++++++++++++++++++++++--- shopfloor/views/shopfloor_menu.xml | 5 ++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 0074c72999..b7adbc2d14 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -1,4 +1,4 @@ -from odoo import fields, models +from odoo import _, api, exceptions, fields, models class ShopfloorMenu(models.Model): @@ -6,18 +6,30 @@ class ShopfloorMenu(models.Model): _description = "Menu displayed in the scanner application" _order = "sequence" + _scenario_allowing_create_moves = ("single_pack_transfer",) + name = fields.Char(translate=True) sequence = fields.Integer() profile_ids = fields.Many2many( "shopfloor.profile", string="Profiles", help="Visible for these profiles" ) picking_type_ids = fields.Many2many( - comodel_name="stock.picking.type", string="Operation Types", required=True, + comodel_name="stock.picking.type", string="Operation Types", required=True ) - # TODO allow only one picking type when 'move creation' is allowed scenario = fields.Selection(selection="_selection_scenario", required=True) + move_create_is_possible = fields.Boolean(compute="_compute_move_create_is_possible") + # only available for some scenarios, move_create_is_possible defines if the option + # can be used or not + allow_move_create = fields.Boolean( + string="Allow Move Creation", + default=False, + help="Some scenario may create move(s) when a product or package is" + " scanned and no move already exists. Any new move is created in the" + " selected operation type, so it can be active only when one type is selected.", + ) + def _selection_scenario(self): return [ # these must match a REST service's '_usage' @@ -27,3 +39,23 @@ def _selection_scenario(self): ("checkout", "Checkout/Packing"), ("delivery", "Delivery"), ] + + @api.depends("scenario", "picking_type_ids") + def _compute_move_create_is_possible(self): + for menu in self: + menu.move_create_is_possible = bool( + menu.scenario in self._scenario_allowing_create_moves + and len(menu.picking_type_ids) == 1 + ) + + @api.onchange("move_create_is_possible") + def onchange_move_create_is_possible(self): + self.allow_move_create = self.move_create_is_possible + + @api.constrains("scenario", "picking_type_ids", "allow_move_create") + def _check_allow_move_create(self): + for menu in self: + if menu.allow_move_create and not menu.move_create_is_possible: + raise exceptions.ValidationError( + _("Creation of moves is not allowed for menu {}.").format(menu.name) + ) diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index 8b611b17d7..68759df75b 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -18,6 +18,11 @@ widget="many2many_tags" options="{'no_create': 1}" /> + + From 6b96df1a7f48f89daa80224a60a9cfb92905a47b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 24 Jun 2020 07:46:08 +0200 Subject: [PATCH 259/940] pack transfer: add option that creates the move before moving It replaces the single pack putaway as pack put-away and transfer were doing the same thing, except put-away was creating the move and transfer was working only on existing moves. --- shopfloor/services/single_pack_transfer.py | 47 +++++++++++++++---- shopfloor/tests/test_single_pack_transfer.py | 48 ++++++++++++++++++++ 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 76b84a3820..085f851a56 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -85,25 +85,56 @@ def start(self, barcode): ) ) - move_lines = self.env["stock.move.line"].search( + package_level = self.env["stock.package_level"].search( [ ("package_id", "=", package.id), - ("state", "not in", ("cancel", "done")), ("picking_id.picking_type_id", "in", picking_types.ids), ] ) - if not move_lines: + # State is computed, can't use it in the domain. And it's probably faster + # to filter here rather than using a domain on "picking_id.state" that would + # use a sub-search on stock.picking: we shouldn't have dozens of package levels + # for a package. + package_level = package_level.filtered( + lambda pl: pl.state not in ("cancel", "done") + ) + if not package_level: + if self.work.menu.allow_move_create: + package_level = self._create_package_level(package) + + if not package_level: return self._response_for_start( message=self.msg_store.no_pending_operation_for_pack(package) ) - if move_lines[0].package_level_id.is_done: + + if package_level.is_done: return self._response_for_confirm_start( - move_lines[0].package_level_id, - message=self.msg_store.already_running_ask_confirmation(), + package_level, message=self.msg_store.already_running_ask_confirmation() ) - move_lines[0].package_level_id.is_done = True - return self._response_for_scan_location(move_lines[0].package_level_id) + package_level.is_done = True + return self._response_for_scan_location(package_level) + + def _create_package_level(self, package): + # this method can be called only if we have one picking type + # (allow_move_create==True on menu) + assert self.picking_types.ensure_one() + StockPicking = self.env["stock.picking"].with_context( + default_picking_type_id=self.picking_types.id + ) + picking = StockPicking.create({}) + package_level = self.env["stock.package_level"].create( + { + "picking_id": picking.id, + "package_id": package.id, + "location_dest_id": picking.location_dest_id.id, + "company_id": self.env.company.id, + } + ) + package_level._generate_moves() + picking.action_confirm() + picking.action_assign() + return package_level def _is_move_state_valid(self, move): return move.state != "cancel" diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index dd4dca4695..aec7b9ca9a 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -15,6 +15,8 @@ def setUpClassVars(cls, *args, **kwargs): @classmethod def setUpClassBaseData(cls, *args, **kwargs): super().setUpClassBaseData(*args, **kwargs) + # we activate the move creation in tests when needed + cls.menu.sudo().allow_move_create = False cls.pack_a = cls.env["stock.quant.package"].create( {"location_id": cls.stock_location.id} ) @@ -140,6 +142,52 @@ def test_start_no_operation(self): }, ) + def test_start_no_operation_create(self): + """Test /start when there is no operation to move the pack, it is created + + The pre-conditions: + + * The option "Allow Move Creation" is turned on on the menu + * A Pack exists in Stock/Shelf1. + * No stock picking exists to move the Pack from Stock/Shelf1 to + Stock/Shelf2, or the state is not assigned. + + Expected result: + + * Create a stock.picking, move, package level and continue with the + workflow + """ + self.menu.sudo().allow_move_create = True + barcode = self.pack_a.name + params = {"barcode": barcode} + self.picking.do_unreserve() + + # Simulate the client scanning a package's barcode, which + # in turns should start the operation in odoo + response = self.service.dispatch("start", params=params) + + move_line = self.env["stock.move.line"].search( + [("package_id", "=", self.pack_a.id)] + ) + package_level = move_line.package_level_id + + self.assertTrue(package_level.is_done) + + expected_data = { + "id": package_level.id, + "name": package_level.package_id.name, + "location_src": self.data.location(self.shelf1), + "location_dest": self.data.location( + self.picking_type.default_location_dest_id + ), + "picking": self.data.picking(package_level.picking_id), + "product": self.data.product(self.product_a), + } + + self.assert_response( + response, next_state="scan_location", data=expected_data, + ) + def test_start_barcode_not_known(self): """Test /start when the barcode is unknown From e48cf8059ac3fef5fc1669fbf8e71d5d9bae56e8 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 24 Jun 2020 07:55:09 +0200 Subject: [PATCH 260/940] pack putaway: remove It has been replaced by a new option "create move" in the single pack transfer scenario. --- shopfloor/actions/__init__.py | 1 - shopfloor/actions/pack_transfer_validate.py | 35 -- shopfloor/demo/shopfloor_menu_demo.xml | 10 +- shopfloor/demo/stock_picking_type_demo.xml | 15 - shopfloor/models/shopfloor_menu.py | 1 - shopfloor/services/__init__.py | 1 - shopfloor/services/single_pack_putaway.py | 355 ----------- shopfloor/tests/__init__.py | 1 - shopfloor/tests/test_single_pack_putaway.py | 624 -------------------- 9 files changed, 1 insertion(+), 1042 deletions(-) delete mode 100644 shopfloor/actions/pack_transfer_validate.py delete mode 100644 shopfloor/services/single_pack_putaway.py delete mode 100644 shopfloor/tests/test_single_pack_putaway.py diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index 58c70c29f3..ac8c5da74d 100644 --- a/shopfloor/actions/__init__.py +++ b/shopfloor/actions/__init__.py @@ -21,6 +21,5 @@ from . import data_detail from . import completion_info from . import message -from . import pack_transfer_validate from . import search from . import inventory diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py deleted file mode 100644 index 7b80f0aae9..0000000000 --- a/shopfloor/actions/pack_transfer_validate.py +++ /dev/null @@ -1,35 +0,0 @@ -from odoo.addons.component.core import Component - - -# TODO remove -# TODO think BETTER about how we want to share the common methods / workflows -class PackTransferValidateAction(Component): - """Pack Transfer shared business logic - - This component is shared by the "validate" action of the processes: - - * single_pack_putaway - * single_pack_transfer - """ - - _name = "shopfloor.pack.transfer.validate.action" - _inherit = "shopfloor.process.action" - _usage = "pack.transfer.validate" - - def is_move_state_valid(self, move): - return move.state != "cancel" - - def is_dest_location_valid(self, move, scanned_location): - """Forbid a dest location to be used""" - return scanned_location.is_sublocation_of( - move.picking_id.picking_type_id.default_location_dest_id - ) - - def is_dest_location_to_confirm(self, move, scanned_location): - """Destination that could be used but need confirmation""" - move_dest_location = move.move_line_ids[0].location_dest_id - return not scanned_location.is_sublocation_of(move_dest_location) - - def set_destination_and_done(self, move, scanned_location): - move.move_line_ids[0].location_dest_id = scanned_location.id - move._action_done() diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml index 1276f19a98..0c2f671777 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -1,16 +1,8 @@ - - 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 b7adbc2d14..362c96c58a 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 dccb05c842..85000d1508 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/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py deleted file mode 100644 index d50be0ba81..0000000000 --- a/shopfloor/services/single_pack_putaway.py +++ /dev/null @@ -1,355 +0,0 @@ -from odoo import _ - -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""" - - _inherit = "base.shopfloor.process" - _name = "shopfloor.single.pack.putaway" - _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( - next_state="start", message=self.msg_store.no_picking_type() - ) - - def _response_for_several_picking_types(self): - return self._response( - next_state="start", message=self.msg_store.several_picking_types() - ) - - def _response_for_package_not_found(self, barcode): - return self._response( - next_state="start", - message=self.msg_store.package_not_found_for_barcode(barcode), - ) - - def _response_for_forbidden_package(self, barcode, picking_type): - return self._response( - next_state="start", - message=self.msg_store.package_not_allowed_in_src_location( - barcode, picking_type - ), - ) - - def _response_for_forbidden_start(self, existing_operations): - return self._response( - next_state="start", - message={ - "message_type": "error", - "body": _( - "An operation exists in %s %s. " - "You cannot process it with this shopfloor scenario." - ) - % ( - existing_operations[0].picking_id.picking_type_id.name, - existing_operations[0].picking_id.name, - ), - }, - ) - - 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_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}, - } - - def _response_for_start_to_confirm(self, move_line, pack): - return self._response( - data=self._data_after_package_scanned(move_line, pack), - next_state="confirm_start", - message=self.msg_store.already_running_ask_confirmation(), - ) - - def _response_for_start_success(self, move_line, pack): - return self._response( - next_state="scan_location", - message=self.msg_store.scan_destination(), - data=self._data_after_package_scanned(move_line, pack), - ) - - def start(self, barcode): - """Scan a pack barcode""" - # 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: - return self._response_for_no_picking_type() - - 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" - - 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( - [ - ("package_id", "=", pack.id), - ( - "state", - "in", - ( - "assigned", - "draft", - "waiting", - "confirmed", - "partially_available", - ), - ), - ], - limit=1, - ) - if ( - existing_operation - and existing_operation[0].picking_id.picking_type_id != picking_type - ): - return self._response_for_forbidden_start(existing_operation) - elif existing_operation: - return self._response_for_start_to_confirm(existing_operation, pack) - - move_vals = self._prepare_stock_move(picking_type, pack) - move = self.env["stock.move"].create(move_vals) - move._action_confirm(merge=False) - package_level = self._prepare_package_level(pack, move) - move._action_assign() - package_level.is_done = True - # TODO what if we have > 1 move line? - return self._response_for_start_success(move.move_line_ids, pack) - - def _prepare_stock_move(self, picking_type, pack): - # FIXME we consider only one product per pack - assert len(pack.quant_ids) == 1 - product = pack.quant_ids[0].product_id - default_location_dest = picking_type.default_location_dest_id - company = self.env.company - return { - "picking_type_id": picking_type.id, - "product_id": product.id, - "location_id": pack.location_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, - "company_id": company.id, - } - - 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": self.env.company.id, - "picking_id": move.picking_id.id, - } - ) - - def _response_for_package_level_not_found(self): - return self._response( - next_state="start", message=self.msg_store.operation_not_found() - ) - - def _response_for_move_canceled_elsewhere(self): - return self._response( - next_state="start", - message=self.msg_store.operation_has_been_canceled_elsewhere(), - ) - - def _response_for_location_not_found(self, move_line, pack): - return self._response( - next_state="scan_location", - message=self.msg_store.no_location_found(), - data=self._data_after_package_scanned(move_line, pack), - ) - - def _response_for_forbidden_location(self, move_line, pack): - return self._response( - next_state="scan_location", - message=self.msg_store.dest_location_not_allowed(), - data=self._data_after_package_scanned(move_line, pack), - ) - - def _response_for_location_need_confirm(self, move_line, pack, to_location): - return self._response( - next_state="confirm_location", - message=self.msg_store.confirm_location_changed( - move_line.location_dest_id, to_location - ), - data=self._data_after_package_scanned(move_line, pack), - ) - - def _response_for_validate_success(self): - return self._response( - next_state="start", message=self.msg_store.confirm_pack_moved() - ) - - def validate(self, package_level_id, location_barcode, confirmation=False): - """Validate the transfer""" - 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(): - return self._response_for_package_level_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() - - 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) - - if pack_transfer.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 - ) - - pack_transfer.set_destination_and_done(move, scanned_location) - return self._response_for_validate_success() - - def _response_for_move_already_processed(self): - return self._response(next_state="start", message=self.msg_store.already_done()) - - def _response_for_confirm_move_cancellation(self): - return self._response( - next_state="start", message=self.msg_store.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(): - 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.move_line_ids.move_id._action_cancel() - return self._response_for_confirm_move_cancellation() - - -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 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}, - } - - -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 _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": self._schema_for_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"} - ) - - def start(self): - return self._response_schema(next_states={"confirm_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_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}, - }, - }, - } diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 87bc146904..5b470c54df 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 diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py deleted file mode 100644 index cbfcff74b1..0000000000 --- a/shopfloor/tests/test_single_pack_putaway.py +++ /dev/null @@ -1,624 +0,0 @@ -from odoo.tests.common import Form - -from .common import CommonCase - - -class SinglePackPutawayCase(CommonCase): - @classmethod - 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"].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"] - .sudo() - .create( - { - "product_id": cls.product_a.id, - "location_id": cls.dispatch_location.id, - "quantity": 1, - "package_id": cls.pack_a.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_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.pack_a.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) - state_data = response["data"]["scan_location"] - - # Checks: - package_level = self.env["stock.package_level"].browse(state_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.shelf1.id}] - ) - self.assertRecordValues( - move, [{"state": "assigned", "location_dest_id": self.shelf1.id}] - ) - self.assert_response( - response, - next_state="scan_location", - message={"message_type": "info", "body": "Scan the destination location"}, - data={ - "id": self.ANY, - "location_src": { - "id": self.dispatch_location.id, - "name": self.dispatch_location.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}, - }, - ) - - 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, - next_state="start", - message={ - "message_type": "error", - "body": "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.pack_a.name - self.pack_a.location_id = self.shelf1 - params = {"barcode": barcode} - response = self.service.dispatch("start", params=params) - self.assert_response( - response, - next_state="start", - message={ - "message_type": "error", - "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, - ), - }, - ) - - 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.pack_a.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.product_a - 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, - next_state="start", - message={ - "message_type": "error", - "body": "An operation exists in Delivery Orders %s. You cannot" - " process it with this shopfloor scenario." % (picking.name,), - }, - ) - - 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: - - * Pack exists - * A move is created and assigned to move the package, using the same - picking type - - Expected result: - - * return a message to confirm - """ - barcode = self.pack_a.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, - next_state="confirm_start", - message={ - "message_type": "warning", - "body": "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_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}, - }, - ) - - 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.picking_type_ids - 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() - picking.action_assign() - package_level = picking.move_line_ids.package_level_id - 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 - 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 - response = self.service.dispatch( - "validate", - params={ - "package_level_id": package_level.id, - "location_barcode": self.shelf1.barcode, - }, - ) - - self.assert_response( - response, - next_state="start", - message={ - "message_type": "success", - "body": "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.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.assert_response( - response, - next_state="start", - message={ - "message_type": "error", - "body": "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, - next_state="scan_location", - message={ - "message_type": "error", - "body": "No location found for this barcode.", - }, - data=self.ANY, - ) - - 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, - next_state="scan_location", - message={"message_type": "error", "body": "You cannot place it here"}, - data=self.ANY, - ) - - 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() - - 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( - "validate", - params={ - "package_level_id": package_level.id, - "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, - data={ - "id": self.ANY, - "location_src": { - "id": self.dispatch_location.id, - "name": self.dispatch_location.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}, - }, - ) - - 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, - next_state="start", - message={ - "message_type": "success", - "body": "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 - - 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.assert_response( - response, - next_state="start", - message={ - "message_type": "success", - "body": "Canceled, you can scan a new pack.", - }, - ) - - 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.assert_response( - response, - next_state="start", - message={ - "message_type": "success", - "body": "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, - next_state="start", - message={"message_type": "info", "body": "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, - next_state="start", - message={ - "message_type": "error", - "body": "This operation does not exist anymore.", - }, - ) From 8d9d236904e67b1f8c97e27bcfefaf1b6258d58a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 7 Jul 2020 10:46:23 +0200 Subject: [PATCH 261/940] 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 a1f1614a87..9bd822d152 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 bda81fc7f1..75c849ebc7 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 b3afe523ee..324d896479 100644 --- a/shopfloor/views/stock_location.xml +++ b/shopfloor/views/stock_location.xml @@ -10,6 +10,11 @@ From c4f48400af3e25fb9ecde104be03bc7a4c760fea Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 7 Jul 2020 09:26:45 +0200 Subject: [PATCH 262/940] Add option to disable zero check in scenario Currently, it applies only for Cluster Picking, it will apply on Zone Picking and Discrete Order Picking too. When a location becomes empty and the option is active, the user has to validate that the location is really empty. It doesn't really make sense for picking types that move pallets, so they would disable it there. There is an ongoing discussion whether this option should be on the picking type or on the shopfloor menu, it is possible that this option will be moved. --- shopfloor/demo/stock_picking_type_demo.xml | 1 + shopfloor/models/stock_picking_type.py | 6 +++ shopfloor/services/cluster_picking.py | 3 +- shopfloor/tests/test_cluster_picking_scan.py | 39 +++++++++++++++++++- shopfloor/views/stock_picking_type.xml | 12 ++++-- 5 files changed, 56 insertions(+), 5 deletions(-) diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml index 09c8042865..c64cf5060d 100644 --- a/shopfloor/demo/stock_picking_type_demo.xml +++ b/shopfloor/demo/stock_picking_type_demo.xml @@ -28,6 +28,7 @@ internal + Checkout diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py index f974b0257a..cbe326fc8d 100644 --- a/shopfloor/models/stock_picking_type.py +++ b/shopfloor/models/stock_picking_type.py @@ -7,3 +7,9 @@ class StockPickingType(models.Model): shopfloor_menu_ids = fields.Many2many( comodel_name="shopfloor.menu", string="Shopfloor Menus", readonly=True, ) + shopfloor_zero_check = fields.Boolean( + string="Activate Zero Check", + help="For Shopfloor scenarios using it (Cluster Picking, Zone Picking," + " Discrete order Picking), the zero check step will be activated when" + " a location becomes empty after a move.", + ) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 75c849ebc7..88fe45175d 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -622,7 +622,8 @@ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantit ) move_line.write({"qty_done": quantity, "result_package_id": bin_package.id}) - if self._planned_qty_in_location_is_empty( + zero_check = move_line.picking_id.picking_type_id.shopfloor_zero_check + if zero_check and self._planned_qty_in_location_is_empty( move_line.product_id, move_line.location_id ): return self._response_for_zero_check(batch, move_line) diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index 11909cccf0..3c097a4ee8 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -513,9 +513,11 @@ def test_scan_destination_pack_quantity_less(self): # the reserved quantity on the quant must stay the same self.assertRecordValues(quant, [{"quantity": 40.0, "reserved_quantity": 20.0}]) - def test_scan_destination_pack_zero_check(self): + def test_scan_destination_pack_zero_check_activated(self): """Location will be emptied, have to go to zero check""" line = self.one_line_picking.move_line_ids + # ensure we have activated the zero check + self.one_line_picking.picking_type_id.sudo().shopfloor_zero_check = True # Update the quantity in the location to be equal to the line's # so when scan_destination_pack sets the qty_done, the planned # qty should be zero and trigger a zero check @@ -542,6 +544,41 @@ def test_scan_destination_pack_zero_check(self): }, ) + def test_scan_destination_pack_zero_check_disabled(self): + """Location will be emptied, no zero check, continue""" + line = self.one_line_picking.move_line_ids + # ensure we have deactivated the zero check + self.one_line_picking.picking_type_id.sudo().shopfloor_zero_check = False + # Update the quantity in the location to be equal to the line's + # so when scan_destination_pack sets the qty_done, the planned + # qty should be zero and trigger a zero check + self._update_qty_in_location( + line.location_id, line.product_id, line.product_uom_qty + ) + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + "barcode": self.bin1.name, + "quantity": line.product_uom_qty, + }, + ) + + next_line = self.batch.picking_ids.move_line_ids[1] + # continue to the next one, no zero check + self.assert_response( + response, + next_state="start_line", + data=self._line_data(next_line), + message={ + "message_type": "success", + "body": "{} {} put in {}".format( + line.qty_done, line.product_id.display_name, self.bin1.name + ), + }, + ) + class ClusterPickingIsZeroCase(ClusterPickingCommonCase): """Tests covering the /is_zero endpoint diff --git a/shopfloor/views/stock_picking_type.xml b/shopfloor/views/stock_picking_type.xml index 25313e1586..ad90a59d5b 100644 --- a/shopfloor/views/stock_picking_type.xml +++ b/shopfloor/views/stock_picking_type.xml @@ -5,9 +5,15 @@ stock.picking.type - - - + + + + + + From aa54cfc83419878e3f3755f8b56c34e5b4af7139 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 10 Jun 2020 15:39:00 +0200 Subject: [PATCH 263/940] location content transfer: add skeleton --- shopfloor/demo/shopfloor_menu_demo.xml | 9 + shopfloor/demo/stock_picking_type_demo.xml | 15 + shopfloor/models/shopfloor_menu.py | 1 + shopfloor/models/stock_picking.py | 6 + shopfloor/services/__init__.py | 1 + .../services/location_content_transfer.py | 439 ++++++++++++++++++ shopfloor/tests/__init__.py | 1 + .../test_location_content_transfer_base.py | 20 + 8 files changed, 492 insertions(+) create mode 100644 shopfloor/services/location_content_transfer.py create mode 100644 shopfloor/tests/test_location_content_transfer_base.py diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml index 0c2f671777..2880a74a80 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -36,4 +36,13 @@ eval="[(4, ref('shopfloor.picking_type_delivery_demo'))]" /> + + 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 c64cf5060d..2dbedd83ad 100644 --- a/shopfloor/demo/stock_picking_type_demo.xml +++ b/shopfloor/demo/stock_picking_type_demo.xml @@ -60,4 +60,19 @@ + + Location Content Transfer + LCT + + + + + + + + + internal + + + diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 362c96c58a..269d87aa01 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -37,6 +37,7 @@ def _selection_scenario(self): ("cluster_picking", "Cluster Picking"), ("checkout", "Checkout/Packing"), ("delivery", "Delivery"), + ("location_content_transfer", "Location Content Transfer"), ] @api.depends("scenario", "picking_type_ids") diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py index d266f38cce..cdcd93cd08 100644 --- a/shopfloor/models/stock_picking.py +++ b/shopfloor/models/stock_picking.py @@ -30,3 +30,9 @@ def _calc_weight(self): for move_line in self.mapped("move_line_ids"): weight += move_line.product_qty * move_line.product_id.weight return weight + + def _create_backorder(self): + if self.env.context.get("_sf_no_backorder"): + return self.browse() + else: + return super()._create_backorder() diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 85000d1508..09470e83ee 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -14,4 +14,5 @@ from . import checkout from . import cluster_picking from . import delivery +from . import location_content_transfer from . import single_pack_transfer diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py new file mode 100644 index 0000000000..d8e98a9366 --- /dev/null +++ b/shopfloor/services/location_content_transfer.py @@ -0,0 +1,439 @@ +from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import Component + +from .service import to_float + +# NOTE for the implementation: share several similarities with the "cluster +# picking" scenario + + +class LocationContentTransfer(Component): + """ + Methods for the Location Content Transfer Process + + Move the full content of a location to one or another location. + + Generally used to move a pallet with multiple boxes to either: + + * 1 destination location, unloading the full pallet + * To multiple destination locations, unloading one product/lot per + locations + * To multiple destination locations, unloading one product/lot per + locations and then unloading all remaining product/lot to a single final + destination + + The move lines must exist beforehand, the workflow only moves them. + + Expected: + + * All move lines and package level have a destination set, and are done. + + 2 complementary actions are possible on the screens allowing to move a line: + + * Declare a stock out for a product or package (nothing found in the + location) + * Skip to the next line (will be asked again at the end) + + Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP + """ + + _inherit = "base.shopfloor.process" + _name = "shopfloor.location.content.transfer" + _usage = "location_content_transfer" + _description = __doc__ + + def _response_for_start(self, message=None): + """Transition to the 'start' state""" + return self._response(next_state="start", message=message) + + def _response_for_scan_destination_all(self, location, message=None): + """Transition to the 'scan_destination_all' state + + The client screen shows a summary of all the lines and packages + to move to a single destination. + """ + return self._response( + next_state="scan_destination_all", + data=self._data_content_all_for_location(location), + message=message, + ) + + def _response_for_start_single( + self, location, package_level=None, line=None, message=None + ): + """Transition to the 'start_single' state + + The client screen shows details of the package level or move line to move. + """ + assert package_level or line + return self._response( + next_state="start_single", + data=self._data_content_line_for_location( + location, package_level=package_level, line=line + ), + message=message, + ) + + def _response_for_scan_destination( + self, location, package_level=None, line=None, message=None + ): + """Transition to the 'start_single' state + + The client screen shows details of the package level or move line to move. + """ + assert package_level or line + return self._response( + next_state="scan_destination", + data=self._data_content_line_for_location( + location, package_level=package_level, line=line + ), + message=message, + ) + + def _data_content_all_for_location(self, location): + return {} + + def _data_content_line_for_location(self, location, package_level=None, line=None): + assert package_level or line + return {} + + def start_or_recover(self): + """Start a new session or recover an existing one + + If the current user had transfers in progress in this scenario + and reopen the menu, we want to directly reopen the screens to choose + destinations. Otherwise, we go to the "start" state. + """ + # TODO if we find any stock.picking != done with current user as user id + # and with move lines having a qty_done > 0, in the current picking types, + # reach start_single or scan_destination_all + return self._response_for_start() + + def scan_location(self, barcode): + """Scan start location + + Called at the beginning at the workflow to select the location from which + we want to move the content. + + All the move lines and package levels must have the same picking type. + + When move lines and package levels have different destinations, the + first line without package level or package level is sent to the client. + + Transitions: + * start: location not found, ... + * scan_destination_all: if the destination of all the lines and package + levels have the same destination + * start_single: if any line or package level has a different destination + """ + return self._response() + + def set_destination_all(self, location_id, barcode): + """Scan destination location for all the moves of the location + + barcode is a stock.location for the destination + + Transitions: + * scan_destination_all: invalid destination or could not set moves to done + * start: moves are done + """ + return self._response() + + def go_to_single(self, location_id): + """Ask the first move line or package level + + If the user was brought to the screen allowing to move everything to + the same location, but they want to move them to different locations, + this method will return the first move line or package level. + + Transitions: + * start: no remaining lines in the location + * start_single: if any line or package level has a different destination + """ + return self._response() + + def scan_package(self, location_id, package_level_id, barcode): + """Scan a package level to move + + It validates that the user scanned the correct package, lot or product. + + Transitions: + * start: no remaining lines in the location + * start_single: barcode not found, ... + * scan_destination: the barcode matches + """ + return self._response() + + def scan_line(self, location_id, move_line_id, barcode): + """Scan a move line to move + + It validates that the user scanned the correct package, lot or product. + + Transitions: + * start: no remaining lines in the location + * start_single: barcode not found, ... + * scan_destination: the barcode matches + """ + return self._response() + + def set_destination_package(self, location_id, package_level_id, barcode): + """Scan destination location for package level + + If the move has other move lines / package levels it has to be split + so we can post only this part. + + After the destination is set, the move is set to done. + + Beware, when _action_done() is called on the move, the normal behavior + of Odoo would be to create a backorder transfer. We don't want this or + we would have a backorder per move. The context key + ``_sf_no_backorder`` disables the creation of backorders, it must be set + on all moves, but the last one of a transfer (so in case something was not + available, a backorder is created). + + Transitions: + * scan_destination: invalid destination or could not + * start_single: continue with the next package level / line + """ + return self._response() + + def set_destination_line(self, location_id, move_line_id, quantity, barcode): + """Scan destination location for move line + + If the quantity < qty of the line, split the move and reserve it. + If the move has other move lines / package levels it has to be split + so we can post only this part. + + After the destination and quantity are set, the move is set to done. + + Beware, when _action_done() is called on the move, the normal behavior + of Odoo would be to create a backorder transfer. We don't want this or + we would have a backorder per move. The context key + ``_sf_no_backorder`` disables the creation of backorders, it must be set + on all moves, but the last one of a transfer (so in case something was not + available, a backorder is created). + + Transitions: + * scan_destination: invalid destination or could not + * start_single: continue with the next package level / line + """ + return self._response() + + def postpone_package(self, location_id, package_level_id): + """Mark a package level as postponed and return the next level/line + + NOTE for implementation: Use the field "shopfloor_postponed", which has + to be included in the sort to get the next lines. + + Transitions: + * start_single: continue with the next package level / line + """ + return self._response() + + def postpone_line(self, location_id, move_line_id): + """Mark a move line as postponed and return the next level/line + + NOTE for implementation: Use the field "shopfloor_postponed", which has + to be included in the sort to get the next lines. + + Transitions: + * start_single: continue with the next package level / line + """ + return self._response() + + def stock_out_package(self, location_id, package_level_id): + """Declare a stock out on a package level + + It first ensures the stock.move only has this package level. If not, it + splits the move to have no side-effect on the other package levels/move + lines. + + It unreserves the move, create an inventory at 0 in the move's source + location, create a second draft inventory (if none exists) to check later. + Finally, it cancels the move. + + Transitions: + * start: no more content to move + * start_single: continue with the next package level / line + """ + return self._response() + + def stock_out_line(self, location_id, move_line_id): + """Declare a stock out on a move line + + It first ensures the stock.move only has this move line. If not, it + splits the move to have no side-effect on the other package levels/move + lines. + + It unreserves the move, create an inventory at 0 in the move's source + location, create a second draft inventory (if none exists) to check later. + Finally, it cancels the move. + + Transitions: + * start: no more content to move + * start_single: continue with the next package level / line + """ + return self._response() + + +class ShopfloorLocationContentTransferValidator(Component): + """Validators for the Location Content Transfer endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.location.content.transfer.validator" + _usage = "location_content_transfer.validator" + + def start_or_recover(self): + return {} + + def scan_location(self): + return {"barcode": {"required": True, "type": "string"}} + + def set_destination_all(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def go_to_single(self): + return {"location_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def scan_package(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def scan_line(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def set_destination_package(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def set_destination_line(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "quantity": {"coerce": to_float, "required": True, "type": "float"}, + "barcode": {"required": True, "type": "string"}, + } + + def postpone_package(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def postpone_line(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def stock_out_package(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def stock_out_line(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + +class ShopfloorLocationContentTransferValidatorResponse(Component): + """Validators for the Location Content Transfer endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.location.content.transfer.validator.response" + _usage = "location_content_transfer.validator.response" + + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + return { + "start": {}, + "scan_destination_all": self._schema_all, + "start_single": self._schema_single, + "scan_destination": self._schema_single, + } + + @property + def _schema_all(self): + package_schema = self.schemas.package() + move_line_schema = self.schemas.move_line() + return { + # we'll display all the packages and move lines *without package + # levels* + "packages": self.schemas._schema_list_of(package_schema), + "move_lines": self.schemas._schema_list_of(move_line_schema), + } + + @property + def _schema_single(self): + schema_package_level = self.schemas.package_level() + schema_move_line = self.schemas.move_line() + return { + # we'll have one or the other... + # TODO add the package in the package_level + "package_level": self.schemas._schema_dict_of(schema_package_level), + "move_line": self.schemas._schema_dict_of(schema_move_line), + } + + def start_or_recover(self): + return self._response_schema( + next_states={"start", "scan_destination_all", "start_single"} + ) + + def scan_location(self): + return self._response_schema( + next_states={"start", "scan_destination_all", "start_single"} + ) + + def set_destination_all(self): + return self._response_schema(next_states={"start", "scan_destination_all"}) + + def go_to_single(self): + return self._response_schema(next_states={"start", "start_single"}) + + def scan_package(self): + return self._response_schema( + next_states={"start", "start_single", "scan_destination"} + ) + + def scan_line(self): + return self._response_schema( + next_states={"start", "start_single", "scan_destination"} + ) + + def set_destination_package(self): + return self._response_schema(next_states={"start_single", "scan_destination"}) + + def set_destination_line(self): + return self._response_schema(next_states={"start_single", "scan_destination"}) + + def postpone_package(self): + return self._response_schema(next_states={"start_single"}) + + def postpone_line(self): + return self._response_schema(next_states={"start_single"}) + + def stock_out_package(self): + return self._response_schema(next_states={"start", "start_single"}) + + def stock_out_line(self): + return self._response_schema(next_states={"start", "start_single"}) diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 5b470c54df..fb9f55a585 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -33,3 +33,4 @@ from . import test_delivery_set_qty_done_line from . import test_delivery_list_stock_picking from . import test_delivery_select +from . import test_location_content_transfer_base diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py new file mode 100644 index 0000000000..f66e8be98f --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -0,0 +1,20 @@ +from .common import CommonCase + + +class LocationContentTransferCommonCase(CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_location_content_transfer") + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + cls.wh = cls.profile.warehouse_id + cls.picking_type = cls.menu.picking_type_ids + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + + def setUp(self): + super().setUp() + with self.work_on_services(menu=self.menu, profile=self.profile) as work: + self.service = work.component(usage="location_content_transfer") From 221a785c06d77e3d07fa7a56371aa4cc82997688 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 17 Jun 2020 14:49:08 +0200 Subject: [PATCH 264/940] location transfer: implement /start_or_recover, /scan_location --- shopfloor/actions/__init__.py | 1 + shopfloor/actions/data.py | 25 +++ .../location_content_transfer_sorter.py | 56 ++++++ shopfloor/actions/message.py | 14 ++ .../services/location_content_transfer.py | 142 +++++++++++--- shopfloor/services/schema.py | 5 +- shopfloor/services/single_pack_transfer.py | 11 +- shopfloor/tests/__init__.py | 1 + shopfloor/tests/common.py | 6 +- .../test_location_content_transfer_base.py | 56 ++++++ .../test_location_content_transfer_start.py | 174 ++++++++++++++++++ 11 files changed, 458 insertions(+), 33 deletions(-) create mode 100644 shopfloor/actions/location_content_transfer_sorter.py create mode 100644 shopfloor/tests/test_location_content_transfer_start.py diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index ac8c5da74d..42f45d7eee 100644 --- a/shopfloor/actions/__init__.py +++ b/shopfloor/actions/__init__.py @@ -20,6 +20,7 @@ from . import data from . import data_detail from . import completion_info +from . import location_content_transfer_sorter from . import message from . import search from . import inventory diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 326e19ff91..c2efca8ba6 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -148,6 +148,31 @@ def _move_line_parser(self): ("location_dest_id:location_dest", self._location_parser), ] + def package_level(self, record, **kw): + data = self._jsonify(record, self._package_level_parser) + if data: + data.update( + { + # cannot use sub-parser here + # because location_id of the package level may be + # empty, we have to go get the picking's one + "location_src": self.location(record.picking_id.location_id, **kw), + } + ) + return data + + def package_levels(self, records, **kw): + return [self.package_level(rec, **kw) for rec in records] + + @property + def _package_level_parser(self): + return [ + "id", + "is_done", + ("package_id:package", self._package_parser), + ("location_dest_id:location_dest", self._location_parser), + ] + def product(self, record, **kw): return self._jsonify(record, self._product_parser, **kw) diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py new file mode 100644 index 0000000000..6777636dca --- /dev/null +++ b/shopfloor/actions/location_content_transfer_sorter.py @@ -0,0 +1,56 @@ +from odoo.addons.component.core import Component + + +class LocationContentTransferSorter(Component): + + _name = "shopfloor.location.content.transfer.sorter" + _inherit = "shopfloor.process.action" + _usage = "location_content_transfer.sorter" + + def __init__(self, work_context): + super().__init__(work_context) + self._pickings = self.env["stock.picking"].browse() + self._content = None + + def feed_pickings(self, pickings): + self._pickings |= pickings + + def move_lines(self): + return self._pickings.move_line_ids.filtered( + # lines without package level only (raw products) + lambda line: not line.package_level_id + and line.state not in ("cancel", "done") + ) + + def package_levels(self): + return self._pickings.package_level_ids.filtered( + lambda level: level.state not in ("cancel", "done") + ) + + @staticmethod + def _sort_key(content): + # content can be either a move line, either a package + # level + return ( + # sort by similar destination + content.location_dest_id.complete_name, + # lines before packages (if we have raw products and packages, raw + # will be on top? wild guess) + 0 if content._name == "stock.move.line" else 1, + # to have a deterministic sort + content.id, + ) + + def sort(self): + content = [line for line in self.move_lines()] + [ + level for level in self.package_levels() + ] + self._content = sorted(content, key=self._sort_key) + + def __iter__(self): + if self._content is None: + self.sort() + return iter(self._content) + + def __next__(self): + return next(iter(self)) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 143bb83e7b..db160ee3f4 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -299,6 +299,14 @@ def transfer_complete(self, picking): "body": _("Transfer {} complete").format(picking.name), } + def location_content_transfer_complete(self, location): + return { + "message_type": "success", + "body": _("Location Content Transfer from {} complete").format( + location.name + ), + } + def transfer_confirm_done(self): return { "message_type": "warning", @@ -315,3 +323,9 @@ def transfer_no_qty_done(self): "No quantity has been processed, unable to complete the transfer." ), } + + def recovered_previous_session(self): + return { + "message_type": "info", + "body": _("Recovered previous session."), + } diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index d8e98a9366..9effb63b7e 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -1,3 +1,5 @@ +from odoo import _ + from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -46,7 +48,7 @@ def _response_for_start(self, message=None): """Transition to the 'start' state""" return self._response(next_state="start", message=message) - def _response_for_scan_destination_all(self, location, message=None): + def _response_for_scan_destination_all(self, location, pickings=None, message=None): """Transition to the 'scan_destination_all' state The client screen shows a summary of all the lines and packages @@ -54,48 +56,94 @@ def _response_for_scan_destination_all(self, location, message=None): """ return self._response( next_state="scan_destination_all", - data=self._data_content_all_for_location(location), + data=self._data_content_all_for_location(location, pickings=pickings), message=message, ) - def _response_for_start_single( - self, location, package_level=None, line=None, message=None - ): + def _response_for_start_single(self, location, next_content, message=None): """Transition to the 'start_single' state The client screen shows details of the package level or move line to move. """ - assert package_level or line return self._response( next_state="start_single", - data=self._data_content_line_for_location( - location, package_level=package_level, line=line - ), + data=self._data_content_line_for_location(location, next_content), message=message, ) - def _response_for_scan_destination( - self, location, package_level=None, line=None, message=None - ): + def _response_for_scan_destination(self, location, next_content, message=None): """Transition to the 'start_single' state The client screen shows details of the package level or move line to move. """ - assert package_level or line return self._response( next_state="scan_destination", - data=self._data_content_line_for_location( - location, package_level=package_level, line=line - ), + data=self._data_content_line_for_location(location, next_content), message=message, ) - def _data_content_all_for_location(self, location): - return {} + def _data_content_all_for_location(self, location, pickings=None): + if not pickings: + # TODO get pickings from location + raise NotImplementedError("to do: get pickings from location") + sorter = self.actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + lines = sorter.move_lines() + package_levels = sorter.package_levels() + return { + "move_lines": self.data.move_lines(lines), + "package_levels": self.data.package_levels(package_levels), + } - def _data_content_line_for_location(self, location, package_level=None, line=None): - assert package_level or line - return {} + def _data_content_line_for_location(self, location, next_content): + assert next_content._name in ("stock.move.line", "stock.package_level") + line_data = ( + self.data.move_line(next_content) + if next_content._name == "stock.move.line" + else None + ) + level_data = ( + self.data.package_level(next_content) + if next_content._name == "stock.package_level" + else None + ) + return {"move_line": line_data, "package_level": level_data} + + def _router_single_or_all_destination(self, pickings, message=None): + location = pickings.mapped("location_id") + if len(pickings.mapped("move_line_ids.location_dest_id")) == 1: + return self._response_for_scan_destination_all( + location, pickings=pickings, message=message + ) + else: + sorter = self.actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + try: + next_content = next(sorter) + except StopIteration: + # TODO test + return self._response_for_start( + message=self.msg_store.location_content_transfer_complete(location) + ) + return self._response_for_start_single( + location, next_content, message=message + ) + + def _domain_recover_pickings(self): + return [ + ("user_id", "=", self.env.uid), + ("state", "in", ("assigned", "partially_available")), + ("picking_type_id", "in", self.picking_types.ids), + ] + + def _search_recover_pickings(self): + candidate_pickings = self.env["stock.picking"].search( + self._domain_recover_pickings() + ) + started_pickings = candidate_pickings.filtered( + lambda picking: any(line.qty_done for line in picking.move_line_ids) + ) + return started_pickings def start_or_recover(self): """Start a new session or recover an existing one @@ -104,11 +152,24 @@ def start_or_recover(self): and reopen the menu, we want to directly reopen the screens to choose destinations. Otherwise, we go to the "start" state. """ - # TODO if we find any stock.picking != done with current user as user id - # and with move lines having a qty_done > 0, in the current picking types, - # reach start_single or scan_destination_all + started_pickings = self._search_recover_pickings() + if started_pickings: + return self._router_single_or_all_destination( + started_pickings, message=self.msg_store.recovered_previous_session() + ) return self._response_for_start() + def _find_location_move_lines_domain(self, location): + return [ + ("location_id", "=", location.id), + ("qty_done", "=", 0), + ] + + def _find_location_move_lines(self, location): + return self.env["stock.move.line"].search( + self._find_location_move_lines_domain(location) + ) + def scan_location(self, barcode): """Scan start location @@ -126,7 +187,34 @@ def scan_location(self, barcode): levels have the same destination * start_single: if any line or package level has a different destination """ - return self._response() + location = self.actions_for("search").location_from_scan(barcode) + if not location: + return self._response_for_start(message=self.msg_store.barcode_not_found()) + move_lines = self._find_location_move_lines(location) + pickings = move_lines.mapped("picking_id") + picking_types = pickings.mapped("picking_type_id") + + if len(picking_types) > 1: + return self._response_for_start( + message={ + "message_type": "error", + "body": _("This location content can't be moved at once."), + } + ) + if picking_types - self.picking_types: + return self._response_for_start( + message={ + "message_type": "error", + "body": _("This location content can't be moved using this menu."), + } + ) + + for line in move_lines: + line.qty_done = line.product_uom_qty + + pickings.user_id = self.env.uid + + return self._router_single_or_all_destination(pickings) def set_destination_all(self, location_id, barcode): """Scan destination location for all the moves of the location @@ -374,12 +462,12 @@ def _states(self): @property def _schema_all(self): - package_schema = self.schemas.package() + package_level_schema = self.schemas.package_level() move_line_schema = self.schemas.move_line() return { # we'll display all the packages and move lines *without package # levels* - "packages": self.schemas._schema_list_of(package_schema), + "package_levels": self.schemas._schema_list_of(package_level_schema), "move_lines": self.schemas._schema_list_of(move_line_schema), } diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index f894f82250..c019d057e5 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -170,9 +170,8 @@ def picking_batch(self, with_pickings=False): def package_level(self): return { "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, + "is_done": {"type": "boolean", "nullable": False, "required": True}, + "package": {"type": "dict", "schema": self.package()}, "location_src": {"type": "dict", "schema": self.location()}, "location_dest": {"type": "dict", "schema": self.location()}, - "product": {"type": "dict", "schema": self.product()}, - "picking": {"type": "dict", "schema": self.picking()}, } diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 085f851a56..2306432b18 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -13,6 +13,7 @@ class SinglePackTransfer(Component): def _data_after_package_scanned(self, package_level): move_line = package_level.move_line_ids[0] package = package_level.package_id + # TODO use data.package_level (but the "name" moves in "package.name") return { "id": package_level.id, "name": package.name, @@ -288,4 +289,12 @@ def validate(self): @property def _schema_for_package_level_details(self): - return self.schemas.package_level() + # TODO use schemas.package_level (but the "name" moves in "package.name") + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "location_src": {"type": "dict", "schema": self.schemas.location()}, + "location_dest": {"type": "dict", "schema": self.schemas.location()}, + "product": {"type": "dict", "schema": self.schemas.product()}, + "picking": {"type": "dict", "schema": self.schemas.picking()}, + } diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index fb9f55a585..83efe92bcf 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -34,3 +34,4 @@ from . import test_delivery_list_stock_picking from . import test_delivery_select from . import test_location_content_transfer_base +from . import test_location_content_transfer_start diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 1f83cde1eb..9fbd131bec 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -288,13 +288,15 @@ def _update_qty_in_location( ) @classmethod - def _fill_stock_for_moves(cls, moves, in_package=False, in_lot=False): + def _fill_stock_for_moves( + cls, moves, in_package=False, in_lot=False, location=False + ): product_locations = {} package = None if in_package: package = cls.env["stock.quant.package"].create({}) for move in moves: - key = (move.product_id, move.location_id) + key = (move.product_id, location or move.location_id) product_locations.setdefault(key, 0) product_locations[key] += move.product_qty for (product, location), qty in product_locations.items(): diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py index f66e8be98f..c5ebe0569c 100644 --- a/shopfloor/tests/test_location_content_transfer_base.py +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -13,8 +13,64 @@ def setUpClassVars(cls, *args, **kwargs): @classmethod def setUpClassBaseData(cls, *args, **kwargs): super().setUpClassBaseData(*args, **kwargs) + cls.content_loc = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Content Location", + "barcode": "Content", + "location_id": cls.picking_type.default_location_src_id.id, + } + ) + ) def setUp(self): super().setUp() with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="location_content_transfer") + + def _simulate_pickings_selected(self, pickings): + """Create a state as if pickings has been selected + + ... during a Location content transfer. + + It means a user scanned the location with the pickings. They are: + + * assigned to the user + * the qty_done of all their move lines is set to they reserved qty + + """ + pickings.user_id = self.env.uid + for line in pickings.mapped("move_line_ids"): + line.qty_done = line.product_uom_qty + + def assert_response_start(self, response, message=None): + self.assert_response(response, next_state="start", message=message) + + def assert_response_scan_destination_all(self, response, pickings, message=None): + # this code is repeated from the implementation, not great, but we + # mostly want to ensure the selection of pickings is right, and the + # data methods have their own tests + lines = pickings.move_line_ids.filtered(lambda line: not line.package_level_id) + package_levels = pickings.package_level_ids + self.assert_response( + response, + next_state="scan_destination_all", + data={ + "move_lines": self.data.move_lines(lines), + "package_levels": self.data.package_levels(package_levels), + }, + message=message, + ) + + def assert_response_start_single(self, response, pickings, message=None): + sorter = self.service.actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + location = pickings.mapped("location_id") + self.assert_response( + response, + next_state="start_single", + data=self.service._data_content_line_for_location(location, next(sorter)), + message=message, + ) diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py new file mode 100644 index 0000000000..378048a7bc --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_start.py @@ -0,0 +1,174 @@ +from .test_location_content_transfer_base import LocationContentTransferCommonCase + + +class LocationContentTransferStartCase(LocationContentTransferCommonCase): + """Tests for start state and recover + + Endpoints: + + * /start_or_recover + * /scan_location + """ + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + products = cls.product_a + cls.product_b + cls.product_c + cls.product_d + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.picking1 = picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking2 = picking2 = cls._create_picking( + lines=[(cls.product_c, 10), (cls.product_d, 10)] + ) + cls.pickings = picking1 | picking2 + cls._fill_stock_for_moves( + picking1.move_lines, in_package=True, location=cls.content_loc + ) + cls._fill_stock_for_moves(picking2.move_lines, location=cls.content_loc) + cls.pickings.action_assign() + + def test_start_fresh(self): + """Start a fresh session when there is no transfer to recover""" + response = self.service.dispatch("start_or_recover", params={}) + self.assert_response(response, next_state="start") + + def test_start_recover_destination_all(self): + """Recover transfers, all move lines have the same destination""" + self._simulate_pickings_selected(self.pickings) + # all lines go to the same destination (shelf1) + self.assertEqual(len(self.pickings.mapped("move_line_ids.location_dest_id")), 1) + + response = self.service.dispatch("start_or_recover", params={}) + self.assert_response_scan_destination_all( + response, + self.pickings, + message=self.service.msg_store.recovered_previous_session(), + ) + + def test_start_recover_destination_single(self): + """Recover transfers, at least one move line has a different destination""" + self._simulate_pickings_selected(self.pickings) + self.picking1.package_level_ids.location_dest_id = self.shelf2 + # we have different destinations + self.assertEqual(len(self.pickings.mapped("move_line_ids.location_dest_id")), 2) + response = self.service.dispatch("start_or_recover", params={}) + self.assert_response_start_single( + response, + self.pickings, + message=self.service.msg_store.recovered_previous_session(), + ) + + def test_scan_location_not_found(self): + """Scan a location with content to transfer, barcode not found""" + response = self.service.dispatch( + "scan_location", params={"barcode": "NOT_FOUND"} + ) + self.assert_response_start( + response, message=self.service.msg_store.barcode_not_found() + ) + + def test_scan_location_find_content_destination_all(self): + """Scan a location with content to transfer, all dest. identical""" + # all lines go to the same destination (shelf1) + self.assertEqual(len(self.pickings.mapped("move_line_ids.location_dest_id")), 1) + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + self.assert_response_scan_destination_all(response, self.pickings) + self.assertRecordValues( + self.pickings, [{"user_id": self.env.uid}, {"user_id": self.env.uid}] + ) + self.assertRecordValues( + self.pickings.move_line_ids, + [ + {"qty_done": 10.0}, + {"qty_done": 10.0}, + {"qty_done": 10.0}, + {"qty_done": 10.0}, + ], + ) + self.assertRecordValues(self.picking1.package_level_ids, [{"is_done": True}]) + + def test_scan_location_find_content_destination_single(self): + """Scan a location with content to transfer, different destinations""" + self.picking1.package_level_ids.location_dest_id = self.shelf2 + # we have different destinations + self.assertEqual(len(self.pickings.mapped("move_line_ids.location_dest_id")), 2) + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + self.assert_response_start_single(response, self.pickings) + self.assertRecordValues( + self.pickings, [{"user_id": self.env.uid}, {"user_id": self.env.uid}] + ) + self.assertRecordValues( + self.pickings.move_line_ids, + [ + {"qty_done": 10.0}, + {"qty_done": 10.0}, + {"qty_done": 10.0}, + {"qty_done": 10.0}, + ], + ) + self.assertRecordValues(self.picking1.package_level_ids, [{"is_done": True}]) + + def test_scan_location_different_picking_type(self): + """Content has different picking types, can't move""" + picking_other_type = self._create_picking( + picking_type=self.wh.pick_type_id, lines=[(self.product_a, 10)] + ) + self._fill_stock_for_moves( + picking_other_type.move_lines, location=self.content_loc + ) + picking_other_type.action_assign() + + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + self.assert_response_start( + response, + message={ + "message_type": "error", + "body": "This location content can't be moved at once.", + }, + ) + + +class LocationContentTransferStartSpecialCase(LocationContentTransferCommonCase): + """Tests for start state and recover (special cases without setup) + + Endpoints: + + * /start_or_recover + * /scan_location + """ + + def test_scan_location_wrong_picking_type(self): + """Content has different picking type than menu""" + picking = self._create_picking( + picking_type=self.wh.pick_type_id, + lines=[(self.product_a, 10), (self.product_b, 10)], + ) + self._fill_stock_for_moves( + picking.move_lines, in_package=True, location=self.content_loc + ) + picking.action_assign() + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + self.assert_response_start( + response, + message={ + "message_type": "error", + "body": "This location content can't be moved using this menu.", + }, + ) From b120c651cebe5191434a2c931c5ae0ec13bab6b8 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 19 Jun 2020 08:09:38 +0200 Subject: [PATCH 265/940] checkout: fix state change when picking not found --- shopfloor/services/checkout.py | 58 +++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index d73ee56489..3f08861d8a 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -308,7 +308,9 @@ def scan_line(self, picking_id, barcode): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) search = self.actions_for("search") @@ -457,7 +459,9 @@ def select_line(self, picking_id, package_id=None, move_line_id=None): picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) selection_lines = self._lines_to_pack(picking) if not selection_lines: @@ -475,7 +479,9 @@ def _change_line_qty( ): picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) move_lines = self.env["stock.move.line"].browse(move_line_ids).exists() @@ -669,7 +675,9 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) search = self.actions_for("search") selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() @@ -718,7 +726,9 @@ def new_package(self, picking_id, selected_line_ids): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() return self._create_and_assign_new_packaging(picking, selected_lines) @@ -734,7 +744,9 @@ def no_package(self, picking_id, selected_line_ids): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() selected_lines.write( {"shopfloor_checkout_done": True, "result_package_id": False} @@ -759,7 +771,9 @@ def list_dest_package(self, picking_id, selected_line_ids): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) lines = self.env["stock.move.line"].browse(selected_line_ids).exists() return self._response_for_select_dest_package(picking, lines) @@ -797,7 +811,9 @@ def scan_dest_package(self, picking_id, selected_line_ids, barcode): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) lines = self.env["stock.move.line"].browse(selected_line_ids).exists() search = self.actions_for("search") package = search.package_from_scan(barcode) @@ -817,7 +833,9 @@ def set_dest_package(self, picking_id, selected_line_ids, package_id): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) lines = self.env["stock.move.line"].browse(selected_line_ids).exists() package = self.env["stock.quant.package"].browse(package_id).exists() return self._set_dest_package_from_selection(picking, lines, package) @@ -830,7 +848,9 @@ def summary(self, picking_id): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) return self._response_for_summary(picking) def _get_allowed_packaging(self): @@ -848,7 +868,9 @@ def list_packaging(self, picking_id, package_id): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) package = self.env["stock.quant.package"].browse(package_id).exists() packaging_list = self._get_allowed_packaging() @@ -863,7 +885,9 @@ def set_packaging(self, picking_id, package_id, packaging_id): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) package = self.env["stock.quant.package"].browse(package_id).exists() packaging = self.env["product.packaging"].browse(packaging_id).exists() @@ -898,7 +922,9 @@ def cancel_line(self, picking_id, package_id=None, line_id=None): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) package = self.env["stock.quant.package"].browse(package_id).exists() line = self.env["stock.move.line"].browse(line_id).exists() @@ -941,7 +967,9 @@ def done(self, picking_id, confirmation=False): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) lines = picking.move_line_ids if not confirmation: if not all(line.qty_done == line.product_uom_qty for line in lines): @@ -1167,7 +1195,7 @@ def _schema_stock_picking(self, lines_with_packaging=False): { "move_lines": self.schemas._schema_list_of( self.schemas.move_line(with_packaging=lines_with_packaging) - ), + ) } ) return {"picking": self.schemas._schema_dict_of(schema, required=True)} From bacd5c780f2aedbefee9b007aa197c527f6a4379 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 19 Jun 2020 10:53:14 +0200 Subject: [PATCH 266/940] location transfer: implement /set_destination_all --- .../location_content_transfer_sorter.py | 1 + .../services/location_content_transfer.py | 149 +++++++++++++++--- shopfloor/tests/__init__.py | 1 + .../test_location_content_transfer_base.py | 23 ++- ...on_content_transfer_set_destination_all.py | 142 +++++++++++++++++ 5 files changed, 292 insertions(+), 24 deletions(-) create mode 100644 shopfloor/tests/test_location_content_transfer_set_destination_all.py diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py index 6777636dca..a79bc69242 100644 --- a/shopfloor/actions/location_content_transfer_sorter.py +++ b/shopfloor/actions/location_content_transfer_sorter.py @@ -32,6 +32,7 @@ def _sort_key(content): # content can be either a move line, either a package # level return ( + # TODO add postponed (need to be added to package_level) # sort by similar destination content.location_dest_id.complete_name, # lines before packages (if we have raw products and packages, raw diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 9effb63b7e..51bc7ea5bc 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -9,6 +9,9 @@ # picking" scenario +# TODO add picking and package content in package level? + + class LocationContentTransfer(Component): """ Methods for the Location Content Transfer Process @@ -48,7 +51,7 @@ def _response_for_start(self, message=None): """Transition to the 'start' state""" return self._response(next_state="start", message=message) - def _response_for_scan_destination_all(self, location, pickings=None, message=None): + def _response_for_scan_destination_all(self, pickings, message=None): """Transition to the 'scan_destination_all' state The client screen shows a summary of all the lines and packages @@ -56,7 +59,20 @@ def _response_for_scan_destination_all(self, location, pickings=None, message=No """ return self._response( next_state="scan_destination_all", - data=self._data_content_all_for_location(location, pickings=pickings), + data=self._data_content_all_for_location(pickings=pickings), + message=message, + ) + + def _response_for_confirm_scan_destination_all(self, pickings, message=None): + """Transition to the 'confirm_scan_destination_all' state + + The client screen shows a summary of all the lines and packages + to move to a single destination. The user has to scan the destination + location a second time to validate the destination. + """ + return self._response( + next_state="confirm_scan_destination_all", + data=self._data_content_all_for_location(pickings=pickings), message=message, ) @@ -72,7 +88,7 @@ def _response_for_start_single(self, location, next_content, message=None): ) def _response_for_scan_destination(self, location, next_content, message=None): - """Transition to the 'start_single' state + """Transition to the 'scan_destination' state The client screen shows details of the package level or move line to move. """ @@ -82,7 +98,22 @@ def _response_for_scan_destination(self, location, next_content, message=None): message=message, ) - def _data_content_all_for_location(self, location, pickings=None): + def _response_for_confirm_scan_destination( + self, location, next_content, message=None + ): + """Transition to the 'confirm_scan_destination' state + + The client screen shows details of the package level or move line to + move. The user has to scan the destination location a second time to + validate the destination. + """ + return self._response( + next_state="confirm_scan_destination", + data=self._data_content_line_for_location(location, next_content), + message=message, + ) + + def _data_content_all_for_location(self, pickings): if not pickings: # TODO get pickings from location raise NotImplementedError("to do: get pickings from location") @@ -109,19 +140,24 @@ def _data_content_line_for_location(self, location, next_content): ) return {"move_line": line_data, "package_level": level_data} + def _next_content(self, pickings): + sorter = self.actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + try: + next_content = next(sorter) + except StopIteration: + # TODO set picking to done + return None + return next_content + def _router_single_or_all_destination(self, pickings, message=None): location = pickings.mapped("location_id") if len(pickings.mapped("move_line_ids.location_dest_id")) == 1: - return self._response_for_scan_destination_all( - location, pickings=pickings, message=message - ) + return self._response_for_scan_destination_all(pickings, message=message) else: - sorter = self.actions_for("location_content_transfer.sorter") - sorter.feed_pickings(pickings) - try: - next_content = next(sorter) - except StopIteration: - # TODO test + next_content = self._next_content(pickings) + if not next_content: + # TODO test (no more lines) return self._response_for_start( message=self.msg_store.location_content_transfer_complete(location) ) @@ -163,9 +199,11 @@ def _find_location_move_lines_domain(self, location): return [ ("location_id", "=", location.id), ("qty_done", "=", 0), + ("state", "in", ("assigned", "partially_available")), ] def _find_location_move_lines(self, location): + """Find lines that potentially are to move in the location""" return self.env["stock.move.line"].search( self._find_location_move_lines_domain(location) ) @@ -216,7 +254,28 @@ def scan_location(self, barcode): return self._router_single_or_all_destination(pickings) - def set_destination_all(self, location_id, barcode): + def _find_transfer_move_lines_domain(self, location): + return [ + ("location_id", "=", location.id), + ("state", "in", ("assigned", "partially_available")), + ("qty_done", ">", 0), + # TODO check generated SQL + ("picking_id.user_id", "=", self.env.uid), + ] + + def _find_transfer_move_lines(self, location): + """Find move lines currently being moved by the user""" + lines = self.env["stock.move.line"].search( + self._find_transfer_move_lines_domain(location) + ) + return lines + + def _set_destination_lines(self, pickings, move_lines, dest_location): + move_lines.location_dest_id = dest_location + move_lines.package_level_id.location_dest_id = dest_location + pickings.action_done() + + def set_destination_all(self, location_id, barcode, confirmation=False): """Scan destination location for all the moves of the location barcode is a stock.location for the destination @@ -225,7 +284,38 @@ def set_destination_all(self, location_id, barcode): * scan_destination_all: invalid destination or could not set moves to done * start: moves are done """ - return self._response() + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_lines = self._find_transfer_move_lines(location) + pickings = move_lines.mapped("picking_id") + scanned_location = self.actions_for("search").location_from_scan(barcode) + if not scanned_location: + return self._response_for_scan_destination_all( + pickings, message=self.msg_store.barcode_not_found() + ) + + if not scanned_location.is_sublocation_of( + self.picking_types.mapped("default_location_dest_id") + ): + return self._response_for_scan_destination_all( + pickings, message=self.msg_store.dest_location_not_allowed() + ) + if not confirmation and not scanned_location.is_sublocation_of( + move_lines.mapped("location_dest_id") + ): + # the scanned location is valid (child of picking type's destination) + # but not the expected one: ask for confirmation + return self._response_for_confirm_scan_destination_all(pickings) + + self._set_destination_lines(pickings, move_lines, scanned_location) + + return self._response_for_start( + message={ + "message_type": "success", + "body": _("Content transferred from {}.").format(location.name), + } + ) def go_to_single(self, location_id): """Ask the first move line or package level @@ -264,7 +354,9 @@ def scan_line(self, location_id, move_line_id, barcode): """ return self._response() - def set_destination_package(self, location_id, package_level_id, barcode): + def set_destination_package( + self, location_id, package_level_id, barcode, confirmation=False + ): """Scan destination location for package level If the move has other move lines / package levels it has to be split @@ -285,7 +377,9 @@ def set_destination_package(self, location_id, package_level_id, barcode): """ return self._response() - def set_destination_line(self, location_id, move_line_id, quantity, barcode): + def set_destination_line( + self, location_id, move_line_id, quantity, barcode, confirmation=False + ): """Scan destination location for move line If the quantity < qty of the line, split the move and reserve it. @@ -381,6 +475,7 @@ def set_destination_all(self): return { "location_id": {"coerce": to_int, "required": True, "type": "integer"}, "barcode": {"required": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, } def go_to_single(self): @@ -405,6 +500,7 @@ def set_destination_package(self): "location_id": {"coerce": to_int, "required": True, "type": "integer"}, "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, "barcode": {"required": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, } def set_destination_line(self): @@ -413,6 +509,7 @@ def set_destination_line(self): "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, "quantity": {"coerce": to_float, "required": True, "type": "float"}, "barcode": {"required": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, } def postpone_package(self): @@ -456,8 +553,10 @@ def _states(self): return { "start": {}, "scan_destination_all": self._schema_all, + "confirm_scan_destination_all": self._schema_all, "start_single": self._schema_single, "scan_destination": self._schema_single, + "confirm_scan_destination": self._schema_single, } @property @@ -493,7 +592,13 @@ def scan_location(self): ) def set_destination_all(self): - return self._response_schema(next_states={"start", "scan_destination_all"}) + return self._response_schema( + next_states={ + "start", + "scan_destination_all", + "confirm_scan_destination_all", + } + ) def go_to_single(self): return self._response_schema(next_states={"start", "start_single"}) @@ -509,10 +614,14 @@ def scan_line(self): ) def set_destination_package(self): - return self._response_schema(next_states={"start_single", "scan_destination"}) + return self._response_schema( + next_states={"start_single", "scan_destination", "confirm_scan_destination"} + ) def set_destination_line(self): - return self._response_schema(next_states={"start_single", "scan_destination"}) + return self._response_schema( + next_states={"start_single", "scan_destination", "confirm_scan_destination"} + ) def postpone_package(self): return self._response_schema(next_states={"start_single"}) diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 83efe92bcf..d368870c0a 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -35,3 +35,4 @@ from . import test_delivery_select from . import test_location_content_transfer_base from . import test_location_content_transfer_start +from . import test_location_content_transfer_set_destination_all diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py index c5ebe0569c..078f40a6ca 100644 --- a/shopfloor/tests/test_location_content_transfer_base.py +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -30,7 +30,8 @@ def setUp(self): with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="location_content_transfer") - def _simulate_pickings_selected(self, pickings): + @classmethod + def _simulate_pickings_selected(cls, pickings): """Create a state as if pickings has been selected ... during a Location content transfer. @@ -41,14 +42,16 @@ def _simulate_pickings_selected(self, pickings): * the qty_done of all their move lines is set to they reserved qty """ - pickings.user_id = self.env.uid + pickings.user_id = cls.env.uid for line in pickings.mapped("move_line_ids"): line.qty_done = line.product_uom_qty def assert_response_start(self, response, message=None): self.assert_response(response, next_state="start", message=message) - def assert_response_scan_destination_all(self, response, pickings, message=None): + def _assert_response_scan_destination_all( + self, state, response, pickings, message=None + ): # this code is repeated from the implementation, not great, but we # mostly want to ensure the selection of pickings is right, and the # data methods have their own tests @@ -56,7 +59,7 @@ def assert_response_scan_destination_all(self, response, pickings, message=None) package_levels = pickings.package_level_ids self.assert_response( response, - next_state="scan_destination_all", + next_state=state, data={ "move_lines": self.data.move_lines(lines), "package_levels": self.data.package_levels(package_levels), @@ -64,6 +67,18 @@ def assert_response_scan_destination_all(self, response, pickings, message=None) message=message, ) + def assert_response_scan_destination_all(self, response, pickings, message=None): + self._assert_response_scan_destination_all( + "scan_destination_all", response, pickings, message=message + ) + + def assert_response_confirm_scan_destination_all( + self, response, pickings, message=None + ): + self._assert_response_scan_destination_all( + "confirm_scan_destination_all", response, pickings, message=message + ) + def assert_response_start_single(self, response, pickings, message=None): sorter = self.service.actions_for("location_content_transfer.sorter") sorter.feed_pickings(pickings) diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_all.py b/shopfloor/tests/test_location_content_transfer_set_destination_all.py new file mode 100644 index 0000000000..be48916af0 --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py @@ -0,0 +1,142 @@ +from .test_location_content_transfer_base import LocationContentTransferCommonCase + + +class LocationContentTransferSetDestinationAllCase(LocationContentTransferCommonCase): + """Tests for endpoint /set_destination_all""" + + # TODO see what can be common + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + products = cls.product_a + cls.product_b + cls.product_c + cls.product_d + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.picking1 = picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking2 = picking2 = cls._create_picking( + lines=[(cls.product_c, 10), (cls.product_d, 10)] + ) + cls.pickings = picking1 | picking2 + cls._fill_stock_for_moves( + picking1.move_lines, in_package=True, location=cls.content_loc + ) + cls._fill_stock_for_moves(picking2.move_lines, location=cls.content_loc) + cls.pickings.action_assign() + cls._simulate_pickings_selected(cls.pickings) + + def assert_all_done(self, destination): + self.assertRecordValues(self.pickings, [{"state": "done"}, {"state": "done"}]) + self.assertRecordValues( + self.pickings.move_line_ids, + [ + {"qty_done": 10.0, "state": "done", "location_dest_id": destination.id}, + {"qty_done": 10.0, "state": "done", "location_dest_id": destination.id}, + {"qty_done": 10.0, "state": "done", "location_dest_id": destination.id}, + {"qty_done": 10.0, "state": "done", "location_dest_id": destination.id}, + ], + ) + self.assertRecordValues( + self.picking1.package_level_ids, + [{"is_done": True, "state": "done", "location_dest_id": destination.id}], + ) + + def test_set_destination_all_dest_location_ok(self): + """Scanned destination location valid, moves set to done accepted""" + sub_shelf1 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Sub Shelf 1", + "barcode": "subshelf1", + "location_id": self.shelf1.id, + } + ) + ) + response = self.service.dispatch( + "set_destination_all", + params={"location_id": self.content_loc.id, "barcode": sub_shelf1.barcode}, + ) + self.assert_response_start( + response, + message={ + "message_type": "success", + "body": "Content transferred from {}.".format(self.content_loc.name), + }, + ) + self.assert_all_done(sub_shelf1) + + def test_set_destination_all_dest_location_not_found(self): + """Barcode scanned for destination location is not found""" + response = self.service.dispatch( + "set_destination_all", + params={"location_id": self.content_loc.id, "barcode": "NOT_FOUND"}, + ) + self.assert_response_scan_destination_all( + response, self.pickings, message=self.service.msg_store.barcode_not_found() + ) + + def test_set_destination_all_dest_location_need_confirm(self): + """Scanned dest. location != child but in picking type location + + So it needs confirmation. + """ + response = self.service.dispatch( + "set_destination_all", + params={ + "location_id": self.content_loc.id, + # expected location was shelf1, but shelf2 is valid as still in the + # picking type's default dest location, ask confirmation (second scan) + # from the user + "barcode": self.shelf2.barcode, + }, + ) + self.assert_response_confirm_scan_destination_all(response, self.pickings) + + def test_set_destination_all_dest_location_confirmation(self): + """Scanned dest. location != child but in picking type location: confirm + + use the confirmation flag to confirm + """ + response = self.service.dispatch( + "set_destination_all", + params={ + "location_id": self.content_loc.id, + # expected location was shelf1, but shelf2 is valid as still in the + # picking type's default dest location, ask confirmation (second scan) + # from the user + "barcode": self.shelf2.barcode, + "confirmation": True, + }, + ) + self.assert_response_start( + response, + message={ + "message_type": "success", + "body": "Content transferred from {}.".format(self.content_loc.name), + }, + ) + self.assert_all_done(self.shelf2) + + def test_set_destination_all_dest_location_invalid(self): + """The scanned destination location is not in the menu's picking types""" + response = self.service.dispatch( + "set_destination_all", + params={ + "location_id": self.content_loc.id, + "barcode": self.dispatch_location.barcode, + }, + ) + self.assert_response_scan_destination_all( + response, + self.pickings, + message=self.service.msg_store.dest_location_not_allowed(), + ) From 416170866dbd7ca04ae73cb7b197f62e04ff9416 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 19 Jun 2020 11:51:05 +0200 Subject: [PATCH 267/940] location transfer: implement /go_to_single --- .../services/location_content_transfer.py | 26 ++++++++++--------- ...on_content_transfer_set_destination_all.py | 14 +++++++++- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 51bc7ea5bc..53843f03eb 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -76,11 +76,18 @@ def _response_for_confirm_scan_destination_all(self, pickings, message=None): message=message, ) - def _response_for_start_single(self, location, next_content, message=None): + def _response_for_start_single(self, pickings, message=None): """Transition to the 'start_single' state The client screen shows details of the package level or move line to move. """ + location = pickings.mapped("location_id") + next_content = self._next_content(pickings) + if not next_content: + # TODO test (no more lines) + return self._response_for_start( + message=self.msg_store.location_content_transfer_complete(location) + ) return self._response( next_state="start_single", data=self._data_content_line_for_location(location, next_content), @@ -151,19 +158,10 @@ def _next_content(self, pickings): return next_content def _router_single_or_all_destination(self, pickings, message=None): - location = pickings.mapped("location_id") if len(pickings.mapped("move_line_ids.location_dest_id")) == 1: return self._response_for_scan_destination_all(pickings, message=message) else: - next_content = self._next_content(pickings) - if not next_content: - # TODO test (no more lines) - return self._response_for_start( - message=self.msg_store.location_content_transfer_complete(location) - ) - return self._response_for_start_single( - location, next_content, message=message - ) + return self._response_for_start_single(pickings, message=message) def _domain_recover_pickings(self): return [ @@ -328,7 +326,11 @@ def go_to_single(self, location_id): * start: no remaining lines in the location * start_single: if any line or package level has a different destination """ - return self._response() + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) def scan_package(self, location_id, package_level_id, barcode): """Scan a package level to move diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_all.py b/shopfloor/tests/test_location_content_transfer_set_destination_all.py index be48916af0..022fb48e45 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_all.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py @@ -2,7 +2,12 @@ class LocationContentTransferSetDestinationAllCase(LocationContentTransferCommonCase): - """Tests for endpoint /set_destination_all""" + """Tests for endpoint used from scan_destination_all + + * /set_destination_all + * /go_to_single + + """ # TODO see what can be common @classmethod @@ -140,3 +145,10 @@ def test_set_destination_all_dest_location_invalid(self): self.pickings, message=self.service.msg_store.dest_location_not_allowed(), ) + + def test_go_to_single(self): + """User used to 'split by lines' button to process line per line""" + response = self.service.dispatch( + "go_to_single", params={"location_id": self.content_loc.id} + ) + self.assert_response_start_single(response, self.pickings) From 265f2821e4db92eec4c6eb648407d8822d01ebc0 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 19 Jun 2020 14:59:46 +0200 Subject: [PATCH 268/940] location transfer: implement /scan_package --- .../services/location_content_transfer.py | 52 ++++- shopfloor/tests/__init__.py | 1 + .../test_location_content_transfer_base.py | 23 ++ .../test_location_content_transfer_single.py | 203 ++++++++++++++++++ 4 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 shopfloor/tests/test_location_content_transfer_single.py diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 53843f03eb..643e868da5 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -342,7 +342,57 @@ def scan_package(self, location_id, package_level_id, barcode): * start_single: barcode not found, ... * scan_destination: the barcode matches """ - return self._response() + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + package_level = self.env["stock.package_level"].browse(package_level_id) + if not package_level.exists(): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message=self.msg_store.record_not_found(), + ) + + search = self.actions_for("search") + package = search.package_from_scan(barcode) + if package and package_level.package_id == package: + return self._response_for_scan_destination(location, package_level) + + move_lines = self._find_transfer_move_lines(location) + package_move_lines = package_level.move_line_ids + other_move_lines = move_lines - package_move_lines + + product = search.product_from_scan(barcode) + # Normally the user scan the barcode of the package. But if they scan the + # product and we can be sure it's the correct package, it's tolerated. + if product and product in package_move_lines.mapped("product_id"): + if product in other_move_lines.mapped("product_id") or product.tracking in ( + "lot", + "serial", + ): + # When the product exists in other move lines as raw products + # or part of another package, we can't be sure they scanned + # the correct package, so ask to scan the package. + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message={"message_type": "error", "body": _("Scan the package")}, + ) + else: + return self._response_for_scan_destination(location, package_level) + + lot = search.lot_from_scan(barcode) + if lot and lot in package_move_lines.mapped("lot_id"): + if lot in other_move_lines.mapped("lot_id"): + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message={"message_type": "error", "body": _("Scan the package")}, + ) + else: + return self._response_for_scan_destination(location, package_level) + + return self._response_for_start_single( + move_lines.mapped("picking_id"), message=self.msg_store.barcode_not_found() + ) def scan_line(self, location_id, move_line_id, barcode): """Scan a move line to move diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index d368870c0a..4584cde8dc 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -36,3 +36,4 @@ from . import test_location_content_transfer_base from . import test_location_content_transfer_start from . import test_location_content_transfer_set_destination_all +from . import test_location_content_transfer_single diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py index 078f40a6ca..33010fe966 100644 --- a/shopfloor/tests/test_location_content_transfer_base.py +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -89,3 +89,26 @@ def assert_response_start_single(self, response, pickings, message=None): data=self.service._data_content_line_for_location(location, next(sorter)), message=message, ) + + def _assert_response_scan_destination( + self, state, response, next_content, message=None + ): + location = next_content.location_id + self.assert_response( + response, + next_state=state, + data=self.service._data_content_line_for_location(location, next_content), + message=message, + ) + + def assert_response_scan_destination(self, response, next_content, message=None): + self._assert_response_scan_destination( + "scan_destination", response, next_content, message=message + ) + + def assert_response_confirm_scan_destination( + self, response, next_content, message=None + ): + self._assert_response_scan_destination( + "confirm_scan_destination", response, next_content, message=message + ) diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py new file mode 100644 index 0000000000..ef437e7969 --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_single.py @@ -0,0 +1,203 @@ +from .test_location_content_transfer_base import LocationContentTransferCommonCase + + +class LocationContentTransferSingleCase(LocationContentTransferCommonCase): + """Tests for endpoint used from state start_single + + * /scan_package + * /scan_line + + """ + + # TODO common with set_destination_all? + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + products = cls.product_a + cls.product_b + cls.product_c + cls.product_d + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.product_d.tracking = "lot" + cls.picking1 = picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking2 = picking2 = cls._create_picking( + lines=[(cls.product_c, 10), (cls.product_d, 10)] + ) + cls.pickings = picking1 | picking2 + cls._fill_stock_for_moves( + picking1.move_lines, in_package=True, location=cls.content_loc + ) + cls.product_d_lot = cls.env["stock.production.lot"].create( + {"product_id": cls.product_d.id, "company_id": cls.env.company.id} + ) + cls._fill_stock_for_moves(picking2.move_lines[0], location=cls.content_loc) + cls._fill_stock_for_moves( + picking2.move_lines[1], location=cls.content_loc, in_lot=cls.product_d_lot + ) + cls.pickings.action_assign() + cls._simulate_pickings_selected(cls.pickings) + + def _test_scan_package_ok(self, barcode): + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "scan_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": barcode, + }, + ) + self.assert_response_scan_destination(response, package_level) + + def test_scan_package_package_ok(self): + package_level = self.picking1.move_line_ids.package_level_id + self._test_scan_package_ok(package_level.package_id.name) + + def test_scan_package_barcode_not_found(self): + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "scan_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": "NOT_FOUND", + }, + ) + self.assert_response_start_single( + response, self.pickings, message=self.service.msg_store.barcode_not_found() + ) + + def test_scan_package_product_ok(self): + # product_a is in the package and anywhere else so it's + # accepted to check we scanned the correct package + self._test_scan_package_ok(self.product_a.barcode) + + def test_scan_package_product_packaging_ok(self): + # product_a is in the package and anywhere else so it's + # accepted to check we scanned the correct package + self._test_scan_package_ok(self.product_a.packaging_ids[0].barcode) + + def test_scan_package_lot_ok(self): + package_level = self.picking1.move_line_ids.package_level_id + line_product_a = package_level.move_line_ids[0] + self.product_a.tracking = "lot" + line_product_a.lot_id = self.env["stock.production.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + # lot of product_a is in the package and anywhere else so it's + # accepted to check we scanned the correct package + self._test_scan_package_ok(line_product_a.lot_id.name) + + def _test_scan_package_nok(self, pickings, barcode, message): + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "scan_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.product_a.barcode, + }, + ) + self.assert_response_start_single(response, pickings, message=message) + + def test_scan_package_product_nok_different_package(self): + # add another picking with a package with product a, + # if we scan product A, we can't know for which package it is + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves( + picking.move_lines, in_package=True, location=self.content_loc + ) + picking.action_assign() + self._simulate_pickings_selected(picking) + self._test_scan_package_nok( + self.pickings | picking, + self.product_a.barcode, + {"message_type": "error", "body": "Scan the package"}, + ) + + def test_scan_package_product_nok_different_line(self): + # add another picking with a raw line with product a, + # if we scan product A, we can't know which line/package we want + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_lines, location=self.content_loc) + picking.action_assign() + self._simulate_pickings_selected(picking) + self._test_scan_package_nok( + self.pickings | picking, + self.product_a.barcode, + {"message_type": "error", "body": "Scan the package"}, + ) + + def test_scan_package_product_nok_product_tracked(self): + # we scan product_a's barcode but it's tracked by lot + self.product_a.tracking = "lot" + self._test_scan_package_nok( + self.pickings, + self.product_a.barcode, + {"message_type": "error", "body": "Scan the package"}, + ) + + def test_scan_package_lot_nok_different_package(self): + # add another picking with a package with the lot used in our package, + # if we scan the lot, we can't know for which package it is + package_level = self.picking1.move_line_ids.package_level_id + line_product_a = package_level.move_line_ids[0] + self.product_a.tracking = "lot" + line_product_a.lot_id = lot = self.env["stock.production.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves( + picking.move_lines, in_package=True, in_lot=lot, location=self.content_loc + ) + picking.action_assign() + self._simulate_pickings_selected(picking) + self._test_scan_package_nok( + self.pickings | picking, + self.product_a.barcode, + {"message_type": "error", "body": "Scan the package"}, + ) + + def test_scan_package_lot_nok_different_line(self): + # add another picking with a raw line with a lot used in our package, + # if we scan the lot, we can't know which line/package we want + package_level = self.picking1.move_line_ids.package_level_id + line_product_a = package_level.move_line_ids[0] + self.product_a.tracking = "lot" + line_product_a.lot_id = lot = self.env["stock.production.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves( + picking.move_lines, in_lot=lot, location=self.content_loc + ) + picking.action_assign() + self._simulate_pickings_selected(picking) + self._test_scan_package_nok( + self.pickings | picking, + self.product_a.barcode, + {"message_type": "error", "body": "Scan the package"}, + ) + + def test_scan_package_package_level_not_exists(self): + package_level = self.picking1.move_line_ids.package_level_id + package_level_id = package_level.id + package_level.unlink() + response = self.service.dispatch( + "scan_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level_id, + "barcode": self.product_a.barcode, + }, + ) + self.assert_response_start_single( + response, self.pickings, message=self.service.msg_store.record_not_found() + ) From 3d06df5da0cd851f1b6bf9872ebe05c5fe865a0f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 22 Jun 2020 12:13:57 +0200 Subject: [PATCH 269/940] location transfer: implement /scan_line --- .../services/location_content_transfer.py | 32 +++++++- shopfloor/tests/common.py | 2 +- .../test_location_content_transfer_single.py | 74 ++++++++++++++++++- 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 643e868da5..21b858477a 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -404,7 +404,37 @@ def scan_line(self, location_id, move_line_id, barcode): * start_single: barcode not found, ... * scan_destination: the barcode matches """ - return self._response() + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message=self.msg_store.record_not_found(), + ) + + search = self.actions_for("search") + product = search.product_from_scan(barcode) + if product and product == move_line.product_id: + if product.tracking in ("lot", "serial"): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message=self.msg_store.scan_lot_on_product_tracked_by_lot(), + ) + else: + return self._response_for_scan_destination(location, move_line) + + lot = search.lot_from_scan(barcode) + if lot and lot == move_line.lot_id: + return self._response_for_scan_destination(location, move_line) + + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single( + move_lines.mapped("picking_id"), message=self.msg_store.barcode_not_found() + ) def set_destination_package( self, location_id, package_level_id, barcode, confirmation=False diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 9fbd131bec..b55dc7f269 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -202,7 +202,7 @@ def setUpClassBaseData(cls): .create( { "name": "Box", - "product_id": cls.product_b.id, + "product_id": cls.product_c.id, "barcode": "ProductCBox", } ) diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py index ef437e7969..d5b89bcd69 100644 --- a/shopfloor/tests/test_location_content_transfer_single.py +++ b/shopfloor/tests/test_location_content_transfer_single.py @@ -102,7 +102,7 @@ def _test_scan_package_nok(self, pickings, barcode, message): params={ "location_id": self.content_loc.id, "package_level_id": package_level.id, - "barcode": self.product_a.barcode, + "barcode": barcode, }, ) self.assert_response_start_single(response, pickings, message=message) @@ -201,3 +201,75 @@ def test_scan_package_package_level_not_exists(self): self.assert_response_start_single( response, self.pickings, message=self.service.msg_store.record_not_found() ) + + def _test_scan_line_ok(self, move_line, barcode): + response = self.service.dispatch( + "scan_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "barcode": barcode, + }, + ) + self.assert_response_scan_destination(response, move_line) + + def test_scan_line_product_ok(self): + move_line = self.picking2.move_line_ids[0] + # check we selected the good line + self.assertEqual(move_line.product_id, self.product_c) + self._test_scan_line_ok(move_line, self.product_c.barcode) + + def test_scan_line_product_packaging_ok(self): + move_line = self.picking2.move_line_ids[0] + # check we selected the good line + self.assertEqual(move_line.product_id, self.product_c) + self._test_scan_line_ok(move_line, self.product_c.packaging_ids[0].barcode) + + def test_scan_line_lot_ok(self): + move_line = self.picking2.move_line_ids[1] + # check we selected the good line (the one with a lot) + self.assertEqual(move_line.product_id, self.product_d) + self._test_scan_line_ok(move_line, self.product_d_lot.name) + + def _test_scan_line_nok(self, pickings, move_line_id, barcode, message): + response = self.service.dispatch( + "scan_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line_id, + "barcode": barcode, + }, + ) + self.assert_response_start_single(response, pickings, message=message) + + def test_scan_line_product_nok_product_tracked(self): + # we scan product_d's barcode but it's tracked by lot + move_line = self.picking2.move_line_ids[1] + # check we selected the good line (the one with a lot) + self.assertEqual(move_line.product_id, self.product_d) + self._test_scan_line_nok( + self.pickings, + move_line.id, + self.product_d.barcode, + self.service.msg_store.scan_lot_on_product_tracked_by_lot(), + ) + + def test_scan_line_barcode_not_found(self): + move_line = self.picking2.move_line_ids[0] + self._test_scan_line_nok( + self.pickings, + move_line.id, + "NOT_FOUND", + self.service.msg_store.barcode_not_found(), + ) + + def test_scan_line_move_line_not_exists(self): + move_line = self.picking2.move_line_ids[0] + move_line_id = move_line.id + move_line.unlink() + self._test_scan_line_nok( + self.pickings, + move_line_id, + "NOT_FOUND", + self.service.msg_store.record_not_found(), + ) From c6c084e62e85124f247e2b5b6b20607194f378ab Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 23 Jun 2020 13:29:15 +0200 Subject: [PATCH 270/940] location transfer: Add a bit of docstring for /scan_location --- shopfloor/services/location_content_transfer.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 21b858477a..76325bf609 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -214,6 +214,12 @@ def scan_location(self, barcode): All the move lines and package levels must have the same picking type. + If the scanned location has no move lines, new move lines to move the + whole content of the location are created if: + + * the menu has the option "Allow to create move(s)" + * the menu is linked to only one picking type. + When move lines and package levels have different destinations, the first line without package level or package level is sent to the client. From 68868c8123b94e505fabde0fbe65dbba743ca09c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 24 Jun 2020 10:01:34 +0200 Subject: [PATCH 271/940] Add todo --- shopfloor/services/location_content_transfer.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 76325bf609..8d0ffca722 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -233,6 +233,9 @@ def scan_location(self, barcode): if not location: return self._response_for_start(message=self.msg_store.barcode_not_found()) move_lines = self._find_location_move_lines(location) + # TODO: Add creation of move lines and package levels when empty, see + # single_pack_transfer.py for creation of package levels (but quants + # without packs need to be created as move lines too here) pickings = move_lines.mapped("picking_id") picking_types = pickings.mapped("picking_type_id") From 2a0d663fce413de51db50975ce92eec334ed454e Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 30 Jun 2020 18:16:13 +0200 Subject: [PATCH 272/940] location transfer: improve /scan_location by creating moves if needed --- shopfloor/actions/message.py | 12 ++++ shopfloor/demo/shopfloor_menu_demo.xml | 1 + .../services/location_content_transfer.py | 55 +++++++++++++++++-- .../test_location_content_transfer_start.py | 51 +++++++++++++++++ 4 files changed, 113 insertions(+), 6 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index db160ee3f4..c497b46924 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -329,3 +329,15 @@ def recovered_previous_session(self): "message_type": "info", "body": _("Recovered previous session."), } + + def no_lines_to_process(self): + return { + "message_type": "info", + "body": _("No lines to process."), + } + + def location_empty(self, location): + return { + "message_type": "info", + "body": _("Location {} empty").format(location.name), + } diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml index 2880a74a80..4929c516c0 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -39,6 +39,7 @@ 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 378048a7bc..35b01f465d 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 ba1b22a544b7b5ef23207456f7a6e10a09a56c8b Mon Sep 17 00:00:00 2001 From: sebalix Date: Wed, 1 Jul 2020 11:56:47 +0200 Subject: [PATCH 273/940] 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 d5b89bcd69..1694089fa3 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 355d7cda62bf84b8456cad78e44309b1e979ed1f Mon Sep 17 00:00:00 2001 From: sebalix Date: Wed, 1 Jul 2020 11:58:12 +0200 Subject: [PATCH 274/940] 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 0844d138bf..87479e5e6f 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 022fb48e45..c40f566250 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 35901e6bd0c78cc73e539255521c7f08582e8b77 Mon Sep 17 00:00:00 2001 From: sebalix Date: Wed, 1 Jul 2020 18:20:01 +0200 Subject: [PATCH 275/940] 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 269d87aa01..51f97a56ff 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 87479e5e6f..bcafcd9a08 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 4584cde8dc..61dfbc481b 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 0000000000..074c8d20cc --- /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 0a9515cb7dd53865ce112be1ef5b80629bd74eee Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 6 Jul 2020 12:01:16 +0200 Subject: [PATCH 276/940] 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 a79bc69242..80ca0488fe 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 1db120cc2f..1aa691c6e9 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 bcafcd9a08..b42c3db554 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 1694089fa3..5d0f7bb5b8 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 ae6995da3684c088a2f402d965a94aa13df83b43 Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 7 Jul 2020 11:42:07 +0200 Subject: [PATCH 277/940] 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 b42c3db554..bc48d5a891 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 5d0f7bb5b8..599b6aabd7 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 dd5c960f466190933b6aee0ebff014ad36f65e50 Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 7 Jul 2020 15:29:25 +0200 Subject: [PATCH 278/940] 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 80ca0488fe..154dedb281 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 aac71405a141216f4af93fce8fb3e4684c0eba52 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 13 Jul 2020 12:02:44 +0200 Subject: [PATCH 279/940] 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 c2efca8ba6..09ead326eb 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 bc48d5a891..51572b345e 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 c019d057e5..c7d78c39e3 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 e266525036..4be811ac45 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 35b01f465d..66786e1023 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 0b5db1f8ba2636adaf38b27e1415eeaf9595c9a0 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 14 Jul 2020 12:24:23 +0200 Subject: [PATCH 280/940] 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 c497b46924..300a1af679 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 51572b345e..cb0d9044f2 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 074c8d20cc..362fdf1973 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 d857bbb0ad792b3173f5b952e89765d9b8e18f40 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 14 Jul 2020 12:26:29 +0200 Subject: [PATCH 281/940] 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 cb0d9044f2..fd35f7905f 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 33010fe966..989f867b17 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 f766dd9ad70bfc61141d32737a5c8f8c59efaac3 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 14 Jul 2020 12:27:23 +0200 Subject: [PATCH 282/940] 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 fd35f7905f..9601395410 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 105e08c17efc1021dd7b40127b80e81abb0d1e8f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 14 Jul 2020 12:36:24 +0200 Subject: [PATCH 283/940] 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 9601395410..c2869d7692 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 989f867b17..0bf62b4ef7 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 c40f566250..35903b2dbf 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 362fdf1973..b46855f270 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 a5109a9d2fb1fd3df39f5f677b131d25dd00425b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 16 Jul 2020 16:33:58 +0200 Subject: [PATCH 284/940] 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 09ead326eb..b5f3e30622 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 c7d78c39e3..ac610977ad 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 4be811ac45..fbd1942025 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 0f8e0053d5fc0b8f8f26f066bc662616b1986721 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 17 Jul 2020 11:12:26 +0200 Subject: [PATCH 285/940] 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 c2869d7692..ba1a0b69ec 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 de1cc57acc4612f4f92e8e8bac02ce21e03fc40e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 17 Jul 2020 11:35:20 +0200 Subject: [PATCH 286/940] 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 300a1af679..9333acb952 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 ba1a0b69ec..c3faf64454 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 35903b2dbf..fb2c21b876 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 2c148e6d5a1d2804dc62d1b454e9f07a1841f6a0 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 15 Jul 2020 14:01:17 +0200 Subject: [PATCH 287/940] 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 51f97a56ff..98d503229f 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 68759df75b..177b271a31 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 3e86812cf387e9780c11f0b9ea853510a1ef5915 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 16 Jul 2020 16:17:38 +0200 Subject: [PATCH 288/940] Add shopfloor automatic creation of picking batch When a user uses the "Get Work" button on the barcode device, if no transfer batch is available, it automatically creates a new batch for the user. --- shopfloor/readme/CONTRIBUTORS.rst | 1 + shopfloor/services/cluster_picking.py | 1 - shopfloor/views/shopfloor_menu.xml | 10 ++++++---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/shopfloor/readme/CONTRIBUTORS.rst b/shopfloor/readme/CONTRIBUTORS.rst index 7a55e5ba99..6fc648282a 100644 --- a/shopfloor/readme/CONTRIBUTORS.rst +++ b/shopfloor/readme/CONTRIBUTORS.rst @@ -1,3 +1,4 @@ * Alexandre Fayolle +* Guewen Baconnier ADD YOURSELF diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 88fe45175d..c7dba785a3 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -239,7 +239,6 @@ def _batch_picking_filter(self, picking): "cancel", ) - # TODO this may be used in other scenarios? if so, extract def _select_a_picking_batch(self, batches): # look for in progress + assigned to self first candidates = batches.filtered( diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index 177b271a31..149d4cea7b 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -57,11 +57,13 @@ /> - - + > + + + From ece616b115654986d1f7ded08bfc984fb1cf653a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 13 Jul 2020 15:51:50 +0200 Subject: [PATCH 289/940] cluster picking: lock all the lines before set destination At the beginning of the endpoint that writes the destination of lines moved by a package, lock them. This is mostly to have an entrypoint for the module shopfloor_checkout_sync: In the general scenario, the lines are moved by the same bin package, they should be moved by the same operator, so we should never have any concurrency issue. However, when we use the synchronization of destinations (shopfloor_checkout_sync), as soon as we scan a destination for the first line, it updates the destination of all the other move lines that reach the same operation type for an order, so it might change the destination of another operator's line. At the beginning of the endpoint, lock the line to ensure 2 operators won't synchronize different destinations (I would expect to have a transaction rollback error and a rollback anyway). --- shopfloor/services/cluster_picking.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index c7dba785a3..179179a6df 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -1139,12 +1139,12 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): if not confirmation: return self._response_for_confirm_unload_all(batch) - self._unload_set_destination_on_lines(lines, scanned_location) + self._unload_write_destination_on_lines(lines, scanned_location) completion_info = self.actions_for("completion.info") completion_info_popup = completion_info.popup(lines) return self._unload_end(batch, completion_info_popup=completion_info_popup) - def _unload_set_destination_on_lines(self, lines, location): + def _unload_write_destination_on_lines(self, lines, location): lines.write({"shopfloor_unloaded": True, "location_dest_id": location.id}) for line in lines: # We set the picking to done only when the last line is @@ -1259,6 +1259,20 @@ def unload_scan_destination( if not lines: return self._unload_end(batch) + return self._unload_scan_destination_lines( + batch, package, lines, barcode, confirmation=confirmation + ) + + def _unload_scan_destination_lock_lines(self, lines): + """Lock move lines""" + sql = "SELECT id FROM %s WHERE ID IN %%s FOR UPDATE" % lines._table + self.env.cr.execute(sql, (tuple(lines.ids),), log_exceptions=False) + + def _unload_scan_destination_lines( + self, batch, package, lines, barcode, confirmation=False + ): + # Lock move lines that will be updated + self._unload_scan_destination_lock_lines(lines) first_line = fields.first(lines) picking_type = fields.first(batch.picking_ids).picking_type_id scanned_location = self.actions_for("search").location_from_scan(barcode) @@ -1277,7 +1291,7 @@ def unload_scan_destination( if not confirmation: return self._response_for_confirm_unload_set_destination(batch, package) - self._unload_set_destination_on_lines(lines, scanned_location) + self._unload_write_destination_on_lines(lines, scanned_location) completion_info = self.actions_for("completion.info") completion_info_popup = completion_info.popup(lines) From b5adf8b81c5da32f005c36c79dc5537c9b5e4883 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jul 2020 09:58:02 +0200 Subject: [PATCH 290/940] cluster picking: sort moves by priority Priorities goes from "1" (Normal) to "3" (Very Urgent). We want to pick very urgent first. The priority field may be empty, ensure they have a normal priority if it happens. --- shopfloor/services/cluster_picking.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 179179a6df..f805e05819 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -322,6 +322,7 @@ def _sort_key_lines(line): line.shopfloor_postponed, line.location_id.shopfloor_picking_sequence, line.location_id.name, + -int(line.move_id.priority or 1), line.move_id.sequence, line.move_id.id, line.id, From a57444d9e089588ab23060cccd5b23732268fad7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jul 2020 12:52:57 +0200 Subject: [PATCH 291/940] Change location picking sequence to a char The rationale being: * it can be composed of existing fields such as corridor, rack, side, level (e.g. 04-006-L-02) * adding a new shelf makes it easier to insert in such pattern than in a sequence --- shopfloor/models/stock_location.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py index 9bd822d152..53eb9eb88b 100644 --- a/shopfloor/models/stock_location.py +++ b/shopfloor/models/stock_location.py @@ -4,10 +4,13 @@ class StockLocation(models.Model): _inherit = "stock.location" - shopfloor_picking_sequence = fields.Integer( + shopfloor_picking_sequence = fields.Char( string="Shopfloor Picking Sequence", - default=0, - help="The picking done in Shopfloor scenarios will respect this order.", + help="The picking done in Shopfloor scenarios will respect this order. " + "The sequence is a char so it can be composed of fields such as " + "'corridor-rack-side-level'. Pay attention to the padding " + "('09' is before '19', '9' is not). It is recommended to use an" + " Export then an Import to populate this field using a spreadsheet.", ) source_move_line_ids = fields.One2many( comodel_name="stock.move.line", inverse_name="location_id", readonly=True From b2c7044505209d9011a71b1205c88c8472b2822d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 24 Jun 2020 16:00:41 +0200 Subject: [PATCH 292/940] zone picking: add skeleton of the service --- shopfloor/actions/data.py | 11 + shopfloor/demo/shopfloor_menu_demo.xml | 9 + shopfloor/demo/stock_picking_type_demo.xml | 15 + shopfloor/models/shopfloor_menu.py | 1 + shopfloor/models/stock_move_line.py | 1 + shopfloor/services/__init__.py | 1 + shopfloor/services/cluster_picking.py | 5 +- shopfloor/services/schema.py | 3 + shopfloor/services/zone_picking.py | 706 +++++++++++++++++++++ shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_zone_picking_base.py | 20 + 11 files changed, 770 insertions(+), 3 deletions(-) create mode 100644 shopfloor/services/zone_picking.py create mode 100644 shopfloor/tests/test_zone_picking_base.py diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index b5f3e30622..2ff2cbdd2b 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -215,3 +215,14 @@ def picking_batches(self, record, with_pickings=False, **kw): @property def _picking_batch_parser(self): return ["id", "name", "picking_count", "move_line_count", "total_weight:weight"] + + def picking_type(self, record, **kw): + parser = self._picking_type_parser + return self._jsonify(record, parser, **kw) + + def picking_types(self, record, **kw): + return self.picking_type(record, multi=True) + + @property + def _picking_type_parser(self): + return ["id", "name"] diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml index 4929c516c0..036a20b724 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -9,6 +9,15 @@ eval="[(4, ref('shopfloor.picking_type_single_pallet_transfer_demo'))]" /> + + Zone Picking + 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 2dbedd83ad..c912a1087d 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 98d503229f..f0385f5717 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -38,6 +38,7 @@ def _selection_scenario(self): return [ # these must match a REST service's '_usage' ("single_pack_transfer", "Single Pack Transfer"), + ("zone_picking", "Zone Picking"), ("cluster_picking", "Cluster Picking"), ("checkout", "Checkout/Packing"), ("delivery", "Delivery"), diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py index 29c96da23d..7bb68b899c 100644 --- a/shopfloor/models/stock_move_line.py +++ b/shopfloor/models/stock_move_line.py @@ -13,6 +13,7 @@ class StockMoveLine(models.Model): "Indicates if a the move has been postponed in a barcode scenario.", ) shopfloor_checkout_done = fields.Boolean(default=False) + shopfloor_user_id = fields.Many2one(comodel_name="res.users", index=True) # we search lines based on their location in some workflows location_id = fields.Many2one(index=True) diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 09470e83ee..166173654b 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -12,6 +12,7 @@ # process services from . import checkout +from . import zone_picking from . import cluster_picking from . import delivery from . import location_content_transfer diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index f805e05819..b065ad02f1 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -666,9 +666,8 @@ def _are_all_dest_location_same(self, batch): def prepare_unload(self, picking_batch_id): """Initiate the unloading phase of the scenario - If the destination of all the move lines still to unload is the same, - Everytime this method is called, it resets the flag according to the - condition above. + It goes to different screens depending if all the move lines have + the same destination or not. Transitions: * unload_all: when all lines go to the same destination diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index ac610977ad..f686a61695 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -164,3 +164,6 @@ def package_level(self): "product": self._schema_dict_of(self.product()), "quantity": {"type": "float", "required": True}, } + + def picking_type(self): + return self._simple_record() diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py new file mode 100644 index 0000000000..99172cfdd7 --- /dev/null +++ b/shopfloor/services/zone_picking.py @@ -0,0 +1,706 @@ +from odoo.addons.base_rest.components.service import to_bool, to_int +from odoo.addons.component.core import Component + +from .service import to_float + + +class ZonePicking(Component): + """ + Methods for the Zone Picking Process + + Zone picking of move lines. + + Note: + + * Several operation types could be linked to a single menu item + * If several operator work in a same zone, they’ll see the same move lines but + will only posts theirs when unloading their goods, which means that when they + scan lines, the backend has to store the user id on the move lines + + Workflow: + + 1. The operator scans the zone location with goods to pick (zone location + meaning a parent location, not a leaf) + 2. If the zone contains lines from different picking types, the operator + chooses the type to work with + 3. The client application shows the list of move lines, with an option + to choose the sorting of the lines + 4. The operator selects a line to pick, by scanning one of: + + * location, if only a single move line there; if a location is scanned + and it contains several move lines, the view is updated to show only + them + * package + * product + * lot + + 5. The operator scans the destination for the line they scanned, this is where + the path splits: + + * they scan a location, in which case the move line's destination is + updated with it and the move is done + * they scan a package, which becomes the destination package of the move + line, the move line is not set to done, its ``qty_done`` is updated + and a field ``shopfloor_user_id`` is set to the user; consider the + move line is set in a buffer + + 6. At any point, from the list of moves, the operator can reach the + "unload" screens to unload what they had put into the buffer (scanned a + destination package during step 5.). This is optional as they can directly + move whole pallets by scanning the destination in step 5. + 7. The unload screens (similar to those of the Cluster Picking workflow) are + used to move what has been put in the buffer: + + * if the original destination of all the lines is unique, screen allows + to scan a single destination; they can use a "split" button to go to + the line by line screen + * if the lines have different destinations, they have to scan the destination + package, then scan the destination location, scan the next package and its + destination and so on. + + The list of move lines (point 4.) has support functions: + + * Change a lot or pack: if the expected lot is at the very bottom of the + location or a stock error forces a user to change lot or pack, user can + do it during the picking. + * Declare stock out: if a good is in fact not in stock or only partially. + Note the move lines may become unavailable or partially unavailable and + generate a back-order. + + """ + + _inherit = "base.shopfloor.process" + _name = "shopfloor.zone.picking" + _usage = "zone_picking" + _description = __doc__ + + def _response_for_start(self, message=None): + return self._response(next_state="start", message=message) + + def _response_for_select_picking_type( + self, zone_location, picking_types, message=None + ): + return self._response( + next_state="select_picking_type", + data=self._data_for_select_picking_type(zone_location, picking_types), + message=message, + ) + + def _response_for_select_line( + self, zone_location, picking_type, move_lines, message=None, popup=None + ): + return self._response( + next_state="select_line", + data=self._data_for_move_lines(zone_location, picking_type, move_lines), + message=message, + popup=popup, + ) + + def _response_for_set_line_destination( + self, zone_location, picking_type, move_line, message=None + ): + return self._response( + next_state="set_line_destination", + data=self._data_for_move_line(zone_location, picking_type, move_line), + message=message, + ) + + def _response_for_confirm_set_line_destination( + self, zone_location, picking_type, move_line, message=None + ): + return self._response( + next_state="confirm_set_line_destination", + data=self._data_for_move_line(zone_location, picking_type, move_line), + message=message, + ) + + def _response_for_zero_check( + self, zone_location, picking_type, location, message=None + ): + return self._response( + next_state="zero_check", + data=self._data_for_move_line(zone_location, picking_type, location), + message=message, + ) + + def _response_for_change_pack_lot( + self, zone_location, picking_type, move_line, message=None + ): + return self._response( + next_state="change_pack_lot", + data=self._data_for_move_line(zone_location, picking_type, move_line), + message=message, + ) + + def _response_for_unload_all( + self, zone_location, picking_type, move_lines, message=None + ): + return self._response( + next_state="unload_all", + data=self._data_for_move_lines(zone_location, picking_type, move_lines), + message=message, + ) + + def _response_for_confirm_unload_all( + self, zone_location, picking_type, move_lines, message=None + ): + return self._response( + next_state="confirm_unload_all", + data=self._data_for_move_lines(zone_location, picking_type, move_lines), + message=message, + ) + + def _response_for_unload_single( + self, zone_location, picking_type, move_line, message=None, popup=None + ): + return self._response( + next_state="unload_single", + data=self._data_for_move_line(zone_location, picking_type, move_line), + message=message, + popup=popup, + ) + + def _response_for_unload_set_destination( + self, zone_location, picking_type, move_line, message=None + ): + return self._response( + next_state="unload_set_destination", + data=self._data_for_move_line(zone_location, picking_type, move_line), + message=message, + ) + + def _response_for_confirm_unload_set_destination( + self, zone_location, picking_type, move_line + ): + return self._response( + next_state="confirm_unload_set_destination", + data=self._data_for_move_line(zone_location, picking_type, move_line), + ) + + def _data_for_select_picking_type(self, zone_location, picking_types): + return { + "zone_location": self.data.location(zone_location), + # available picking types to choose from + # TODO add lines count and priority lines count in the data + "picking_types": self.data.picking_types(picking_types), + } + + def _data_for_move_line(self, zone_location, picking_type, move_line): + return { + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "move_line": self.data.move_line(move_line), + } + + def _data_for_move_lines(self, zone_location, picking_type, move_lines): + return { + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + # TODO sorting, ... (but maybe the lines are already sorted when passed) + "move_lines": self.data.move_lines(move_lines), + } + + def scan_location(self, barcode): + """Scan the zone location where the picking should occur + + The location must be a sub-location of one of the picking types' + default destination locations of the menu. + + Transitions: + * start: invalid barcode + * select_picking_type: the location is valid, user has to choose a picking type + """ + return self._response() + + def list_move_lines(self, zone_location_id, picking_type_id, order="priority"): + """List all move lines to pick, sorted + + Transitions: + * select_line: show the list of move lines + """ + return self._response() + + def scan_source(self, zone_location_id, picking_type_id, barcode, order="priority"): + """Select a move line or narrow the list of move lines + + When the barcode is a location and we can unambiguously know which move + line is picked (the quants in the location has one product/lot/package, + matching a single move line), then the move line is selected. + Otherwise, the list of move lines is refreshed with a filter on the + scanned location, showing the move lines that have this location as + source. + + When the barcode is a package, a product or a lot, the first matching + line is selected. + + A selected line goes to the next screen to select the destination + location or package. + + Transitions: + * select_line: barcode not found or narrow the list on a location + * set_line_destination: a line has been selected for picking + """ + return self._response() + + def set_destination( + self, + zone_location_id, + picking_type_id, + move_line_id, + barcode, + quantity, + confirmation=False, + ): + """Set a destination location (and done) or a destination package (in buffer) + + When a line is picked, it can either: + + * be moved directly to a destination location, typically a pallet + * be moved to a destination package, that we'll call buffer in the docstrings + + When the barcode is a valid location, actions on the move line: + + * destination location set to the scanned one + * the quantity done is set to the passed quantity + * if the move has other move lines, it is split to have only this move line + * set to done (without backorder) + + A valid location is a sub-location of the original destination, or a + sub-location of the picking type's default destination location if + ``confirmation`` is True. + + When the barcode is a valid package, actions on the move line: + + * destination package is set to the scanned one + * the quantity done is set to the passed quantity + * the field ``shopfloor_user_id`` is updated with the current user + + Those fields will be used to identify which move lines are in the buffer. + + A valid package is: + + * an empty package + * not used as destination for another move line + + Transitions: + * select_line: destination has been set, showing the next lines to pick + * zero_check: if the quantity of product moved is 0 in the source + location after the move (beware: at this point the product we put in + the buffer is still considered to be in the source location, so we + have to compute the source location's quantity - qty_done). + * set_line_destination: the scanned location is invalid, user has to + scan another one + * confirm_set_line_destination: the scanned location is not in the + expected one but is valid (in picking type's default destination) + """ + # TODO on _action_done, use ``_sf_no_backorder`` in the + # context to disable backorders (see override in stock_picking.py). + return self._response() + + def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero): + """Confirm or not if the source location of a move has zero qty + + If the user confirms there is zero quantity, it means the stock was + correct and there is nothing to do. If the user says "no", a draft + empty inventory is created for the product (with lot if tracked). + + Transitions: + * select_line: whether the user confirms or not the location is empty, + go back to the picking of lines + """ + # TODO look in cluster_picking.py, same function exists + return self._response() + + def stock_issue(self, zone_location_id, picking_type_id, move_line_id): + """Declare a stock issue for a line + + After errors in the stock, the user cannot take all the products + because there is physically not enough goods. The move line is deleted + (unreserve), and an inventory is created to reduce the quantity in the + source location to prevent future errors until a correction. Beware: + the quantity already reserved and having a qty_done set on other lines + in the same location should remain reserved so the inventory's quantity + must be set to the total of qty_done of other lines. + + The other lines not yet picked (no qty_done) in the same location for + the same product, lot, package are unreserved as well (moves lines + deleted, which unreserve their quantity on the move). + + A second inventory is created in draft to have someone do an inventory + check. + + At the end, it tries to reserve the goods again, and if the current + line could be reserved in the current zone location, it transitions + directly to the screen to set the destination. + + Transitions: + * select_line: go back to the picking of lines for the next ones (nothing + could be reserved as replacement) + * set_line_destination: something could be reserved instead of the original + move line + """ + # TODO look in cluster_picking.py, similar function exists + return self._response() + + def change_pack_lot(self, zone_location_id, picking_type_id, move_line_id, barcode): + """Change the source package or the lot of a move line + + If the expected lot or package is at the very bottom of the location or + a stock error forces a user to change lot or package, user can change the + package or lot of the current move line. + + If the pack or lot was not supposed to be in the source location, + a draft inventory is created to have this checked. + + Transitions: + * change_pack_lot: the barcode scanned is invalid or change could not be done + * set_line_destination: the package / lot has been changed, can be + moved to destination now + * select_line: if the move line does not exist anymore + """ + # TODO look in cluster_picking.py, similar function exists + return self._response() + + def prepare_unload(self, zone_location_id, picking_type_id): + """Initiate the unloading of the buffer + + The buffer is composed of move lines: + + * in the current zone location and picking type + * not done or canceled + * with a qty_done > 0 + * have a destination package + * with ``shopfloor_user_id`` equal to the current user + + The lines are grouped by their destination package. The destination + package is what is shown on the screen (with their content, which is + the move lines with the package as destination), and this is what is + passed along in the ``package_id`` parameters in the unload methods. + + It goes to different screens depending if there is only one move line, + or if all the move lines have the same destination or not. + + Transitions: + * unload_single: move lines have different destinations, return data + for the next destination package + * unload_set_destination: there is only one move line in the buffer + * unload_all: the move lines in the buffer all have the same + destination location + * select_line: no remaining move lines in buffer + """ + return self._response() + + def set_destination_all( + self, zone_location_id, picking_type_id, barcode, confirmation=False + ): + """Set the destination for all the lines in the buffer + + Look in ``prepare_unload`` for the definition of the buffer. + + This method must be used only if all the buffer's move lines which have + a destination package, qty done > 0, and have the same destination + location. + + A scanned location outside of the source location of the operation type is + invalid. + + The move lines are then set to done, without backorders. + + Transitions: + * unload_all: the scanned destination is invalid, user has to + scan another one + * confirm_unload_all: the scanned location is not in the + expected one but is valid (in picking type's default destination) + * select_line: no remaining move lines in buffer + """ + return self._response() + + def unload_split(self, zone_location_id, picking_type_id): + """Indicates that now the buffer must be treated line per line + + Called from a button, users decides to handle destinations one by one. + Even if the move lines to unload all have the same destination. + + Look in ``prepare_unload`` for the definition of the buffer. + + Transitions: + * unload_single: more than one remaining line in the buffer + * unload_set_destination: there is only one remaining line in the buffer + * select_line: no remaining move lines in buffer + """ + return self._response() + + def unload_scan_pack(self, zone_location_id, picking_type_id, package_id, barcode): + """Scan the destination package to check user moves the good one + + The "unload_single" screen proposes a package (which has been + previously been set as destination package of lines of the buffer). + The user has to scan the package to validate they took the good one. + + Transitions: + * unload_single: the scanned barcode does not match the package + * unload_set_destination: the scanned barcode matches the package + * confirm_unload_set_destination: the scanned location is not in the + expected one but is valid (in picking type's default destination) + * select_line: no remaining move lines in buffer + """ + return self._response() + + def unload_set_destination( + self, zone_location_id, picking_type_id, package_id, barcode, confirmation=False + ): + """Scan the final destination for move lines in the buffer with the + destination package + + All the move lines in the buffer with the package_id as destination + package are updated with the scanned location. + + The move lines are then set to done, without backorders. + + Look in ``prepare_unload`` for the definition of the buffer. + + Transitions: + * unload_single: buffer still contains move lines, unload the next package + * unload_set_destination: the scanned location is invalid, user has to + scan another one + * confirm_unload_set_destination: the scanned location is not in the + expected one but is valid (in picking type's default destination) + * select_line: no remaining move lines in buffer + """ + # TODO on _action_done, use ``_sf_no_backorder`` in the + # context to disable backorders (see override in stock_picking.py). + # TODO return a popup with completion info alongside the response, + # see in cluster_picking.py how it's done + return self._response() + + +class ShopfloorZonePickingValidator(Component): + """Validators for the Zone Picking endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.zone_picking.validator" + _usage = "zone_picking.validator" + + def scan_location(self): + return {"barcode": {"required": True, "type": "string"}} + + def list_move_lines(self): + return { + "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": False, "nullable": True, "type": "string"}, + "order": { + "required": False, + "type": "string", + "allowed": ["priority", "location"], + }, + } + + def scan_source(self): + return { + "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": False, "nullable": True, "type": "string"}, + "order": { + "required": False, + "type": "string", + "allowed": ["priority", "location"], + }, + } + + def set_destination(self): + return { + "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": False, "nullable": True, "type": "string"}, + "order": { + "required": False, + "type": "string", + "allowed": ["priority", "location"], + }, + "quantity": {"coerce": to_float, "required": True, "type": "float"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, + } + + def is_zero(self): + return { + "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "zero": {"coerce": to_bool, "required": True, "type": "boolean"}, + } + + def stock_issue(self): + return { + "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def change_pack_lot(self): + return { + "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": False, "nullable": True, "type": "string"}, + } + + def prepare_unload(self): + return { + "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def set_destination_all(self): + return { + "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": False, "nullable": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, + } + + def unload_split(self): + return { + "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def unload_scan_pack(self): + return { + "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def unload_set_destination(self): + return { + "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": False, "nullable": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, + } + + +class ShopfloorZonePickingValidatorResponse(Component): + """Validators for the Zone Picking endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.zone_picking.validator.response" + _usage = "zone_picking.validator.response" + + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + return { + "start": {}, + "select_picking_type": self._schema_for_select_picking_type, + "select_line": self._schema_for_move_lines, + "set_line_destination": self._schema_for_move_line, + "confirm_set_line_destination": self._schema_for_move_line, + "zero_check": self._schema_for_move_line, + "change_pack_lot": self._schema_for_move_line, + "unload_all": self._schema_for_move_lines, + "confirm_unload_all": self._schema_for_move_lines, + "unload_single": self._schema_for_move_line, + "unload_set_destination": self._schema_for_move_line, + "confirm_unload_set_destination": self._schema_for_move_line, + } + + def scan_location(self): + return self._response_schema(next_states={"start", "select_picking_type"}) + + def list_move_lines(self): + return self._response_schema(next_states={"select_line"}) + + def scan_source(self): + return self._response_schema( + next_states={"select_line", "set_line_destination"} + ) + + def set_destination(self): + return self._response_schema( + next_states={ + "select_line", + "set_line_destination", + "confirm_set_line_destination", + "zero_check", + } + ) + + def is_zero(self): + return self._response_schema(next_states={"select_line"}) + + def stock_issue(self): + return self._response_schema( + next_states={"select_line", "set_line_destination"} + ) + + def change_pack_lot(self): + return self._response_schema( + next_states={"change_pack_lot", "set_line_destination", "select_line"} + ) + + def prepare_unload(self): + return self._response_schema( + next_states={ + "unload_all", + "unload_single", + "unload_set_destination", + "select_line", + } + ) + + def set_destination_all(self): + return self._response_schema( + next_states={"unload_all", "confirm_unload_all", "select_line"} + ) + + def unload_split(self): + return self._response_schema( + next_states={"unload_single", "unload_set_destination", "select_line"} + ) + + def unload_scan_pack(self): + return self._response_schema( + next_states={"unload_single", "unload_set_destination", "select_line"} + ) + + def unload_set_destination(self): + return self._response_schema( + next_states={ + "unload_single", + "unload_set_destination", + "confirm_unload_set_destination", + "select_line", + } + ) + + @property + def _schema_for_select_picking_type(self): + schema = { + "zone_location": self.schemas._schema_dict_of(self.schemas.location()), + "picking_types": self.schemas._schema_list_of(self.schemas.picking_type()), + } + return schema + + @property + def _schema_for_move_line(self): + schema = { + "zone_location": self.schemas._schema_dict_of(self.schemas.location()), + "picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()), + "move_line": self.schemas._schema_dict_of(self.schemas.move_line()), + } + return schema + + @property + def _schema_for_move_lines(self): + schema = { + "zone_location": self.schemas._schema_dict_of(self.schemas.location()), + "picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()), + "move_lines": self.schemas._schema_list_of(self.schemas.move_line()), + } + return schema diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 61dfbc481b..f55c20ab3c 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -5,6 +5,7 @@ from . import test_actions_data from . import test_actions_data_detail from . import test_single_pack_transfer +from . import test_zone_picking_base from . import test_cluster_picking_base from . import test_cluster_picking_batch from . import test_cluster_picking_select diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py new file mode 100644 index 0000000000..9a5b9f895a --- /dev/null +++ b/shopfloor/tests/test_zone_picking_base.py @@ -0,0 +1,20 @@ +from .common import CommonCase + + +class ZonePickingCommonCase(CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_zone_picking") + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + cls.wh = cls.profile.warehouse_id + cls.picking_type = cls.menu.picking_type_ids + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + + def setUp(self): + super().setUp() + with self.work_on_services(menu=self.menu, profile=self.profile) as work: + self.service = work.component(usage="zone_picking") From 819875f9b2a49a6173be23787b27f4af48073cf6 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 7 Jul 2020 09:34:24 +0200 Subject: [PATCH 293/940] Add note for optional zero check --- shopfloor/services/zone_picking.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 99172cfdd7..ea5636c7e0 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -284,10 +284,11 @@ def set_destination( Transitions: * select_line: destination has been set, showing the next lines to pick - * zero_check: if the quantity of product moved is 0 in the source - location after the move (beware: at this point the product we put in - the buffer is still considered to be in the source location, so we - have to compute the source location's quantity - qty_done). + * zero_check: if the option is active and if the quantity of product + moved is 0 in the source location after the move (beware: at this + point the product we put in the buffer is still considered to be in + the source location, so we have to compute the source location's + quantity - qty_done). * set_line_destination: the scanned location is invalid, user has to scan another one * confirm_set_line_destination: the scanned location is not in the @@ -295,6 +296,9 @@ def set_destination( """ # TODO on _action_done, use ``_sf_no_backorder`` in the # context to disable backorders (see override in stock_picking.py). + # NOTE for the implementation: zero_check is active only if the option + # is active on the picking_type (maybe shopfloor.menu), check in + # cluster picking how it's done return self._response() def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero): From db31707956a496500919028f19c3ea9db29d2c86 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 7 Jul 2020 11:15:10 +0200 Subject: [PATCH 294/940] Add comment --- shopfloor/services/zone_picking.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index ea5636c7e0..12a7629b0a 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -197,6 +197,7 @@ def _data_for_move_lines(self, zone_location, picking_type, move_lines): "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), # TODO sorting, ... (but maybe the lines are already sorted when passed) + # also, check https://github.com/camptocamp/wms/pull/29 "move_lines": self.data.move_lines(move_lines), } From 1e26a7eb5a8ca0ea9eb4dcd63f9a62b7a1a9cd40 Mon Sep 17 00:00:00 2001 From: sebalix Date: Fri, 17 Jul 2020 09:08:10 +0200 Subject: [PATCH 295/940] zone picking: implement /scan_location endpoint --- shopfloor/actions/data.py | 42 ++++++++++++- shopfloor/services/schema.py | 9 ++- shopfloor/services/zone_picking.py | 34 +++++++++- shopfloor/tests/__init__.py | 3 +- shopfloor/tests/test_zone_picking_base.py | 73 ++++++++++++++++++++++ shopfloor/tests/test_zone_picking_start.py | 47 ++++++++++++++ 6 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 shopfloor/tests/test_zone_picking_start.py diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 2ff2cbdd2b..10f04f8269 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -225,4 +225,44 @@ def picking_types(self, record, **kw): @property def _picking_type_parser(self): - return ["id", "name"] + return [ + "id", + "name", + ("lines_count", self._picking_type_lines_count), + ("picking_count", self._picking_type_picking_count), + ("priority_lines_count", self._picking_type_priority_lines_count), + ("priority_picking_count", self._picking_type_priority_picking_count), + ] + + def _picking_type_lines_count(self, rec, field): + return self.env["stock.move.line"].search_count( + [ + ("picking_id.picking_type_id", "=", rec.id), + ("qty_done", "=", 0), + ("state", "in", ("assigned", "partially_available")), + ] + ) + + def _picking_type_priority_lines_count(self, rec, field): + return self.env["stock.move.line"].search_count( + [ + ("picking_id.picking_type_id", "=", rec.id), + ("qty_done", "=", 0), + ("state", "in", ("assigned", "partially_available")), + ("picking_id.priority", "in", ["2", "3"]), + ] + ) + + def _picking_type_picking_count(self, rec, field): + return self.env["stock.picking"].search_count( + [("picking_type_id", "=", rec.id), ("state", "not in", ("cancel", "done"))] + ) + + def _picking_type_priority_picking_count(self, rec, field): + return self.env["stock.picking"].search_count( + [ + ("picking_type_id", "=", rec.id), + ("state", "not in", ("cancel", "done")), + ("priority", "in", ["2", "3"]), + ] + ) diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index f686a61695..59c085c033 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -166,4 +166,11 @@ def package_level(self): } def picking_type(self): - return self._simple_record() + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "lines_count": {"type": "float", "required": True}, + "picking_count": {"type": "float", "required": True}, + "priority_lines_count": {"type": "float", "required": True}, + "priority_picking_count": {"type": "float", "required": True}, + } diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 12a7629b0a..d433eeef38 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -181,7 +181,6 @@ def _data_for_select_picking_type(self, zone_location, picking_types): return { "zone_location": self.data.location(zone_location), # available picking types to choose from - # TODO add lines count and priority lines count in the data "picking_types": self.data.picking_types(picking_types), } @@ -201,17 +200,46 @@ def _data_for_move_lines(self, zone_location, picking_type, move_lines): "move_lines": self.data.move_lines(move_lines), } + def _find_location_move_lines_domain(self, location): + return [ + ("location_id", "child_of", location.id), + ("qty_done", "=", 0), + ("state", "in", ("assigned", "partially_available")), + ] + + def _find_location_move_lines(self, location): + """Find lines that potentially are to move in the location""" + return self.env["stock.move.line"].search( + self._find_location_move_lines_domain(location) + ) + def scan_location(self, barcode): """Scan the zone location where the picking should occur The location must be a sub-location of one of the picking types' - default destination locations of the menu. + default source locations of the menu. Transitions: * start: invalid barcode * select_picking_type: the location is valid, user has to choose a picking type """ - return self._response() + search = self.actions_for("search") + zone_location = search.location_from_scan(barcode) + if not zone_location: + return self._response_for_start(message=self.msg_store.no_location_found()) + if not zone_location.is_sublocation_of( + self.work.menu.picking_type_ids.mapped("default_location_src_id") + ): + return self._response_for_start( + message=self.msg_store.location_not_allowed() + ) + move_lines = self._find_location_move_lines(zone_location) + if not move_lines: + return self._response_for_start( + message=self.msg_store.no_lines_to_process() + ) + picking_types = move_lines.picking_id.picking_type_id + return self._response_for_select_picking_type(zone_location, picking_types) def list_move_lines(self, zone_location_id, picking_type_id, order="priority"): """List all move lines to pick, sorted diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index f55c20ab3c..a4791dcc17 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -5,7 +5,6 @@ from . import test_actions_data from . import test_actions_data_detail from . import test_single_pack_transfer -from . import test_zone_picking_base from . import test_cluster_picking_base from . import test_cluster_picking_batch from . import test_cluster_picking_select @@ -39,3 +38,5 @@ from . import test_location_content_transfer_set_destination_all from . import test_location_content_transfer_single from . import test_location_content_transfer_set_destination_package_or_line +from . import test_zone_picking_base +from . import test_zone_picking_start diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 9a5b9f895a..1a63f61aad 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -13,8 +13,81 @@ def setUpClassVars(cls, *args, **kwargs): @classmethod def setUpClassBaseData(cls, *args, **kwargs): super().setUpClassBaseData(*args, **kwargs) + # We want to limit the tests to a dedicated location in Stock/ to not + # be bothered with pickings brought by demo data + cls.zone_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone location", + "location_id": cls.stock_location.id, + "barcode": "ZONE_LOCATION", + } + ) + ) + cls.zone_sublocation = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location", + "location_id": cls.zone_location.id, + "barcode": "ZONE_SUBLOCATION", + } + ) + ) + products = cls.product_a + cls.product_b + cls.product_c + cls.product_d + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.picking1 = picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking2 = picking2 = cls._create_picking( + lines=[(cls.product_c, 10), (cls.product_d, 10)] + ) + cls.pickings = picking1 | picking2 + cls._fill_stock_for_moves( + picking1.move_lines, in_package=True, location=cls.zone_sublocation + ) + cls._fill_stock_for_moves(picking2.move_lines, location=cls.zone_sublocation) + cls.pickings.action_assign() def setUp(self): super().setUp() with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="zone_picking") + + def assert_response_start(self, response, message=None): + self.assert_response(response, next_state="start", message=message) + + def _assert_response_select_picking_type( + self, state, response, zone_location, picking_types, message=None + ): + self.assert_response( + response, + next_state=state, + data={ + "zone_location": self.data.location(zone_location), + "picking_types": self.data.picking_types(picking_types), + }, + message=message, + ) + + def assert_response_select_picking_type( + self, response, zone_location, picking_types, message=None + ): + self._assert_response_select_picking_type( + "select_picking_type", + response, + zone_location, + picking_types, + message=message, + ) diff --git a/shopfloor/tests/test_zone_picking_start.py b/shopfloor/tests/test_zone_picking_start.py new file mode 100644 index 0000000000..b339bbd893 --- /dev/null +++ b/shopfloor/tests/test_zone_picking_start.py @@ -0,0 +1,47 @@ +from .test_zone_picking_base import ZonePickingCommonCase + + +class ZonePickingStartCase(ZonePickingCommonCase): + """Tests for endpoint used from start + + * /scan_location + + """ + + def test_scan_location_wrong_barcode(self): + """Scanned location invalid, no location found.""" + response = self.service.dispatch( + "scan_location", params={"barcode": "UNKNOWN LOCATION"}, + ) + self.assert_response_start( + response, message=self.service.msg_store.no_location_found(), + ) + + def test_scan_location_not_allowed(self): + """Scanned location not allowed because it's not a child of picking + types' source location. + """ + response = self.service.dispatch( + "scan_location", params={"barcode": self.customer_location.barcode}, + ) + self.assert_response_start( + response, message=self.service.msg_store.location_not_allowed(), + ) + + def test_scan_location_no_move_lines(self): + """Scanned location valid, but no move lines found in it.""" + response = self.service.dispatch( + "scan_location", params={"barcode": self.shelf2.barcode}, + ) + self.assert_response_start( + response, message=self.service.msg_store.no_lines_to_process(), + ) + + def test_scan_location_ok(self): + """Scanned location valid, list of picking types of related move lines.""" + response = self.service.dispatch( + "scan_location", params={"barcode": self.zone_location.barcode}, + ) + self.assert_response_select_picking_type( + response, zone_location=self.zone_location, picking_types=self.picking_type, + ) From 554e7bd2f71b99fada588c524669e94890fa4dbd Mon Sep 17 00:00:00 2001 From: sebalix Date: Fri, 17 Jul 2020 15:58:05 +0200 Subject: [PATCH 296/940] zone picking: implement /list_move_lines endpoint --- shopfloor/services/zone_picking.py | 20 ++++++-- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_zone_picking_base.py | 41 +++++++++++++++ .../test_zone_picking_select_picking_type.py | 51 +++++++++++++++++++ 4 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 shopfloor/tests/test_zone_picking_select_picking_type.py diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index d433eeef38..b3df881ef7 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -200,17 +200,20 @@ def _data_for_move_lines(self, zone_location, picking_type, move_lines): "move_lines": self.data.move_lines(move_lines), } - def _find_location_move_lines_domain(self, location): - return [ + def _find_location_move_lines_domain(self, location, picking_type=None): + domain = [ ("location_id", "child_of", location.id), ("qty_done", "=", 0), ("state", "in", ("assigned", "partially_available")), ] + if picking_type: + domain += [("picking_id.picking_type_id", "=", picking_type.id)] + return domain - def _find_location_move_lines(self, location): + def _find_location_move_lines(self, location, picking_type=None): """Find lines that potentially are to move in the location""" return self.env["stock.move.line"].search( - self._find_location_move_lines_domain(location) + self._find_location_move_lines_domain(location, picking_type) ) def scan_location(self, barcode): @@ -247,7 +250,14 @@ def list_move_lines(self, zone_location_id, picking_type_id, order="priority"): Transitions: * select_line: show the list of move lines """ - return self._response() + zone_location = self.env["stock.location"].browse(zone_location_id) + if not zone_location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + picking_type = self.env["stock.picking.type"].browse(picking_type_id) + if not picking_type.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_lines = self._find_location_move_lines(zone_location, picking_type) + return self._response_for_select_line(zone_location, picking_type, move_lines) def scan_source(self, zone_location_id, picking_type_id, barcode, order="priority"): """Select a move line or narrow the list of move lines diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index a4791dcc17..c100ca244a 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -40,3 +40,4 @@ from . import test_location_content_transfer_set_destination_package_or_line from . import test_zone_picking_base from . import test_zone_picking_start +from . import test_zone_picking_select_picking_type diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 1a63f61aad..5b708639a1 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -91,3 +91,44 @@ def assert_response_select_picking_type( picking_types, message=message, ) + + def _assert_response_select_line( + self, + state, + response, + zone_location, + picking_type, + move_lines, + message=None, + popup=None, + ): + self.assert_response( + response, + next_state=state, + data={ + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "move_lines": self.data.move_lines(move_lines), + }, + message=message, + popup=popup, + ) + + def assert_response_select_line( + self, + response, + zone_location, + picking_type, + move_lines, + message=None, + popup=None, + ): + self._assert_response_select_line( + "select_line", + response, + zone_location, + picking_type, + move_lines, + message=message, + popup=popup, + ) diff --git a/shopfloor/tests/test_zone_picking_select_picking_type.py b/shopfloor/tests/test_zone_picking_select_picking_type.py new file mode 100644 index 0000000000..a07ee6be9b --- /dev/null +++ b/shopfloor/tests/test_zone_picking_select_picking_type.py @@ -0,0 +1,51 @@ +from .test_zone_picking_base import ZonePickingCommonCase + + +class ZonePickingSelectPickingTypeCase(ZonePickingCommonCase): + """Tests for endpoint used from select_picking_type + + * /list_move_lines + + """ + + def test_list_move_lines_wrong_parameters(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "list_move_lines", + params={ + "zone_location_id": 1234567890, + "picking_type_id": picking_type.id, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + response = self.service.dispatch( + "list_move_lines", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": 1234567890, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + + def test_list_move_lines_ok(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "list_move_lines", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + }, + ) + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + ) From d9c702076ae31f5095047e26f4cedf1908f8dd71 Mon Sep 17 00:00:00 2001 From: sebalix Date: Sat, 18 Jul 2020 13:24:56 +0200 Subject: [PATCH 297/940] zone picking: implement /scan_source endpoint --- shopfloor/actions/message.py | 20 +- shopfloor/services/zone_picking.py | 156 +++++++++- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_zone_picking_base.py | 71 ++++- .../tests/test_zone_picking_select_line.py | 291 ++++++++++++++++++ 5 files changed, 521 insertions(+), 18 deletions(-) create mode 100644 shopfloor/tests/test_zone_picking_select_line.py diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 9333acb952..8c422b7e33 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -152,7 +152,13 @@ def confirm_canceled_scan_next_pack(self): def no_pack_in_location(self, location): return { "message_type": "error", - "body": _("Location %s doesn't contain any package." % location.name), + "body": _("Location %s doesn't contain any package.") % location.name, + } + + def several_lines_in_location(self, location): + return { + "message_type": "warning", + "body": _("Several lines found in %s, please scan one.") % location.name, } def several_packs_in_location(self, location): @@ -252,6 +258,12 @@ def product_mixed_package_scan_package(self): ), } + def product_not_found(self): + return { + "message_type": "error", + "body": _("This product does not exist anymore."), + } + def product_not_found_in_pickings(self): return { "message_type": "warning", @@ -275,6 +287,12 @@ def lot_multiple_packages_scan_package(self): "body": _("This lot is part of multiple packages, please scan a package."), } + def lot_not_found(self): + return { + "message_type": "error", + "body": _("This lot does not exist anymore."), + } + def lot_not_found_in_pickings(self): return { "message_type": "warning", diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index b3df881ef7..2d015fc31a 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -1,3 +1,5 @@ +from odoo.fields import first + from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component @@ -195,12 +197,12 @@ def _data_for_move_lines(self, zone_location, picking_type, move_lines): return { "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), - # TODO sorting, ... (but maybe the lines are already sorted when passed) - # also, check https://github.com/camptocamp/wms/pull/29 "move_lines": self.data.move_lines(move_lines), } - def _find_location_move_lines_domain(self, location, picking_type=None): + def _find_location_move_lines_domain( + self, location, picking_type=None, package=None, product=None, lot=None + ): domain = [ ("location_id", "child_of", location.id), ("qty_done", "=", 0), @@ -208,13 +210,46 @@ def _find_location_move_lines_domain(self, location, picking_type=None): ] if picking_type: domain += [("picking_id.picking_type_id", "=", picking_type.id)] + if package: + domain += [("package_id", "=", package.id)] + if product: + domain += [("product_id", "=", product.id)] + if lot: + domain += [("lot_id", "=", lot.id)] return domain - def _find_location_move_lines(self, location, picking_type=None): + def _find_location_move_lines( + self, + location, + picking_type=None, + package=None, + product=None, + lot=None, + order="priority", + ): """Find lines that potentially are to move in the location""" - return self.env["stock.move.line"].search( - self._find_location_move_lines_domain(location, picking_type) + move_lines = self.env["stock.move.line"].search( + self._find_location_move_lines_domain( + location, picking_type, package, product, lot + ) ) + sort_keys_func, reverse = self._sort_key_move_lines(order) + move_lines = move_lines.sorted(sort_keys_func, reverse=reverse) + return move_lines + + @staticmethod + def _sort_key_move_lines(order): + """Return a `(sort_keys_func, reverse)` tuple for move lines.""" + if order == "priority": + return lambda line: line.move_id.priority, True + elif order == "location": + return ( + lambda line: ( + line.location_id.shopfloor_picking_sequence, + line.location_id.name, + ), + False, + ) def scan_location(self, barcode): """Scan the zone location where the picking should occur @@ -256,9 +291,53 @@ def list_move_lines(self, zone_location_id, picking_type_id, order="priority"): picking_type = self.env["stock.picking.type"].browse(picking_type_id) if not picking_type.exists(): return self._response_for_start(message=self.msg_store.record_not_found()) - move_lines = self._find_location_move_lines(zone_location, picking_type) + move_lines = self._find_location_move_lines( + zone_location, picking_type, order=order + ) return self._response_for_select_line(zone_location, picking_type, move_lines) + def _scan_source_location(self, zone_location, picking_type, location): + """Return the move line related to the scanned `location`. + + The method tries to identify unambiguously a move line in the location + if possible, otherwise return `False`. + """ + quants = self.env["stock.quant"].search([("location_id", "=", location.id)]) + product = quants.product_id + lot = quants.lot_id + package = quants.package_id + if len(product) > 1 or len(lot) > 1 or len(package) > 1: + return False + domain = [("location_id", "=", location.id)] + if product: + domain.append(("product_id", "=", product.id)) + if lot: + domain.append(("lot_id", "=", lot.id)) + if package: + domain.append(("package_id", "=", package.id)) + move_line = self.env["stock.move.line"].search(domain) + if len(move_line) == 1: + return move_line + raise False + + def _scan_source_package(self, zone_location, picking_type, package, order): + move_lines = self._find_location_move_lines( + zone_location, picking_type, package=package, order=order + ) + return first(move_lines) + + def _scan_source_product(self, zone_location, picking_type, product, order): + move_lines = self._find_location_move_lines( + zone_location, picking_type, product=product, order=order + ) + return first(move_lines) + + def _scan_source_lot(self, zone_location, picking_type, lot, order): + move_lines = self._find_location_move_lines( + zone_location, picking_type, lot=lot, order=order + ) + return first(move_lines) + def scan_source(self, zone_location_id, picking_type_id, barcode, order="priority"): """Select a move line or narrow the list of move lines @@ -279,7 +358,68 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit * select_line: barcode not found or narrow the list on a location * set_line_destination: a line has been selected for picking """ - return self._response() + zone_location = self.env["stock.location"].browse(zone_location_id) + if not zone_location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + picking_type = self.env["stock.picking.type"].browse(picking_type_id) + if not picking_type.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + # select corresponding move line from barcode (location, package, product, lot) + search = self.actions_for("search") + move_line = self.env["stock.move.line"] + location = search.location_from_scan(barcode) + if location: + if not location.is_sublocation_of(zone_location): + return self._response_for_start( + message=self.msg_store.location_not_allowed() + ) + move_line = self._scan_source_location( + zone_location, picking_type, location + ) + # if no move line, narrow the list of move lines on the scanned location + if not move_line: + response = self.list_move_lines(location.id, picking_type.id) + return self._response( + base_response=response, + message=self.msg_store.several_lines_in_location(location), + ) + package = search.package_from_scan(barcode) + if package: + move_line = self._scan_source_package( + zone_location, picking_type, package, order + ) + if not move_line: + response = self.list_move_lines(zone_location.id, picking_type.id) + return self._response( + base_response=response, message=self.msg_store.package_not_found(), + ) + product = search.product_from_scan(barcode) + if product: + move_line = self._scan_source_product( + zone_location, picking_type, product, order + ) + if not move_line: + response = self.list_move_lines(zone_location.id, picking_type.id) + return self._response( + base_response=response, message=self.msg_store.product_not_found(), + ) + lot = search.lot_from_scan(barcode) + if lot: + move_line = self._scan_source_lot(zone_location, picking_type, lot, order) + if not move_line: + response = self.list_move_lines(zone_location.id, picking_type.id) + return self._response( + base_response=response, message=self.msg_store.lot_not_found(), + ) + # barcode not found, get back on 'select_line' screen + if not move_line: + response = self.list_move_lines(zone_location.id, picking_type.id) + return self._response( + base_response=response, message=self.msg_store.barcode_not_found(), + ) + return self._response_for_set_line_destination( + zone_location, picking_type, move_line + ) def set_destination( self, diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index c100ca244a..90a93e7bbe 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -41,3 +41,4 @@ from . import test_zone_picking_base from . import test_zone_picking_start from . import test_zone_picking_select_picking_type +from . import test_zone_picking_select_line diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 5b708639a1..ed857bb349 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -26,14 +26,25 @@ def setUpClassBaseData(cls, *args, **kwargs): } ) ) - cls.zone_sublocation = ( + cls.zone_sublocation1 = ( cls.env["stock.location"] .sudo() .create( { - "name": "Zone sub-location", + "name": "Zone sub-location 1", "location_id": cls.zone_location.id, - "barcode": "ZONE_SUBLOCATION", + "barcode": "ZONE_SUBLOCATION_1", + } + ) + ) + cls.zone_sublocation2 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location 2", + "location_id": cls.zone_location.id, + "barcode": "ZONE_SUBLOCATION_2", } ) ) @@ -47,18 +58,34 @@ def setUpClassBaseData(cls, *args, **kwargs): } ) - cls.picking1 = picking1 = cls._create_picking( - lines=[(cls.product_a, 10), (cls.product_b, 10)] - ) + cls.picking1 = picking1 = cls._create_picking(lines=[(cls.product_a, 10)]) cls.picking2 = picking2 = cls._create_picking( - lines=[(cls.product_c, 10), (cls.product_d, 10)] + lines=[(cls.product_b, 10), (cls.product_c, 10)] ) cls.pickings = picking1 | picking2 cls._fill_stock_for_moves( - picking1.move_lines, in_package=True, location=cls.zone_sublocation + picking1.move_lines, in_package=True, location=cls.zone_sublocation1 + ) + cls._fill_stock_for_moves( + picking2.move_lines, in_lot=True, location=cls.zone_sublocation2 ) - cls._fill_stock_for_moves(picking2.move_lines, location=cls.zone_sublocation) cls.pickings.action_assign() + # Some records not related at all to the processed move lines + cls.free_package = cls.env["stock.quant.package"].create( + {"name": "FREE_PACKAGE"} + ) + cls.free_lot = cls.env["stock.production.lot"].create( + { + "name": "FREE_LOT", + "product_id": cls.product_a.id, + "company_id": cls.env.company.id, + } + ) + cls.free_product = ( + cls.env["product.product"] + .sudo() + .create({"name": "FREE_PRODUCT", "barcode": "FREE_PRODUCT"}) + ) def setUp(self): super().setUp() @@ -132,3 +159,29 @@ def assert_response_select_line( message=message, popup=popup, ) + + def _assert_response_set_line_destination( + self, state, response, zone_location, picking_type, move_line, message=None, + ): + self.assert_response( + response, + next_state=state, + data={ + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "move_line": self.data.move_line(move_line), + }, + message=message, + ) + + def assert_response_set_line_destination( + self, response, zone_location, picking_type, move_line, message=None, + ): + self._assert_response_set_line_destination( + "set_line_destination", + response, + zone_location, + picking_type, + move_line, + message=message, + ) diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py new file mode 100644 index 0000000000..e9142500cc --- /dev/null +++ b/shopfloor/tests/test_zone_picking_select_line.py @@ -0,0 +1,291 @@ +from .test_zone_picking_base import ZonePickingCommonCase + + +class ZonePickingSelectLineCase(ZonePickingCommonCase): + """Tests for endpoint used from select_line + + * /list_move_lines (to change order) + * /scan_source + + """ + + def test_list_move_lines_order_by_location(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + # Ensure that the second location is ordered before the first one + # to avoid "false-positive" checks + self.zone_sublocation2.name = "a " + self.zone_sublocation2.name + response = self.service.dispatch( + "list_move_lines", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "order": "location", + }, + ) + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = move_lines.sorted(lambda l: l.location_id.name) + self.assert_response_select_line( + response, zone_location, picking_type, move_lines, + ) + + def test_scan_source_wrong_parameters(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "scan_source", + params={ + "zone_location_id": 1234567890, + "picking_type_id": picking_type.id, + "barcode": self.zone_sublocation1.barcode, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + response = self.service.dispatch( + "scan_source", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": 1234567890, + "barcode": self.zone_sublocation1.barcode, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + + def test_scan_source_barcode_location_not_allowed(self): + """Scan source: scanned location not allowed.""" + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "scan_source", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": self.customer_location.barcode, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.location_not_allowed(), + ) + + def test_scan_source_barcode_location_one_move_line(self): + """Scan source: scanned location 'Zone sub-location 1' contains only + one move line, next step 'set_line_destination' expected. + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "scan_source", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": self.zone_sublocation1.barcode, + }, + ) + move_line = self.picking1.move_line_ids + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + ) + + def test_scan_source_barcode_location_several_move_lines(self): + """Scan source: scanned location 'Zone sub-location 2' contains two + move lines, next step 'select_line' expected with the list of these + move lines. + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "scan_source", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": self.zone_sublocation2.barcode, + }, + ) + move_lines = self.picking2.move_line_ids + self.assert_response_select_line( + response, + zone_location=self.zone_sublocation2, + picking_type=self.picking_type, + move_lines=move_lines, + message=self.service.msg_store.several_lines_in_location( + self.zone_sublocation2 + ), + ) + + def test_scan_source_barcode_package(self): + """Scan source: scanned package has one related move line, + next step 'set_line_destination' expected on it. + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + package = self.picking1.package_level_ids[0].package_id + response = self.service.dispatch( + "scan_source", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": package.name, + }, + ) + move_lines = self.service._find_location_move_lines( + zone_location, picking_type, package=package, + ) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + move_line = move_lines[0] + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + ) + + def test_scan_source_barcode_package_not_found(self): + """Scan source: scanned package has no related move line, + next step 'select_line' expected. + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "scan_source", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": self.free_package.name, + }, + ) + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + message=self.service.msg_store.package_not_found(), + ) + + def test_scan_source_barcode_product(self): + """Scan source: scanned product has one related move line, + next step 'set_line_destination' expected on it. + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "scan_source", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": self.product_a.barcode, + }, + ) + move_line = self.service._find_location_move_lines( + zone_location, picking_type, product=self.product_a, + ) + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + ) + + def test_scan_source_barcode_product_not_found(self): + """Scan source: scanned product has no related move line, + next step 'select_line' expected. + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "scan_source", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": self.free_product.barcode, + }, + ) + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + message=self.service.msg_store.product_not_found(), + ) + + def test_scan_source_barcode_lot(self): + """Scan source: scanned lot has one related move line, + next step 'set_line_destination' expected on it. + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + lot = self.picking2.move_line_ids.lot_id[0] + response = self.service.dispatch( + "scan_source", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": lot.name, + }, + ) + move_lines = self.service._find_location_move_lines( + zone_location, picking_type, lot=lot, + ) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + move_line = move_lines[0] + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + ) + + def test_scan_source_barcode_lot_not_found(self): + """Scan source: scanned lot has no related move line, + next step 'select_line' expected. + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "scan_source", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": self.free_lot.name, + }, + ) + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + message=self.service.msg_store.lot_not_found(), + ) + + def test_scan_source_barcode_not_found(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "scan_source", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": "UNKNOWN", + }, + ) + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = move_lines.sorted(lambda l: l.move_id.priority) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + message=self.service.msg_store.barcode_not_found(), + ) From cd0f64a7d24e2aa675bf0ba78dda448fce655311 Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 21 Jul 2020 10:39:28 +0200 Subject: [PATCH 298/940] zone picking: implement /set_destination + change zero_check implementation (both for zone and cluster picking) to only check the location stock status (all products combined). --- shopfloor/actions/message.py | 24 + shopfloor/models/__init__.py | 1 + shopfloor/models/stock_location.py | 24 + shopfloor/models/stock_move.py | 20 + shopfloor/services/cluster_picking.py | 32 +- .../services/location_content_transfer.py | 22 +- shopfloor/services/zone_picking.py | 222 +++++++- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_cluster_picking_scan.py | 27 +- shopfloor/tests/test_zone_picking_base.py | 99 +++- .../test_zone_picking_set_line_destination.py | 519 ++++++++++++++++++ 11 files changed, 908 insertions(+), 83 deletions(-) create mode 100644 shopfloor/models/stock_move.py create mode 100644 shopfloor/tests/test_zone_picking_set_line_destination.py diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 8c422b7e33..6a3be13450 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -89,6 +89,24 @@ def package_not_available_in_picking(self, package, picking): ), } + def package_not_empty(self, package): + return { + "message_type": "warning", + "body": _("Package {} is not empty.").format(package.name), + } + + def package_already_used(self, package): + return { + "message_type": "warning", + "body": _("Package {} is already used.").format(package.name), + } + + def dest_package_required(self): + return { + "message_type": "warning", + "body": _("A destination package is required."), + } + def line_not_available_in_picking(self, picking): return { "message_type": "warning", @@ -365,3 +383,9 @@ def location_empty(self, location): "message_type": "info", "body": _("Location {} empty").format(location.name), } + + def unable_to_pick_more(self, quantity): + return { + "message_type": "error", + "body": _("You must not pick more than {} units.").format(quantity), + } diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index 1bfd3ece74..05a95579d3 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -4,6 +4,7 @@ from . import shopfloor_profile from . import stock_inventory from . import stock_location +from . import stock_move from . import stock_move_line from . import stock_package_level from . import stock_picking diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py index 53eb9eb88b..db4672b573 100644 --- a/shopfloor/models/stock_location.py +++ b/shopfloor/models/stock_location.py @@ -1,4 +1,5 @@ from odoo import fields, models +from odoo.tools.float_utils import float_compare class StockLocation(models.Model): @@ -38,3 +39,26 @@ def _get_reserved_move_lines(self): def _compute_reserved_move_lines(self): for rec in self: rec.update({"reserved_move_line_ids": rec._get_reserved_move_lines()}) + + def planned_qty_in_location_is_empty(self): + """Return if a location will be empty when move lines will be confirmed + + Used for the "zero check". We need to know if a location is empty, but since + we set the move lines to "done" only at the end of the unload workflow, we + have to look at the qty_done of the move lines from this location. + """ + self.ensure_one() + quants = self.env["stock.quant"].search( + [("quantity", ">", 0), ("location_id", "=", self.id)] + ) + remaining = sum(quants.mapped("quantity")) + lines = self.env["stock.move.line"].search( + [ + ("state", "!=", "done"), + ("location_id", "=", self.id), + ("qty_done", ">", 0), + ] + ) + planned = remaining - sum(lines.mapped("qty_done")) + compare = float_compare(planned, 0, precision_rounding=0.01) + return compare <= 0 diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py new file mode 100644 index 0000000000..0194457f9d --- /dev/null +++ b/shopfloor/models/stock_move.py @@ -0,0 +1,20 @@ +from odoo import models + + +class StockMove(models.Model): + _inherit = "stock.move" + + def split_other_move_lines(self, move_lines): + """Substract `move_lines` from `move.move_line_ids`, put the result + in a new move and returns it. + """ + self.ensure_one() + other_move_lines = self.move_line_ids - move_lines + if other_move_lines: + qty_to_split = sum(other_move_lines.mapped("product_uom_qty")) + backorder_move_id = self._split(qty_to_split) + backorder_move = self.browse(backorder_move_id) + backorder_move.move_line_ids = other_move_lines + backorder_move._action_assign() + return backorder_move + return False diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index b065ad02f1..f7577f7d95 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -579,12 +579,7 @@ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantit if qty_greater: return self._response_for_scan_destination( move_line, - message={ - "message_type": "error", - "body": _("You must not pick more than {} units.").format( - move_line.product_uom_qty - ), - }, + message=self.msg_store.unable_to_pick_more(move_line.product_uom_qty), ) elif qty_lesser: # split the move line which will be processed later (maybe the user @@ -623,9 +618,7 @@ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantit move_line.write({"qty_done": quantity, "result_package_id": bin_package.id}) zero_check = move_line.picking_id.picking_type_id.shopfloor_zero_check - if zero_check and self._planned_qty_in_location_is_empty( - move_line.product_id, move_line.location_id - ): + if zero_check and move_line.location_id.planned_qty_in_location_is_empty(): return self._response_for_zero_check(batch, move_line) return self._pick_next_line( @@ -638,27 +631,6 @@ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantit force_line=new_line, ) - def _planned_qty_in_location_is_empty(self, product, location): - """Return if a location will be empty when move lines will be confirmed - - Used for the "zero check". We need to know if a location is empty, but since - we set the move lines to "done" only at the end of the unload workflow, we - have to look at the qty_done of the move lines from this location. - """ - remaining = product.with_context(location=location.id).qty_available - lines_in_loc = self.env["stock.move.line"].search( - # TODO do we care about lots here? - [ - ("state", "!=", "done"), - ("location_id", "=", location.id), - ("product_id", "=", product.id), - ] - ) - planned = remaining - sum(lines_in_loc.mapped("qty_done")) - rounding = product.uom_id.rounding - compare = float_compare(planned, 0, precision_rounding=rounding) - return compare <= 0 - def _are_all_dest_location_same(self, batch): lines_to_unload = self._lines_to_unload(batch) return len(lines_to_unload.mapped("location_dest_id")) == 1 diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index c3faf64454..02cfe4683d 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -317,20 +317,6 @@ def _set_destination_lines(self, pickings, move_lines, dest_location): move_lines.package_level_id.location_dest_id = dest_location pickings.action_done() - def _split_other_move_lines(self, move, move_lines): - """Substract `move_lines` from `move.move_line_ids` and put the result - in a new move. - """ - other_move_lines = move.move_line_ids - move_lines - if other_move_lines: - qty_to_split = sum(other_move_lines.mapped("product_uom_qty")) - backorder_move_id = move._split(qty_to_split) - backorder_move = self.env["stock.move"].browse(backorder_move_id) - backorder_move.move_line_ids = other_move_lines - backorder_move._action_assign() - return backorder_move - return False - def set_destination_all(self, location_id, barcode, confirmation=False): """Scan destination location for all the moves of the location @@ -553,7 +539,7 @@ def set_destination_package( # Check if there is no other lines linked to the move others than # the lines related to the package itself. In such case we have to # split the move to process only the lines related to the package. - self._split_other_move_lines(package_move, package_move_lines) + package_move.split_other_move_lines(package_move_lines) package_level.location_dest_id = scanned_location package_moves.with_context(_sf_no_backorder=True)._action_done() move_lines = self._find_transfer_move_lines(location) @@ -625,7 +611,7 @@ def set_destination_line( (new_move | current_move)._action_assign() for remaining_move_line in current_move.move_line_ids: remaining_move_line.qty_done = remaining_move_line.product_uom_qty - self._split_other_move_lines(move_line.move_id, move_line) + move_line.move_id.split_other_move_lines(move_line) move_line.location_dest_id = scanned_location move_line.move_id.with_context(_sf_no_backorder=True)._action_done() move_lines = self._find_transfer_move_lines(location) @@ -695,7 +681,7 @@ def stock_out_package(self, location_id, package_level_id): # Check if there is no other lines linked to the move others than # the lines related to the package itself. In such case we have to # split the move to process only the lines related to the package. - self._split_other_move_lines(package_move, package_move_lines) + package_move.split_other_move_lines(package_move_lines) lot = package_move.move_line_ids.lot_id package_move._do_unreserve() package_move._recompute_state() @@ -747,7 +733,7 @@ def stock_out_line(self, location_id, move_line_id): move_lines = self._find_transfer_move_lines(location) return self._response_for_start_single(move_lines.mapped("picking_id")) inventory = self.actions_for("inventory") - self._split_other_move_lines(move_line.move_id, move_line) + move_line.move_id.split_other_move_lines(move_line) move_line_src_location = move_line.location_id move = move_line.move_id package = move_line.package_id diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 2d015fc31a..167947ec3b 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -1,4 +1,5 @@ from odoo.fields import first +from odoo.tools.float_utils import float_compare from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component @@ -99,21 +100,19 @@ def _response_for_select_line( ) def _response_for_set_line_destination( - self, zone_location, picking_type, move_line, message=None + self, + zone_location, + picking_type, + move_line, + message=None, + confirmation_required=False, ): + if confirmation_required and not message: + message = self.msg_store.need_confirmation() + data = self._data_for_move_line(zone_location, picking_type, move_line) + data["confirmation_required"] = confirmation_required return self._response( - next_state="set_line_destination", - data=self._data_for_move_line(zone_location, picking_type, move_line), - message=message, - ) - - def _response_for_confirm_set_line_destination( - self, zone_location, picking_type, move_line, message=None - ): - return self._response( - next_state="confirm_set_line_destination", - data=self._data_for_move_line(zone_location, picking_type, move_line), - message=message, + next_state="set_line_destination", data=data, message=message, ) def _response_for_zero_check( @@ -121,7 +120,7 @@ def _response_for_zero_check( ): return self._response( next_state="zero_check", - data=self._data_for_move_line(zone_location, picking_type, location), + data=self._data_for_location(zone_location, picking_type, location), message=message, ) @@ -200,6 +199,13 @@ def _data_for_move_lines(self, zone_location, picking_type, move_lines): "move_lines": self.data.move_lines(move_lines), } + def _data_for_location(self, zone_location, picking_type, location): + return { + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "location": self.data.location(location), + } + def _find_location_move_lines_domain( self, location, picking_type=None, package=None, product=None, lot=None ): @@ -421,6 +427,133 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit zone_location, picking_type, move_line ) + def _set_destination_location( + self, zone_location, picking_type, move_line, quantity, confirmation, location + ): + # Ask confirmation to the user if the scanned location is not in the + # expected ones but is valid (in picking type's default destination) + if not location.is_sublocation_of(move_line.location_dest_id) and ( + not confirmation + and location.is_sublocation_of(picking_type.default_location_dest_id) + ): + return self._response_for_set_line_destination( + zone_location, + picking_type, + move_line, + message=self.msg_store.confirm_location_changed( + move_line.location_dest_id, location + ), + confirmation_required=True, + ) + # A valid location is a sub-location of the original destination, or a + # sub-location of the picking type's default destination location if + # `confirmation is True + if not location.is_sublocation_of(move_line.location_dest_id) and ( + confirmation + and not location.is_sublocation_of(picking_type.default_location_dest_id) + ): + return self._response_for_set_line_destination( + zone_location, + picking_type, + move_line, + message=self.msg_store.dest_location_not_allowed(), + ) + # If no destination package + if not move_line.result_package_id: + return self._response_for_set_line_destination( + zone_location, + picking_type, + move_line, + message=self.msg_store.dest_package_required(), + ) + # destination location set to the scanned one + move_line.location_dest_id = location + # the quantity done is set to the passed quantity + move_line.qty_done = quantity + # if the move has other move lines, it is split to have only this move line + move_line.move_id.split_other_move_lines(move_line) + # set to done (without backorder) + move_line.move_id.with_context(_sf_no_backorder=True)._action_done() + # try to re-assign any split move (in case of partial qty) + if "confirmed" in move_line.picking_id.move_lines.mapped("state"): + move_line.picking_id.action_assign() + # Zero check + zero_check = picking_type.shopfloor_zero_check + if zero_check and move_line.location_id.planned_qty_in_location_is_empty(): + return self._response_for_zero_check( + zone_location, picking_type, move_line.location_id + ) + + def _is_package_empty(self, package): + return not bool(package.quant_ids) + + def _is_package_already_used(self, package): + return bool( + self.env["stock.move.line"].search_count( + [ + ("state", "not in", ("done", "cancel")), + ("result_package_id", "=", package.id), + ] + ) + ) + + def _set_destination_package( + self, zone_location, picking_type, move_line, quantity, package + ): + # A valid package is: + # * an empty package + # * not used as destination for another move line + if not self._is_package_empty(package): + return self._response_for_set_line_destination( + zone_location, + picking_type, + move_line, + message=self.msg_store.package_not_empty(package), + ) + if self._is_package_already_used(package): + return self._response_for_set_line_destination( + zone_location, + picking_type, + move_line, + message=self.msg_store.package_already_used(package), + ) + # the quantity done is set to the passed quantity + # but if we move a partial qty, we need to split the move line + rounding = move_line.product_uom_id.rounding + compare = float_compare( + quantity, move_line.product_uom_qty, precision_rounding=rounding + ) + qty_lesser = compare == -1 + qty_greater = compare == 1 + if qty_greater: + return self._response_for_set_line_destination( + zone_location, + picking_type, + move_line, + message=self.msg_store.unable_to_pick_more(move_line.product_uom_qty), + ) + elif qty_lesser: + # split the move line which will be processed later + remaining = move_line.product_uom_qty - quantity + move_line.copy({"product_uom_qty": remaining, "qty_done": 0}) + # if we didn't bypass reservation update, the quant reservation + # would be reduced as much as the deduced quantity, which is wrong + # as we only moved the quantity to a new move line + move_line.with_context( + bypass_reservation_update=True + ).product_uom_qty = quantity + move_line.qty_done = quantity + # destination package is set to the scanned one + move_line.result_package_id = package + # the field ``shopfloor_user_id`` is updated with the current user + move_line.shopfloor_user_id = self.env.user + # Zero check + zero_check = picking_type.shopfloor_zero_check + if zero_check and move_line.location_id.planned_qty_in_location_is_empty(): + return self._response_for_zero_check( + zone_location, picking_type, move_line.location_id + ) + def set_destination( self, zone_location_id, @@ -429,6 +562,7 @@ def set_destination( barcode, quantity, confirmation=False, + order="priority", ): """Set a destination location (and done) or a destination package (in buffer) @@ -473,12 +607,35 @@ def set_destination( * confirm_set_line_destination: the scanned location is not in the expected one but is valid (in picking type's default destination) """ - # TODO on _action_done, use ``_sf_no_backorder`` in the - # context to disable backorders (see override in stock_picking.py). - # NOTE for the implementation: zero_check is active only if the option - # is active on the picking_type (maybe shopfloor.menu), check in - # cluster picking how it's done - return self._response() + zone_location = self.env["stock.location"].browse(zone_location_id) + if not zone_location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + picking_type = self.env["stock.picking.type"].browse(picking_type_id) + if not picking_type.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + search = self.actions_for("search") + # When the barcode is a location + location = search.location_from_scan(barcode) + if location: + response = self._set_destination_location( + zone_location, picking_type, move_line, quantity, confirmation, location + ) + if response: + return response + # When the barcode is a package + package = search.package_from_scan(barcode) + if package: + location = move_line.location_dest_id + response = self._set_destination_package( + zone_location, picking_type, move_line, quantity, package + ) + if response: + return response + # Process the next line + return self.list_move_lines(zone_location.id, picking_type.id) def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero): """Confirm or not if the source location of a move has zero qty @@ -695,6 +852,7 @@ def set_destination(self): return { "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"}, "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, "barcode": {"required": False, "nullable": True, "type": "string"}, "order": { "required": False, @@ -783,8 +941,7 @@ def _states(self): "select_picking_type": self._schema_for_select_picking_type, "select_line": self._schema_for_move_lines, "set_line_destination": self._schema_for_move_line, - "confirm_set_line_destination": self._schema_for_move_line, - "zero_check": self._schema_for_move_line, + "zero_check": self._schema_for_zero_check, "change_pack_lot": self._schema_for_move_line, "unload_all": self._schema_for_move_lines, "confirm_unload_all": self._schema_for_move_lines, @@ -806,12 +963,7 @@ def scan_source(self): def set_destination(self): return self._response_schema( - next_states={ - "select_line", - "set_line_destination", - "confirm_set_line_destination", - "zero_check", - } + next_states={"select_line", "set_line_destination", "zero_check"} ) def is_zero(self): @@ -876,6 +1028,11 @@ def _schema_for_move_line(self): "zone_location": self.schemas._schema_dict_of(self.schemas.location()), "picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()), "move_line": self.schemas._schema_dict_of(self.schemas.move_line()), + "confirmation_required": { + "type": "boolean", + "nullable": True, + "required": False, + }, } return schema @@ -887,3 +1044,12 @@ def _schema_for_move_lines(self): "move_lines": self.schemas._schema_list_of(self.schemas.move_line()), } return schema + + @property + def _schema_for_zero_check(self): + schema = { + "zone_location": self.schemas._schema_dict_of(self.schemas.location()), + "picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()), + "location": self.schemas._schema_dict_of(self.schemas.location()), + } + return schema diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 90a93e7bbe..82123a252d 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -42,3 +42,4 @@ from . import test_zone_picking_start from . import test_zone_picking_select_picking_type from . import test_zone_picking_select_line +from . import test_zone_picking_set_line_destination diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index 3c097a4ee8..5f550f7205 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -515,15 +515,36 @@ def test_scan_destination_pack_quantity_less(self): def test_scan_destination_pack_zero_check_activated(self): """Location will be emptied, have to go to zero check""" + # ensure that the location used for the test will contain only what we want + self.zero_check_location = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "ZeroCheck", + "location_id": self.stock_location.id, + "barcode": "ZEROCHECK", + } + ) + ) line = self.one_line_picking.move_line_ids + location, product, qty = ( + self.zero_check_location, + line.product_id, + line.product_uom_qty, + ) + self.one_line_picking.do_unreserve() + # ensure we have activated the zero check self.one_line_picking.picking_type_id.sudo().shopfloor_zero_check = True # Update the quantity in the location to be equal to the line's # so when scan_destination_pack sets the qty_done, the planned # qty should be zero and trigger a zero check - self._update_qty_in_location( - line.location_id, line.product_id, line.product_uom_qty - ) + self._update_qty_in_location(location, product, qty) + # Reserve goods (now the move line has the expected source location) + self.one_line_picking.move_lines.location_id = location + self.one_line_picking.action_assign() + line = self.one_line_picking.move_line_ids response = self.service.dispatch( "scan_destination_pack", params={ diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index ed857bb349..d0b18cba63 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -13,6 +13,7 @@ def setUpClassVars(cls, *args, **kwargs): @classmethod def setUpClassBaseData(cls, *args, **kwargs): super().setUpClassBaseData(*args, **kwargs) + cls.packing_location.sudo().active = True # We want to limit the tests to a dedicated location in Stock/ to not # be bothered with pickings brought by demo data cls.zone_location = ( @@ -48,7 +49,48 @@ def setUpClassBaseData(cls, *args, **kwargs): } ) ) - products = cls.product_a + cls.product_b + cls.product_c + cls.product_d + cls.zone_sublocation3 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location 3", + "location_id": cls.zone_location.id, + "barcode": "ZONE_SUBLOCATION_3", + } + ) + ) + cls.zone_sublocation4 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location 4", + "location_id": cls.zone_location.id, + "barcode": "ZONE_SUBLOCATION_4", + } + ) + ) + cls.product_e = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product E", + "type": "product", + "default_code": "E", + "barcode": "E", + "weight": 3, + } + ) + ) + products = ( + cls.product_a + + cls.product_b + + cls.product_c + + cls.product_d + + cls.product_e + ) for product in products: cls.env["stock.putaway.rule"].sudo().create( { @@ -62,13 +104,21 @@ def setUpClassBaseData(cls, *args, **kwargs): cls.picking2 = picking2 = cls._create_picking( lines=[(cls.product_b, 10), (cls.product_c, 10)] ) - cls.pickings = picking1 | picking2 + cls.picking3 = picking3 = cls._create_picking(lines=[(cls.product_d, 10)]) + cls.picking4 = picking4 = cls._create_picking(lines=[(cls.product_e, 10)]) + cls.pickings = picking1 | picking2 | picking3 | picking4 cls._fill_stock_for_moves( picking1.move_lines, in_package=True, location=cls.zone_sublocation1 ) cls._fill_stock_for_moves( picking2.move_lines, in_lot=True, location=cls.zone_sublocation2 ) + cls._fill_stock_for_moves(picking3.move_lines, location=cls.zone_sublocation3) + # Put product_e quantities in two different source locations to get + # two stock move lines (6 and 4 to satisfy 10 qties) + cls._update_qty_in_location(cls.zone_sublocation3, cls.product_e, 6) + cls._update_qty_in_location(cls.zone_sublocation4, cls.product_e, 4) + # cls._fill_stock_for_moves(picking4.move_lines, location=cls.zone_sublocation3) cls.pickings.action_assign() # Some records not related at all to the processed move lines cls.free_package = cls.env["stock.quant.package"].create( @@ -161,7 +211,14 @@ def assert_response_select_line( ) def _assert_response_set_line_destination( - self, state, response, zone_location, picking_type, move_line, message=None, + self, + state, + response, + zone_location, + picking_type, + move_line, + message=None, + confirmation_required=False, ): self.assert_response( response, @@ -170,12 +227,19 @@ def _assert_response_set_line_destination( "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), "move_line": self.data.move_line(move_line), + "confirmation_required": confirmation_required, }, message=message, ) def assert_response_set_line_destination( - self, response, zone_location, picking_type, move_line, message=None, + self, + response, + zone_location, + picking_type, + move_line, + message=None, + confirmation_required=False, ): self._assert_response_set_line_destination( "set_line_destination", @@ -184,4 +248,31 @@ def assert_response_set_line_destination( picking_type, move_line, message=message, + confirmation_required=confirmation_required, + ) + + def _assert_response_zero_check( + self, state, response, zone_location, picking_type, location, message=None, + ): + self.assert_response( + response, + next_state=state, + data={ + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "location": self.data.location(location), + }, + message=message, + ) + + def assert_response_zero_check( + self, response, zone_location, picking_type, location, message=None, + ): + self._assert_response_zero_check( + "zero_check", + response, + zone_location, + picking_type, + location, + message=message, ) diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py new file mode 100644 index 0000000000..88568e70fd --- /dev/null +++ b/shopfloor/tests/test_zone_picking_set_line_destination.py @@ -0,0 +1,519 @@ +from .test_zone_picking_base import ZonePickingCommonCase + + +class ZonePickingSetLineDestinationCase(ZonePickingCommonCase): + """Tests for endpoint used from set_line_destination + + * /set_destination + + """ + + def test_set_destination_wrong_parameters(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": 1234567890, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": move_line.product_uom_qty, + "confirmation": False, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": 1234567890, + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": move_line.product_uom_qty, + "confirmation": False, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": 1234567890, + "barcode": self.packing_location.barcode, + "quantity": move_line.product_uom_qty, + "confirmation": False, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + + def test_set_destination_location_confirm(self): + """Scanned barcode is the destination location but needs confirmation + as it is outside the current move line destination but is still + allowed by the picking type's default destination. + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + move_line.location_dest_id = self.shelf1 + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": move_line.product_uom_qty, + "confirmation": False, + }, + ) + # Check response + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.confirm_location_changed( + move_line.location_dest_id, self.packing_location + ), + confirmation_required=True, + ) + # Confirm the destination with a wrong destination (should not happen) + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.customer_location.barcode, + "quantity": move_line.product_uom_qty, + "confirmation": True, + }, + ) + # Check response + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.dest_location_not_allowed(), + ) + # Confirm the destination with the right destination this time + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": move_line.product_uom_qty, + "confirmation": True, + }, + ) + # Check response + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, zone_location, picking_type, move_lines, + ) + + def test_set_destination_location_no_other_move_line_full_qty(self): + """Scanned barcode is the destination location. + + The move line is the only one in the move, and we move the whole qty. + + Initial data: + + move qty 10 (assigned): + -> move_line qty 10 from location X + + Then the operator move the 10 qty, we get: + + move qty 10 (done): + -> move_line qty 10 from location X + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + moves_before = self.picking1.move_lines + self.assertEqual(len(moves_before), 1) + self.assertEqual(len(moves_before.move_line_ids), 1) + move_line = moves_before.move_line_ids + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": move_line.product_uom_qty, + "confirmation": False, + }, + ) + # Check picking data + moves_after = self.picking1.move_lines + self.assertEqual(moves_before, moves_after) + self.assertEqual(move_line.qty_done, 10) + # Check response + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, zone_location, picking_type, move_lines + ) + + def test_set_destination_location_no_other_move_line_partial_qty(self): + """Scanned barcode is the destination location. + + The move line is the only one in the move, and we move some of the qty. + + Initial data: + + move qty 10 (assigned): + -> move_line qty 10 from location X + + Then the operator move 6 qty on 10, we get: + + move qty 6 (done): + -> move_line qty 6 from location X + move qty 4 (assigned): + -> move_line qty 4 from location Y (remaining) + """ + zone_location = self.zone_location + picking_type = self.picking3.picking_type_id + moves_before = self.picking3.move_lines + self.assertEqual(len(moves_before), 1) + self.assertEqual(len(moves_before.move_line_ids), 1) + move_line = moves_before.move_line_ids + # we need a destination package if we want to scan a destination location + move_line.result_package_id = self.free_package + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": 6, + "confirmation": False, + }, + ) + # Check picking data (move has been split in two, 6 done and 4 remaining) + moves_after = self.picking3.move_lines + self.assertEqual(len(moves_after), 2) + self.assertEqual(moves_after[0].product_uom_qty, 6) + self.assertEqual(moves_after[0].state, "done") + self.assertEqual(moves_after[0].move_line_ids.product_uom_qty, 0) + self.assertEqual(moves_after[1].product_uom_qty, 4) + self.assertEqual(moves_after[1].state, "assigned") + self.assertEqual(moves_after[1].move_line_ids.product_uom_qty, 4) + self.assertEqual(move_line.qty_done, 6) + # Check response + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, zone_location, picking_type, move_lines + ) + + def test_set_destination_location_several_move_line_full_qty(self): + """Scanned barcode is the destination location. + + The move line has siblings in the move, and we move the whole qty: + the processed move line will then get its own move (split from original one) + + Initial data: + + move qty 10 (assigned): + -> move_line qty 6 from location X + -> move_line qty 4 from location Y + + Then the operator move 6 qty (from the first move line), we get: + + move qty 6 (done): + -> move_line qty 4 from location X + move qty 4 (assigned): + -> move_line qty 4 from location Y (untouched) + """ + zone_location = self.zone_location + picking_type = self.picking4.picking_type_id + moves_before = self.picking4.move_lines + self.assertEqual(len(moves_before), 1) + self.assertEqual(len(moves_before.move_line_ids), 2) + move_line = moves_before.move_line_ids[0] + # we need a destination package if we want to scan a destination location + move_line.result_package_id = self.free_package + other_move_line = moves_before.move_line_ids[1] + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": move_line.product_uom_qty, # 6 qty + "confirmation": False, + }, + ) + # Check picking data (move has been split in two, 6 done and 4 remaining) + moves_after = self.picking4.move_lines + self.assertEqual(len(moves_after), 2) + self.assertEqual(moves_after[0].product_uom_qty, 6) + self.assertEqual(moves_after[0].state, "done") + self.assertEqual(moves_after[0].move_line_ids.product_uom_qty, 0) + self.assertEqual(moves_after[1].product_uom_qty, 4) + self.assertEqual(moves_after[1].state, "assigned") + self.assertEqual(moves_after[1].move_line_ids.product_uom_qty, 4) + self.assertEqual(move_line.qty_done, 6) + self.assertNotEqual(move_line.move_id, other_move_line.move_id) + # Check response + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, zone_location, picking_type, move_lines + ) + + def test_set_destination_location_several_move_line_partial_qty(self): + """Scanned barcode is the destination location. + + The move line has siblings in the move, and we move some of the qty: + the processed move line will then get its own move (split from original one) + + Initial data: + + move qty 10 (assigned): + -> move_line qty 6 from location X + -> move_line qty 4 from location Y + + Then the operator move 4 qty on 6 (from the first move line), we get: + + move qty 4 (done): + -> move_line qty 4 from location X + move qty 2 (assigned): + -> move_line qty 2 from location X (remaining) + move qty 4 (assigned): + -> move_line qty 4 from location Y (untouched) + """ + zone_location = self.zone_location + picking_type = self.picking4.picking_type_id + moves_before = self.picking4.move_lines + self.assertEqual(len(moves_before), 1) # 10 qty + self.assertEqual(len(moves_before.move_line_ids), 2) # 6+4 qty + move_line = moves_before.move_line_ids[0] + # we need a destination package if we want to scan a destination location + move_line.result_package_id = self.free_package + other_move_line = moves_before.move_line_ids[1] + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": 4, # 4/6 qty + "confirmation": False, + }, + ) + # Check picking data (move has been split in three, 4 done, 2+4 remaining) + moves_after = self.picking4.move_lines + self.assertEqual(len(moves_after), 3) + self.assertEqual(moves_after[0].product_uom_qty, 4) + self.assertEqual(moves_after[0].state, "done") + self.assertEqual(moves_after[0].move_line_ids.product_uom_qty, 0) + self.assertEqual(moves_after[1].product_uom_qty, 4) + self.assertEqual(moves_after[1].state, "assigned") + self.assertEqual(moves_after[1].move_line_ids.product_uom_qty, 4) + self.assertEqual(moves_after[2].product_uom_qty, 2) + self.assertEqual(moves_after[2].state, "assigned") + self.assertEqual(moves_after[2].move_line_ids.product_uom_qty, 2) + self.assertEqual(move_line.qty_done, 4) + self.assertNotEqual(move_line.move_id, other_move_line.move_id) + # Check response + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, zone_location, picking_type, move_lines + ) + + def test_set_destination_location_zero_check(self): + """Scanned barcode is the destination location. + + The move line is the only one in the source location, as such the + 'zero_check' step is triggered. + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + picking_type.sudo().shopfloor_zero_check = True + self.assertEqual(len(self.picking1.move_line_ids), 1) + move_line = self.picking1.move_line_ids + location_is_empty = move_line.location_id.planned_qty_in_location_is_empty + self.assertFalse(location_is_empty()) + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": move_line.product_uom_qty, + "confirmation": False, + }, + ) + self.assertTrue(location_is_empty()) + # Check response + self.assert_response_zero_check( + response, zone_location, picking_type, move_line.location_id, + ) + + def test_set_destination_package_full_qty(self): + """Scanned barcode is the destination package. + + Initial data: + + move qty 10 (assigned): + -> move_line qty 10 from location X + + Then the operator move the 10 qty, we get: + + move qty 10 (done): + -> move_line qty 10 from location X with the scanned package + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + moves_before = self.picking1.move_lines + self.assertEqual(len(moves_before), 1) + self.assertEqual(len(moves_before.move_line_ids), 1) + move_line = moves_before.move_line_ids + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": move_line.product_uom_qty, + "confirmation": False, + }, + ) + # Check picking data + moves_after = self.picking1.move_lines + self.assertEqual(moves_before, moves_after) + self.assertRecordValues( + move_line, + [ + { + "result_package_id": self.free_package.id, + "product_uom_qty": 10, + "qty_done": 10, + "shopfloor_user_id": self.env.user.id, + }, + ], + ) + # Check response + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, zone_location, picking_type, move_lines + ) + + def test_set_destination_package_partial_qty(self): + """Scanned barcode is the destination package. + + Initial data: + + move qty 10 (assigned): + -> move_line qty 10 from location X + + Then the operator move the 6 on 10 qty, we get: + + move qty 6 (assigned): + -> move_line qty 6 from location X with the scanned package (buffer) + -> move_line qty 4 from location X (remaining) + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + moves_before = self.picking1.move_lines + self.assertEqual(len(moves_before), 1) + self.assertEqual(len(moves_before.move_line_ids), 1) + move_line = moves_before.move_line_ids + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": 6, + "confirmation": False, + }, + ) + # Check picking data + moves_after = self.picking1.move_lines + new_move_line = self.picking1.move_line_ids.filtered( + lambda line: line != move_line + ) + self.assertTrue(move_line != new_move_line) + self.assertEqual(moves_before, moves_after) + self.assertRecordValues( + move_line, + [ + { + "result_package_id": self.free_package.id, + "product_uom_qty": 6, + "qty_done": 6, + "shopfloor_user_id": self.env.user.id, + }, + ], + ) + self.assertRecordValues( + new_move_line, + [ + { + "result_package_id": new_move_line.package_id.id, # Unchanged + "product_uom_qty": 4, + "qty_done": 0, + "shopfloor_user_id": False, + }, + ], + ) + # Check response + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, zone_location, picking_type, move_lines + ) + + def test_set_destination_package_zero_check(self): + """Scanned barcode is the destination package. + + The move line is the only one in the source location, as such the + 'zero_check' step is triggered. + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + picking_type.sudo().shopfloor_zero_check = True + self.assertEqual(len(self.picking1.move_line_ids), 1) + move_line = self.picking1.move_line_ids + location_is_empty = move_line.location_id.planned_qty_in_location_is_empty + self.assertFalse(location_is_empty()) + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": move_line.product_uom_qty, + "confirmation": False, + }, + ) + self.assertTrue(location_is_empty()) + # Check response + self.assert_response_zero_check( + response, zone_location, picking_type, move_line.location_id, + ) From 1a6d2fe45d3d571f79beb65429b365fde921c6d0 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 27 Jul 2020 11:07:50 +0200 Subject: [PATCH 299/940] zone picking: implement /is_zero --- shopfloor/services/zone_picking.py | 24 +++- shopfloor/tests/__init__.py | 1 + .../tests/test_zone_picking_zero_check.py | 103 ++++++++++++++++++ 3 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 shopfloor/tests/test_zone_picking_zero_check.py diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 167947ec3b..35d61bbdc8 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -648,8 +648,28 @@ def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero): * select_line: whether the user confirms or not the location is empty, go back to the picking of lines """ - # TODO look in cluster_picking.py, same function exists - return self._response() + zone_location = self.env["stock.location"].browse(zone_location_id) + if not zone_location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + picking_type = self.env["stock.picking.type"].browse(picking_type_id) + if not picking_type.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + if not zero: + inventory = self.actions_for("inventory") + inventory.create_draft_check_empty( + move_line.location_id, + # FIXME as zero_check is done on the whole location, we should + # create an inventory on it without the product critera + # => the same applies on "Cluster Picking" scenario + # move_line.product_id, + move_line.product_id.browse(), # HACK send an empty recordset + ref=picking_type.name, + ) + move_lines = self._find_location_move_lines(zone_location, picking_type) + return self._response_for_select_line(zone_location, picking_type, move_lines) def stock_issue(self, zone_location_id, picking_type_id, move_line_id): """Declare a stock issue for a line diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 82123a252d..62f5b637e5 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -43,3 +43,4 @@ from . import test_zone_picking_select_picking_type from . import test_zone_picking_select_line from . import test_zone_picking_set_line_destination +from . import test_zone_picking_zero_check diff --git a/shopfloor/tests/test_zone_picking_zero_check.py b/shopfloor/tests/test_zone_picking_zero_check.py new file mode 100644 index 0000000000..dba09b2c70 --- /dev/null +++ b/shopfloor/tests/test_zone_picking_zero_check.py @@ -0,0 +1,103 @@ +from .test_zone_picking_base import ZonePickingCommonCase + + +class ZonePickingZeroCheckCase(ZonePickingCommonCase): + """Tests for endpoint used from zero_check + + * /is_zero + + """ + + def test_is_zero_wrong_parameters(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + response = self.service.dispatch( + "is_zero", + params={ + "zone_location_id": 1234567890, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "zero": True, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + response = self.service.dispatch( + "is_zero", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": 1234567890, + "move_line_id": move_line.id, + "zero": True, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + response = self.service.dispatch( + "is_zero", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": 1234567890, + "zero": True, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + + def test_is_zero_is_empty(self): + """call /is_zero confirming it's empty""" + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + response = self.service.dispatch( + "is_zero", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "zero": True, + }, + ) + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + self.assert_response_select_line( + response, zone_location, picking_type, move_lines, + ) + + def test_is_zero_is_not_empty(self): + """call /is_zero not confirming it's empty""" + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + response = self.service.dispatch( + "is_zero", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "zero": False, + }, + ) + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + self.assert_response_select_line( + response, zone_location, picking_type, move_lines, + ) + inventory = self.env["stock.inventory"].search( + [ + ("location_ids", "in", move_line.location_id.id), + # FIXME check 'is_zero' implementation + # ("product_ids", "in", move_line.product_id.id), + ("state", "=", "draft"), + ] + ) + self.assertTrue(inventory) + self.assertEqual( + inventory.name, + "Zero check issue on location {} ({})".format( + move_line.location_id.name, picking_type.name, + ), + ) From bf7b5131cb684f32f019b29909b66a24b38db5bc Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 27 Jul 2020 13:17:22 +0200 Subject: [PATCH 300/940] zone picking: implement /stock_issue --- shopfloor/services/zone_picking.py | 66 +++++- shopfloor/tests/__init__.py | 1 + .../tests/test_zone_picking_stock_issue.py | 193 ++++++++++++++++++ 3 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 shopfloor/tests/test_zone_picking_stock_issue.py diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 35d61bbdc8..227d50c2e5 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -671,6 +671,26 @@ def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero): move_lines = self._find_location_move_lines(zone_location, picking_type) return self._response_for_select_line(zone_location, picking_type, move_lines) + def _domain_stock_issue_unlink_lines(self, move_line): + # Since we have not enough stock, delete the move lines, which will + # in turn unreserve the moves. The moves lines we delete are those + # in the same location, and not yet started. + # The goal is to prevent the same operator to declare twice the same + # stock issue for the same product/lot/package. + move = move_line.move_id + lot = move_line.lot_id + package = move_line.package_id + location = move_line.location_id + domain = [ + ("location_id", "=", location.id), + ("product_id", "=", move.product_id.id), + ("package_id", "=", package.id), + ("lot_id", "=", lot.id), + ("state", "not in", ("cancel", "done")), + ("qty_done", "=", 0), + ] + return domain + def stock_issue(self, zone_location_id, picking_type_id, move_line_id): """Declare a stock issue for a line @@ -699,8 +719,50 @@ def stock_issue(self, zone_location_id, picking_type_id, move_line_id): * set_line_destination: something could be reserved instead of the original move line """ - # TODO look in cluster_picking.py, similar function exists - return self._response() + zone_location = self.env["stock.location"].browse(zone_location_id) + if not zone_location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + picking_type = self.env["stock.picking.type"].browse(picking_type_id) + if not picking_type.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + inventory = self.actions_for("inventory") + # create a draft inventory for a user to check + inventory.create_control_stock( + move_line.location_id, + move_line.product_id, + move_line.package_id, + move_line.lot_id, + ) + move = move_line.move_id + lot = move_line.lot_id + package = move_line.package_id + location = move_line.location_id + + # unreserve every lines for the same product/lot in the same location and + # not done yet, so the same user doesn't have to declare 2 times the + # stock issue for the same thing! + domain = self._domain_stock_issue_unlink_lines(move_line) + unreserve_move_lines = move_line | self.env["stock.move.line"].search(domain) + unreserve_moves = unreserve_move_lines.mapped("move_id").sorted() + unreserve_move_lines.unlink() + + # Then, create an inventory with just enough qty so the other assigned + # move lines for the same product in other batches and the other move lines + # already picked stay assigned. + inventory.create_stock_issue(move, location, package, lot) + + # try to reassign the moves in case we have stock in another location + unreserve_moves._action_assign() + + if move.move_line_ids: + return self._response_for_set_line_destination( + zone_location, picking_type, move.move_line_ids[0] + ) + move_lines = self._find_location_move_lines(zone_location, picking_type) + return self._response_for_select_line(zone_location, picking_type, move_lines) def change_pack_lot(self, zone_location_id, picking_type_id, move_line_id, barcode): """Change the source package or the lot of a move line diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 62f5b637e5..373328a6fc 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -44,3 +44,4 @@ from . import test_zone_picking_select_line from . import test_zone_picking_set_line_destination from . import test_zone_picking_zero_check +from . import test_zone_picking_stock_issue diff --git a/shopfloor/tests/test_zone_picking_stock_issue.py b/shopfloor/tests/test_zone_picking_stock_issue.py new file mode 100644 index 0000000000..3767f02905 --- /dev/null +++ b/shopfloor/tests/test_zone_picking_stock_issue.py @@ -0,0 +1,193 @@ +from .test_zone_picking_base import ZonePickingCommonCase + + +class ZonePickingStockIssueCase(ZonePickingCommonCase): + """Tests for endpoint used from stock_issue + + * /stock_issue + + """ + + def test_stock_issue_wrong_parameters(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + response = self.service.dispatch( + "stock_issue", + params={ + "zone_location_id": 1234567890, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + response = self.service.dispatch( + "stock_issue", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": 1234567890, + "move_line_id": move_line.id, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + response = self.service.dispatch( + "stock_issue", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": 1234567890, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + + def test_stock_issue_no_more_reservation(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + move = move_line.move_id + response = self.service.dispatch( + "stock_issue", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + }, + ) + self.assertFalse(move_line.exists()) + self.assertFalse(move.move_line_ids) + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + self.assert_response_select_line( + response, zone_location, picking_type, move_lines, + ) + + def test_stock_issue1(self): + """Once the stock issue is done, the move can't be reserved anymore.""" + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + location = move_line.location_id + move = move_line.move_id + response = self.service.dispatch( + "stock_issue", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + }, + ) + self.assertFalse(move_line.exists()) + self.assertFalse(move.move_line_ids) + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + self.assert_response_select_line( + response, zone_location, picking_type, move_lines, + ) + # Check that the inventory exists + inventory = self.env["stock.inventory"].search( + [ + ( + "name", + "ilike", + "{} stock correction in location {}".format( + move.picking_id.name, location.name + ), + ), + ("state", "=", "done"), + ("line_ids.location_id", "in", location.ids), + ("line_ids.product_id", "in", move.product_id.ids), + ] + ) + self.assertTrue(inventory) + self.assertEqual(inventory.line_ids.product_id, move.product_id) + self.assertEqual(inventory.line_ids.product_qty, 0) + + def test_stock_issue2(self): + """Once the stock issue is done, the move has been reserved again.""" + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + location = move_line.location_id + move = move_line.move_id + # Increase the quantity in the current location + self._update_qty_in_location(location, move.product_id, 100) + response = self.service.dispatch( + "stock_issue", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + }, + ) + self.assertFalse(move_line.exists()) + self.assertTrue(move.move_line_ids) + self.assertEqual(move.move_line_ids.location_id, location) + self.assert_response_set_line_destination( + response, zone_location, picking_type, move.move_line_ids, + ) + # Check the inventory + inventory = self.env["stock.inventory"].search( + [ + ( + "name", + "ilike", + "{} stock correction in location {}".format( + move.picking_id.name, location.name + ), + ), + ("state", "=", "done"), + ("line_ids.location_id", "in", location.ids), + ("line_ids.product_id", "in", move.product_id.ids), + ] + ) + self.assertTrue(inventory) + self.assertEqual(inventory.line_ids.product_id, move.product_id) + self.assertEqual(inventory.line_ids.product_qty, 0) + + def test_stock_issue3(self): + """Once the stock issue is done, the move has been reserved again + but from another location. + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + location = move_line.location_id + move = move_line.move_id + # Put some quantity in another location to get a new reservations from there + self._update_qty_in_location(self.zone_sublocation2, move.product_id, 10) + response = self.service.dispatch( + "stock_issue", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + }, + ) + self.assertFalse(move_line.exists()) + self.assertTrue(move.move_line_ids) + self.assertEqual(move.move_line_ids.location_id, self.zone_sublocation2) + self.assert_response_set_line_destination( + response, zone_location, picking_type, move.move_line_ids, + ) + # Check the inventory + inventory = self.env["stock.inventory"].search( + [ + ( + "name", + "ilike", + "{} stock correction in location {}".format( + move.picking_id.name, location.name + ), + ), + ("state", "=", "done"), + ("line_ids.location_id", "in", location.ids), + ("line_ids.product_id", "in", move.product_id.ids), + ] + ) + self.assertTrue(inventory) + self.assertEqual(inventory.line_ids.product_id, move.product_id) + self.assertEqual(inventory.line_ids.product_qty, 0) From 0561d0f013c7c57c07c448f328414ef65c107573 Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 28 Jul 2020 17:49:01 +0200 Subject: [PATCH 301/940] zone picking: implement /change_pack_lot --- shopfloor/actions/message.py | 40 ++- shopfloor/services/change_pack_lot_mixin.py | 165 +++++++++++ shopfloor/services/cluster_picking.py | 185 +------------ shopfloor/services/zone_picking.py | 54 +++- shopfloor/tests/__init__.py | 1 + .../test_cluster_picking_change_pack_lot.py | 79 ++---- shopfloor/tests/test_zone_picking_base.py | 26 ++ .../test_zone_picking_change_pack_lot.py | 258 ++++++++++++++++++ 8 files changed, 572 insertions(+), 236 deletions(-) create mode 100644 shopfloor/services/change_pack_lot_mixin.py create mode 100644 shopfloor/tests/test_zone_picking_change_pack_lot.py diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 6a3be13450..0f72b69ecd 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -187,10 +187,16 @@ def several_packs_in_location(self, location): ), } + def no_package_or_lot_for_barcode(self, barcode): + return { + "message_type": "error", + "body": _("No package or lot found for barcode {}.").format(barcode), + } + def no_lot_for_barcode(self, barcode): return { "message_type": "error", - "body": _("No lot found for {}".format(barcode)), + "body": _("No lot found for {}").format(barcode), } def lot_on_wrong_product(self, barcode): @@ -202,7 +208,7 @@ def lot_on_wrong_product(self, barcode): def several_lots_in_location(self, location): return { "message_type": "warning", - "body": _("Several lots found in %s, please scan a lot." % location.name), + "body": _("Several lots found in %s, please scan a lot.") % location.name, } def several_products_in_location(self, location): @@ -216,7 +222,7 @@ def several_products_in_location(self, location): def no_pending_operation_for_pack(self, pack): return { "message_type": "error", - "body": _("No pending operation for package %s." % pack.name), + "body": _("No pending operation for package %s.") % pack.name, } def unrecoverable_error(self): @@ -389,3 +395,31 @@ def unable_to_pick_more(self, quantity): "message_type": "error", "body": _("You must not pick more than {} units.").format(quantity), } + + def lot_replaced_by_lot(self, old_lot, new_lot): + return { + "message_type": "success", + "body": _("Lot {} replaced by lot {}.").format(old_lot.name, new_lot.name), + } + + def package_replaced_by_package(self, old_package, new_package): + return { + "message_type": "success", + "body": _("Package {} replaced by package {}.").format( + old_package.name, new_package.name + ), + } + + def package_already_picked_by(self, package, picking): + return { + "message_type": "error", + "body": _( + _("Package {} cannot be picked, already moved by transfer {}.") + ).format(package.name, picking.name), + } + + def lot_is_not_a_package(self, lot): + return { + "message_type": "error", + "body": _("Lot {} is not a package.").format(lot.name), + } diff --git a/shopfloor/services/change_pack_lot_mixin.py b/shopfloor/services/change_pack_lot_mixin.py new file mode 100644 index 0000000000..bf6cb12f75 --- /dev/null +++ b/shopfloor/services/change_pack_lot_mixin.py @@ -0,0 +1,165 @@ +from odoo import _ + + +class ChangePackLotMixin: + def _change_lot(self, move_line, lot, response_ok_func, response_error_func): + """Change the lot on the move line. + + :param response_ok_func: callable used tu return ok response + :param response_error_func: callable used tu return error response + """ + # If the lot is part of a package, what we really want + # is not to change the lot, but change the package (which will + # in turn change the lot altogether), but we have to pay attention + # to some things: + # * cannot replace a package by a lot without package (qty may be + # different, ...) + # * if we have several packages for the same lot, we can't know which + # one the operator is moving, ask to scan a package + lot_package_quants = self.env["stock.quant"].search( + [ + ("lot_id", "=", lot.id), + ("location_id", "=", move_line.location_id.id), + ("package_id", "!=", False), + ("quantity", ">", 0), + ] + ) + if move_line.package_id and not lot_package_quants: + return response_error_func( + move_line, message=self.msg_store.lot_is_not_a_package(lot), + ) + if len(lot_package_quants) == 1: + package = lot_package_quants.package_id + return self._change_pack_lot_change_package( + move_line, package, response_ok_func, response_error_func + ) + elif len(lot_package_quants) > 1: + return response_error_func( + move_line, + message=self.msg_store.several_packs_in_location(move_line.location_id), + ) + return self._change_pack_lot_change_lot( + move_line, lot, response_ok_func, response_error_func + ) + + def _change_pack_lot_change_lot( + self, move_line, lot, response_ok_func, response_error_func + ): + inventory = self.actions_for("inventory") + product = move_line.product_id + if lot.product_id != product: + return response_error_func( + move_line, message=self.msg_store.lot_on_wrong_product(lot.name) + ) + previous_lot = move_line.lot_id + # Changing the lot on the move line updates the reservation on the quants + move_line.lot_id = lot + + message = self.msg_store.lot_replaced_by_lot(previous_lot, lot) + # check that we are supposed to have enough of this lot in the source location + quant = lot.quant_ids.filtered(lambda q: q.location_id == move_line.location_id) + if not quant: + # not supposed to have this lot here... (if there is a quant + # but not enough quantity we don't care here: user will report + # a stock issue) + inventory.create_control_stock( + move_line.location_id, + move_line.product_id, + move_line.package_id, + move_line.lot_id, + _("Pick: stock issue on lot: {} found in {}").format( + lot.name, move_line.location_id.name + ), + ) + message["body"] += _(" A draft inventory has been created for control.") + + return response_ok_func(move_line, message=message) + + def _package_identical_move_lines_qty(self, package, move_lines): + grouped_quants = {} + for quant in package.quant_ids: + grouped_quants.setdefault(quant.product_id, 0) + grouped_quants[quant.product_id] += quant.quantity + + grouped_lines = {} + for move_line in move_lines: + grouped_lines.setdefault(move_line.product_id, 0) + grouped_lines[move_line.product_id] += move_line.product_uom_qty + + return grouped_quants == grouped_lines + + def _change_pack_lot_change_package( + self, move_line, package, response_ok_func, response_error_func + ): + inventory = self.actions_for("inventory") + + package_level = move_line.package_level_id + # several move lines can be moved by the package level, we'll have + # to update all of them + move_lines = package_level.move_line_ids + + # prevent to replace a package by a package with a different content + identical_content = self._package_identical_move_lines_qty(package, move_lines) + if not identical_content: + return response_error_func( + move_line, message=self.msg_store.package_different_content(package) + ) + + previous_package = move_line.package_id + + if package.location_id != move_line.location_id: + # the package has been scanned in the current location so we know its + # a mistake in the data... fix the quant to move the package here + inventory.move_package_quants_to_location(package, move_line.location_id) + + # search a package level which would already move the scanned package + reserved_level = ( + self.env["stock.package_level"].search([("package_id", "=", package.id)]) + # not possible to search on state + .filtered(lambda level: level.state in ("new", "assigned")) + ) + if reserved_level: + reserved_level.ensure_one() + if reserved_level.is_done: + # Not really supposed to happen: if someone sets is_done, the package + # should no longer be here! But we have to check this and inform the + # user in any case. + return response_error_func( + move_line, + message=self.msg_store.package_already_picked_by( + package, reserved_level.picking_id + ), + ) + + # Switch the package with the level which was moving it, as we know + # that: + # * only one package level at a time is supposed to move a package + # * the content of the other package is the same (as we checked the + # content is the same as the current move lines) + # * if we left the reserved level with the scanned package, we would + # have 2 levels for the same package and odoo would unreserve the + # move lines as soon as we confirm the current moves + # Considering this, we should be safe to interchange the packages + if reserved_level: + # Ignore updates on quant reservation, which would prevent to switch + # 2 packages between 2 assigned package levels: when writing the + # package of the second level to the first level, it would unreserve + # it because the second level is still using the package. + # But here, we know they both available before and must be available after! + reserved_level.with_context(bypass_reservation_update=True).replace_package( + previous_package + ) + package_level.with_context(bypass_reservation_update=True).replace_package( + package + ) + else: + # when we are not switching packages, we expect the quant + # reservations to be aligned + package_level.replace_package(package) + + return response_ok_func( + move_line, + message=self.msg_store.package_replaced_by_package( + previous_package, package + ), + ) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index f7577f7d95..0871afb452 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -5,10 +5,11 @@ from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component +from .change_pack_lot_mixin import ChangePackLotMixin from .service import to_float -class ClusterPicking(Component): +class ClusterPicking(Component, ChangePackLotMixin): """ Methods for the Cluster Picking Process @@ -881,186 +882,24 @@ def change_pack_lot(self, picking_batch_id, move_line_id, barcode): batch, message=self.msg_store.operation_not_found() ) search = self.actions_for("search") + response_ok_func = self._response_for_scan_destination + response_error_func = self._response_for_change_pack_lot lot = search.lot_from_scan(barcode) if lot: - # If the lot is part of a package, what we really want - # is not to change the lot, but change the package (which will - # in turn change the lot altogether), but we have to pay attention - # to some things: - # * cannot replace a package by a lot without package (qty may be - # different, ...) - # * if we have several packages for the same lot, we can't know which - # one the operator is moving, ask to scan a package - lot_package_quants = self.env["stock.quant"].search( - [ - ("lot_id", "=", lot.id), - ("location_id", "=", move_line.location_id.id), - ("package_id", "!=", False), - ("quantity", ">", 0), - ] + response = self._change_lot( + move_line, lot, response_ok_func, response_error_func ) - if move_line.package_id and not lot_package_quants: - return self._response_for_change_pack_lot( - move_line, - message={ - "message_type": "error", - "body": _("Lot {} is not a package.").format(lot.name), - }, - ) - - if len(lot_package_quants) == 1: - package = lot_package_quants.package_id - return self._change_pack_lot_change_package(move_line, package) - elif len(lot_package_quants) > 1: - return self._response_for_change_pack_lot( - move_line, - message=self.msg_store.several_packs_in_location( - move_line.location_id - ), - ) - - return self._change_pack_lot_change_lot(move_line, lot) + if response: + return response package = search.package_from_scan(barcode) if package: - return self._change_pack_lot_change_package(move_line, package) - - return self._response_for_change_pack_lot( - move_line, - message={ - "message_type": "warning", - "body": _("No package or lot found for barcode {}").format(barcode), - }, - ) - - def _change_pack_lot_change_lot(self, move_line, lot): - inventory = self.actions_for("inventory") - product = move_line.product_id - if lot.product_id != product: - return self._response_for_change_pack_lot( - move_line, message=self.msg_store.lot_on_wrong_product(lot.name) + return self._change_pack_lot_change_package( + move_line, package, response_ok_func, response_error_func ) - previous_lot = move_line.lot_id - # Changing the lot on the move line updates the reservation - # on the quants - move_line.lot_id = lot - - success_body = _("Lot {} replaced by lot {}.").format( - previous_lot.name, lot.name - ) - # check that we are supposed to have enough of this lot in the - # source location - quant = lot.quant_ids.filtered(lambda q: q.location_id == move_line.location_id) - if not quant: - # not supposed to have this lot here... (if there is a quant - # but not enough quantity we don't care here: user will report - # a stock issue) - inventory.create_control_stock( - move_line.location_id, - move_line.product_id, - move_line.package_id, - move_line.lot_id, - _("Pick: stock issue on lot: {} found in {}").format( - lot.name, move_line.location_id.name - ), - ) - success_body += _(" A draft inventory has been created for control.") - - return self._response_for_scan_destination( - move_line, message={"message_type": "success", "body": success_body} - ) - - def _package_identical_move_lines_qty(self, package, move_lines): - grouped_quants = {} - for quant in package.quant_ids: - grouped_quants.setdefault(quant.product_id, 0) - grouped_quants[quant.product_id] += quant.quantity - grouped_lines = {} - for move_line in move_lines: - grouped_lines.setdefault(move_line.product_id, 0) - grouped_lines[move_line.product_id] += move_line.product_uom_qty - - return grouped_quants == grouped_lines - - def _change_pack_lot_change_package(self, move_line, package): - inventory = self.actions_for("inventory") - - package_level = move_line.package_level_id - # several move lines can be moved by the package level, we'll have - # to update all of them - move_lines = package_level.move_line_ids - - # prevent to replace a package by a package with a different content - identical_content = self._package_identical_move_lines_qty(package, move_lines) - if not identical_content: - return self._response_for_change_pack_lot( - move_line, message=self.msg_store.package_different_content(package) - ) - - previous_package = move_line.package_id - - if package.location_id != move_line.location_id: - # the package has been scanned in the current location so we know its - # a mistake in the data... fix the quant to move the package here - inventory.move_package_quants_to_location(package, move_line.location_id) - - # search a package level which would already move the scanned package - reserved_level = ( - self.env["stock.package_level"].search([("package_id", "=", package.id)]) - # not possible to search on state - .filtered(lambda level: level.state in ("new", "assigned")) - ) - if reserved_level: - reserved_level.ensure_one() - if reserved_level.is_done: - # Not really supposed to happen: if someone sets is_done, the package - # should no longer be here! But we have to check this and inform the - # user in any case. - return self._response_for_change_pack_lot( - move_line, - message={ - "message_type": "error", - "body": _( - "Package {} cannot be picked, already moved by transfer {}" - ).format(package.name, reserved_level.picking_id.name), - }, - ) - - # Switch the package with the level which was moving it, as we know - # that: - # * only one package level at a time is supposed to move a package - # * the content of the other package is the same (as we checked the - # content is the same as the current move lines) - # * if we left the reserved level with the scanned package, we would - # have 2 levels for the same package and odoo would unreserve the - # move lines as soon as we confirm the current moves - # Considering this, we should be safe to interchange the packages - if reserved_level: - # Ignore updates on quant reservation, which would prevent to switch - # 2 packages between 2 assigned package levels: when writing the - # package of the second level to the first level, it would unreserve - # it because the second level is still using the package. - # But here, we know they both available before and must be available after! - reserved_level.with_context(bypass_reservation_update=True).replace_package( - previous_package - ) - package_level.with_context(bypass_reservation_update=True).replace_package( - package - ) - else: - # when we are not switching packages, we expect the quant - # reservations to be aligned - package_level.replace_package(package) - - return self._response_for_scan_destination( - move_line, - message={ - "message_type": "success", - "body": _("Package {} replaced by package {}").format( - previous_package.name, package.name - ), - }, + return self._response_for_change_pack_lot( + move_line, message=self.msg_store.no_package_or_lot_for_barcode(barcode), ) def set_destination_all(self, picking_batch_id, barcode, confirmation=False): diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 227d50c2e5..1c51b2f3cb 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -1,13 +1,16 @@ +import functools + from odoo.fields import first from odoo.tools.float_utils import float_compare from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component +from .change_pack_lot_mixin import ChangePackLotMixin from .service import to_float -class ZonePicking(Component): +class ZonePicking(Component, ChangePackLotMixin): """ Methods for the Zone Picking Process @@ -780,8 +783,53 @@ def change_pack_lot(self, zone_location_id, picking_type_id, move_line_id, barco moved to destination now * select_line: if the move line does not exist anymore """ - # TODO look in cluster_picking.py, similar function exists - return self._response() + zone_location = self.env["stock.location"].browse(zone_location_id) + if not zone_location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + picking_type = self.env["stock.picking.type"].browse(picking_type_id) + if not picking_type.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + search = self.actions_for("search") + # pre-configured callable used to generate the response as 'ChangePackLotMixin' + # is not aware of the needed response type and related parameters for + # zone picking scenario + response_ok_func = functools.partial( + self._response_for_set_line_destination, zone_location, picking_type + ) + response_error_func = functools.partial( + self._response_for_change_pack_lot, zone_location, picking_type + ) + response = None + # handle lot + lot = search.lot_from_scan(barcode) + if lot: + response = self._change_lot( + move_line, lot, response_ok_func, response_error_func + ) + # handle package + package = search.package_from_scan(barcode) + if package: + response = self._change_pack_lot_change_package( + move_line, package, response_ok_func, response_error_func + ) + # if the response is not an error, we check the move_line status + # to adapt the response ('set_line_destination' or 'select_line') + # TODO not sure to understand how 'move_line' could not exist here? + if response and response["message"]["message_type"] == "success": + # TODO adapt the response based on the move_line.exists() + if move_line.exists(): + return response + return response + + return self._response_for_change_pack_lot( + zone_location, + picking_type, + move_line, + message=self.msg_store.no_package_or_lot_for_barcode(barcode), + ) def prepare_unload(self, zone_location_id, picking_type_id): """Initiate the unloading of the buffer diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 373328a6fc..2dea4bfe54 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -45,3 +45,4 @@ from . import test_zone_picking_set_line_destination from . import test_zone_picking_zero_check from . import test_zone_picking_stock_issue +from . import test_zone_picking_change_pack_lot diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py index f68772726b..1f924f10b3 100644 --- a/shopfloor/tests/test_cluster_picking_change_pack_lot.py +++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py @@ -129,12 +129,9 @@ def test_change_pack_lot_change_pack_ok(self): line, new_package.name, success=True, - message={ - "message_type": "success", - "body": "Package {} replaced by package {}".format( - initial_package.name, new_package.name - ), - }, + message=self.service.msg_store.package_replaced_by_package( + initial_package, new_package + ), ) self.assertRecordValues( @@ -169,12 +166,9 @@ def test_change_pack_lot_change_pack_different_location(self): line, new_package.name, success=True, - message={ - "message_type": "success", - "body": "Package {} replaced by package {}".format( - initial_package.name, new_package.name - ), - }, + message=self.service.msg_store.package_replaced_by_package( + initial_package, new_package + ), ) self.assertRecordValues( @@ -207,12 +201,9 @@ def test_change_pack_lot_change_lot_in_package_ok(self): line, new_lot.name, success=True, - message={ - "message_type": "success", - "body": "Package {} replaced by package {}".format( - initial_package.name, new_package.name - ), - }, + message=self.service.msg_store.package_replaced_by_package( + initial_package, new_package + ), ) self.assertRecordValues( @@ -252,11 +243,7 @@ def test_change_pack_lot_change_lot_in_several_packages_error(self): line, new_lot.name, success=False, - message={ - "message_type": "warning", - "body": "Several packages found in {}," - " please scan a package.".format(self.shelf1.name), - }, + message=self.service.msg_store.several_packs_in_location(self.shelf1), ) def test_change_pack_lot_change_lot_from_package_error(self): @@ -277,10 +264,7 @@ def test_change_pack_lot_change_lot_from_package_error(self): line, new_lot.name, success=False, - message={ - "message_type": "error", - "body": "Lot {} is not a package.".format(new_lot.name), - }, + message=self.service.msg_store.lot_is_not_a_package(new_lot), ) def test_change_pack_lot_change_lot_ok(self): @@ -296,12 +280,7 @@ def test_change_pack_lot_change_lot_ok(self): line, new_lot.name, success=True, - message={ - "message_type": "success", - "body": "Lot {} replaced by lot {}.".format( - initial_lot.name, new_lot.name - ), - }, + message=self.service.msg_store.lot_replaced_by_lot(initial_lot, new_lot), ) self.assertRecordValues(line, [{"lot_id": new_lot.id}]) @@ -318,15 +297,10 @@ def test_change_pack_lot_change_lot_different_location_ok(self): new_lot = self._create_lot(self.product_a) # ensure we have our new package in a different location self._update_qty_in_location(self.shelf2, line.product_id, 10, lot=new_lot) + message = self.service.msg_store.lot_replaced_by_lot(initial_lot, new_lot) + message["body"] += " A draft inventory has been created for control." self._test_change_pack_lot( - line, - new_lot.name, - success=True, - message={ - "message_type": "success", - "body": "Lot {} replaced by lot {}. A draft inventory has" - " been created for control.".format(initial_lot.name, new_lot.name), - }, + line, new_lot.name, success=True, message=message, ) self.assertRecordValues(line, [{"lot_id": new_lot.id}]) @@ -394,10 +368,7 @@ def test_change_pack_lot_change_pack_different_content_error(self): lines[0], new_package.name, success=False, - message={ - "message_type": "error", - "body": "Package {} has a different content.".format(new_package.name), - }, + message=self.service.msg_store.package_different_content(new_package), ) def test_change_pack_lot_change_pack_multi_content_with_lot(self): @@ -438,12 +409,9 @@ def test_change_pack_lot_change_pack_multi_content_with_lot(self): lines[0], new_package.name, success=True, - message={ - "message_type": "success", - "body": "Package {} replaced by package {}".format( - initial_package.name, new_package.name - ), - }, + message=self.service.msg_store.package_replaced_by_package( + initial_package, new_package + ), ) self.assertRecordValues( @@ -493,12 +461,9 @@ def test_change_pack_lot_change_pack_steal_from_other_move_line(self): line, package2.name, success=True, - message={ - "message_type": "success", - "body": "Package {} replaced by package {}".format( - package1.name, package2.name - ), - }, + message=self.service.msg_store.package_replaced_by_package( + package1, package2 + ), ) self.assertRecordValues( diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index d0b18cba63..79d22c8ea7 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -276,3 +276,29 @@ def assert_response_zero_check( location, message=message, ) + + def _assert_response_change_pack_lot( + self, state, response, zone_location, picking_type, move_line, message=None, + ): + self.assert_response( + response, + next_state=state, + data={ + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "move_line": self.data.move_line(move_line), + }, + message=message, + ) + + def assert_response_change_pack_lot( + self, response, zone_location, picking_type, move_line, message=None, + ): + self._assert_response_change_pack_lot( + "change_pack_lot", + response, + zone_location, + picking_type, + move_line, + message=message, + ) diff --git a/shopfloor/tests/test_zone_picking_change_pack_lot.py b/shopfloor/tests/test_zone_picking_change_pack_lot.py new file mode 100644 index 0000000000..06f6047342 --- /dev/null +++ b/shopfloor/tests/test_zone_picking_change_pack_lot.py @@ -0,0 +1,258 @@ +from .test_zone_picking_base import ZonePickingCommonCase + + +class ZonePickingChangePackLotCase(ZonePickingCommonCase): + """Tests for endpoint used from change_pack_lot + + * /change_pack_lot + + """ + + def test_change_pack_lot_wrong_parameters(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + response = self.service.dispatch( + "change_pack_lot", + params={ + "zone_location_id": 1234567890, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.free_lot.name, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + response = self.service.dispatch( + "change_pack_lot", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": 1234567890, + "move_line_id": move_line.id, + "barcode": self.free_lot.name, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + response = self.service.dispatch( + "change_pack_lot", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": 1234567890, + "barcode": self.free_lot.name, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + + def test_change_pack_lot_no_package_or_lot_for_barcode(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + barcode = "UNKNOWN" + response = self.service.dispatch( + "change_pack_lot", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": barcode, + }, + ) + self.assert_response_change_pack_lot( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.no_package_or_lot_for_barcode(barcode), + ) + + def test_change_pack_lot_change_pack_ok(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + previous_package = move_line.package_id + # ensure we have our new package in the same location + self._update_qty_in_location( + move_line.location_id, + move_line.product_id, + move_line.product_uom_qty, + package=self.free_package, + ) + # change package + response = self.service.dispatch( + "change_pack_lot", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.free_package.name, + }, + ) + # check data + self.assertRecordValues( + move_line, + [ + { + "package_id": self.free_package.id, + "result_package_id": self.free_package.id, + } + ], + ) + self.assertRecordValues( + move_line.package_level_id, [{"package_id": self.free_package.id}] + ) + # check that reservations have been updated + previous_quant = self.env["stock.quant"].search( + [ + ("location_id", "=", move_line.location_id.id), + ("product_id", "=", move_line.product_id.id), + ("package_id", "=", previous_package.id), + ] + ) + self.assertEqual(previous_quant.quantity, 10) + self.assertEqual(previous_quant.reserved_quantity, 0) + new_quant = self.env["stock.quant"].search( + [ + ("location_id", "=", move_line.location_id.id), + ("product_id", "=", move_line.product_id.id), + ("package_id", "=", self.free_package.id), + ] + ) + self.assertEqual(new_quant.quantity, 10) + self.assertEqual(new_quant.reserved_quantity, 10) + # check response + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.package_replaced_by_package( + previous_package, self.free_package + ), + ) + + def test_change_pack_lot_change_lot_ok(self): + zone_location = self.zone_location + picking_type = self.picking2.picking_type_id + move_line = self.picking2.move_line_ids[0] + previous_lot = move_line.lot_id + self.free_lot.product_id = move_line.product_id + # ensure we have our new lot in the same location + self._update_qty_in_location( + move_line.location_id, + move_line.product_id, + move_line.product_uom_qty, + lot=self.free_lot, + ) + # change lot + response = self.service.dispatch( + "change_pack_lot", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.free_lot.name, + }, + ) + # check data + self.assertRecordValues(move_line, [{"lot_id": self.free_lot.id}]) + # check that reservations have been updated + previous_quant = self.env["stock.quant"].search( + [ + ("location_id", "=", move_line.location_id.id), + ("product_id", "=", move_line.product_id.id), + ("lot_id", "=", previous_lot.id), + ] + ) + self.assertEqual(previous_quant.quantity, 10) + self.assertEqual(previous_quant.reserved_quantity, 0) + new_quant = self.env["stock.quant"].search( + [ + ("location_id", "=", move_line.location_id.id), + ("product_id", "=", move_line.product_id.id), + ("lot_id", "=", self.free_lot.id), + ] + ) + self.assertEqual(new_quant.quantity, 10) + self.assertEqual(new_quant.reserved_quantity, 10) + # check response + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.lot_replaced_by_lot( + previous_lot, self.free_lot + ), + ) + + def test_change_pack_lot_change_lot_ok_with_control_stock(self): + zone_location = self.zone_location + picking_type = self.picking2.picking_type_id + move_line = self.picking2.move_line_ids[0] + previous_lot = move_line.lot_id + self.free_lot.product_id = move_line.product_id + # ensure we have our new lot but in another location + self._update_qty_in_location( + self.zone_sublocation1, + move_line.product_id, + move_line.product_uom_qty, + lot=self.free_lot, + ) + # change lot + response = self.service.dispatch( + "change_pack_lot", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.free_lot.name, + }, + ) + # check data + self.assertRecordValues(move_line, [{"lot_id": self.free_lot.id}]) + # check that reservations could not be made as the lot is + # theoretically elsewhere + previous_quant = self.env["stock.quant"].search( + [ + ("location_id", "=", move_line.location_id.id), + ("product_id", "=", move_line.product_id.id), + ("lot_id", "=", previous_lot.id), + ] + ) + self.assertEqual(previous_quant.quantity, 10) + self.assertEqual(previous_quant.reserved_quantity, 0) + new_quant = self.env["stock.quant"].search( + [ + ("location_id", "=", move_line.location_id.id), + ("product_id", "=", move_line.product_id.id), + ("lot_id", "=", self.free_lot.id), + ] + ) + self.assertFalse(new_quant) + # as such an inventory of control has been generated to check this issue + control_inventory_name = "Pick: stock issue on lot: {} found in {}".format( + self.free_lot.name, move_line.location_id.name + ) + control_inventory = self.env["stock.inventory"].search( + [ + ("name", "=", control_inventory_name), + ("location_ids", "in", move_line.location_id.id), + ("product_ids", "in", move_line.product_id.id), + ("state", "in", ("draft", "confirm")), + ] + ) + self.assertTrue(control_inventory) + # check response + message = self.service.msg_store.lot_replaced_by_lot( + previous_lot, self.free_lot + ) + message["body"] += " A draft inventory has been created for control." + self.assert_response_set_line_destination( + response, zone_location, picking_type, move_line, message=message, + ) From ae720169d75bf826879bd6a628e8ba71f6b7049c Mon Sep 17 00:00:00 2001 From: sebalix Date: Wed, 29 Jul 2020 16:17:55 +0200 Subject: [PATCH 302/940] zone picking: implement /prepare_unload --- shopfloor/services/zone_picking.py | 46 ++++++- shopfloor/tests/test_zone_picking_base.py | 100 +++++++++++++++- .../tests/test_zone_picking_select_line.py | 113 +++++++++++++++++- 3 files changed, 254 insertions(+), 5 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 1c51b2f3cb..d12ee07ad2 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -260,6 +260,29 @@ def _sort_key_move_lines(order): False, ) + def _find_buffer_move_lines_domain(self, zone_location, picking_type): + return [ + ("location_id", "child_of", zone_location.id), + ("qty_done", ">", 0), + ("state", "not in", ("cancel", "done")), + ("result_package_id", "!=", False), + ("shopfloor_user_id", "=", self.env.user.id), + ] + + def _find_buffer_move_lines(self, zone_location, picking_type): + """Find lines that belongs to the operator's buffer and return them + grouped by destination package. + """ + domain = self._find_buffer_move_lines_domain(zone_location, picking_type) + return self.env["stock.move.line"].search(domain) + + def _group_buffer_move_lines_by_package(self, move_lines): + data = {} + for move_line in move_lines: + data.setdefault(move_line.result_package_id, move_line.browse()) + data[move_line.result_package_id] |= move_line + return data + def scan_location(self, barcode): """Scan the zone location where the picking should occur @@ -858,7 +881,28 @@ def prepare_unload(self, zone_location_id, picking_type_id): destination location * select_line: no remaining move lines in buffer """ - return self._response() + zone_location = self.env["stock.location"].browse(zone_location_id) + if not zone_location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + picking_type = self.env["stock.picking.type"].browse(picking_type_id) + if not picking_type.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_lines = self._find_buffer_move_lines(zone_location, picking_type) + location_dest = move_lines.mapped("location_dest_id") + if len(move_lines) == 1: + return self._response_for_unload_set_destination( + zone_location, picking_type, move_lines + ) + elif len(move_lines) > 1 and len(location_dest) == 1: + return self._response_for_unload_all( + zone_location, picking_type, move_lines + ) + elif len(move_lines) > 1 and len(location_dest) > 1: + return self._response_for_unload_single( + zone_location, picking_type, first(move_lines) + ) + move_lines = self._find_location_move_lines(zone_location, picking_type,) + return self._response_for_select_line(zone_location, picking_type, move_lines) def set_destination_all( self, zone_location_id, picking_type_id, barcode, confirmation=False diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 79d22c8ea7..8031c291da 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -84,12 +84,26 @@ def setUpClassBaseData(cls, *args, **kwargs): } ) ) + cls.product_f = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product F", + "type": "product", + "default_code": "F", + "barcode": "F", + "weight": 3, + } + ) + ) products = ( cls.product_a + cls.product_b + cls.product_c + cls.product_d + cls.product_e + + cls.product_f ) for product in products: cls.env["stock.putaway.rule"].sudo().create( @@ -106,7 +120,10 @@ def setUpClassBaseData(cls, *args, **kwargs): ) cls.picking3 = picking3 = cls._create_picking(lines=[(cls.product_d, 10)]) cls.picking4 = picking4 = cls._create_picking(lines=[(cls.product_e, 10)]) - cls.pickings = picking1 | picking2 | picking3 | picking4 + cls.picking5 = picking5 = cls._create_picking( + lines=[(cls.product_b, 10), (cls.product_f, 10)] + ) + cls.pickings = picking1 | picking2 | picking3 | picking4 | picking5 cls._fill_stock_for_moves( picking1.move_lines, in_package=True, location=cls.zone_sublocation1 ) @@ -114,6 +131,9 @@ def setUpClassBaseData(cls, *args, **kwargs): picking2.move_lines, in_lot=True, location=cls.zone_sublocation2 ) cls._fill_stock_for_moves(picking3.move_lines, location=cls.zone_sublocation3) + cls._fill_stock_for_moves( + picking5.move_lines, in_package=True, location=cls.zone_sublocation4 + ) # Put product_e quantities in two different source locations to get # two stock move lines (6 and 4 to satisfy 10 qties) cls._update_qty_in_location(cls.zone_sublocation3, cls.product_e, 6) @@ -302,3 +322,81 @@ def assert_response_change_pack_lot( move_line, message=message, ) + + def _assert_response_unload_set_destination( + self, state, response, zone_location, picking_type, move_line, message=None, + ): + self.assert_response( + response, + next_state=state, + data={ + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "move_line": self.data.move_line(move_line), + }, + message=message, + ) + + def assert_response_unload_set_destination( + self, response, zone_location, picking_type, move_line, message=None, + ): + self._assert_response_unload_set_destination( + "unload_set_destination", + response, + zone_location, + picking_type, + move_line, + message=message, + ) + + def _assert_response_unload_all( + self, state, response, zone_location, picking_type, move_lines, message=None, + ): + self.assert_response( + response, + next_state=state, + data={ + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "move_lines": self.data.move_lines(move_lines), + }, + message=message, + ) + + def assert_response_unload_all( + self, response, zone_location, picking_type, move_lines, message=None, + ): + self._assert_response_unload_all( + "unload_all", + response, + zone_location, + picking_type, + move_lines, + message=message, + ) + + def _assert_response_unload_single( + self, state, response, zone_location, picking_type, move_line, message=None, + ): + self.assert_response( + response, + next_state=state, + data={ + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "move_line": self.data.move_line(move_line), + }, + message=message, + ) + + def assert_response_unload_single( + self, response, zone_location, picking_type, move_line, message=None, + ): + self._assert_response_unload_single( + "unload_single", + response, + zone_location, + picking_type, + move_line, + message=message, + ) diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py index e9142500cc..fc442b851e 100644 --- a/shopfloor/tests/test_zone_picking_select_line.py +++ b/shopfloor/tests/test_zone_picking_select_line.py @@ -6,6 +6,7 @@ class ZonePickingSelectLineCase(ZonePickingCommonCase): * /list_move_lines (to change order) * /scan_source + * /prepare_unload """ @@ -23,8 +24,9 @@ def test_list_move_lines_order_by_location(self): "order": "location", }, ) - move_lines = self.service._find_location_move_lines(zone_location, picking_type) - move_lines = move_lines.sorted(lambda l: l.location_id.name) + move_lines = self.service._find_location_move_lines( + zone_location, picking_type, order="location" + ) self.assert_response_select_line( response, zone_location, picking_type, move_lines, ) @@ -281,7 +283,6 @@ def test_scan_source_barcode_not_found(self): }, ) move_lines = self.service._find_location_move_lines(zone_location, picking_type) - move_lines = move_lines.sorted(lambda l: l.move_id.priority) self.assert_response_select_line( response, zone_location=self.zone_location, @@ -289,3 +290,109 @@ def test_scan_source_barcode_not_found(self): move_lines=move_lines, message=self.service.msg_store.barcode_not_found(), ) + + def test_prepare_unload_wrong_parameters(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "prepare_unload", + params={ + "zone_location_id": 1234567890, + "picking_type_id": picking_type.id, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + response = self.service.dispatch( + "prepare_unload", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": 1234567890, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + + def test_prepare_unload_buffer_empty(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + # unload goods + response = self.service.dispatch( + "prepare_unload", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + }, + ) + # check response + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + self.assert_response_select_line( + response, zone_location, picking_type, move_lines, + ) + + def test_prepare_unload_buffer_one_line(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + # scan a destination package to get something in the buffer + move_line = self.picking1.move_line_ids + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": move_line.product_uom_qty, + }, + ) + # unload goods + response = self.service.dispatch( + "prepare_unload", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + }, + ) + # check response + self.assert_response_unload_set_destination( + response, zone_location, picking_type, move_line, + ) + + def test_prepare_unload_buffer_multi_line_same_destination(self): + zone_location = self.zone_location + picking_type = self.picking5.picking_type_id + # scan a destination package for some move lines + # to get several lines in the buffer (which have the same destination) + self.another_package = self.env["stock.quant.package"].create( + {"name": "ANOTHER_PACKAGE"} + ) + self.assertEqual( + self.picking5.move_line_ids.location_dest_id, self.packing_location + ) + for move_line, package_dest in zip( + self.picking5.move_line_ids, self.free_package | self.another_package + ): + self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": package_dest.name, + "quantity": move_line.product_uom_qty, + }, + ) + # unload goods + response = self.service.dispatch( + "prepare_unload", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + }, + ) + # check response + self.assert_response_unload_all( + response, zone_location, picking_type, self.picking5.move_line_ids, + ) From 97071d6167a3c93563db1fbeb9b750e56fe05170 Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 30 Jul 2020 11:56:02 +0200 Subject: [PATCH 303/940] zone picking: implement /unload_scan_pack --- shopfloor/actions/message.py | 18 ++ shopfloor/services/zone_picking.py | 84 ++++++++- shopfloor/tests/__init__.py | 1 + .../tests/test_zone_picking_unload_single.py | 159 ++++++++++++++++++ 4 files changed, 254 insertions(+), 8 deletions(-) create mode 100644 shopfloor/tests/test_zone_picking_unload_single.py diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 0f72b69ecd..f8b02276a6 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -423,3 +423,21 @@ def lot_is_not_a_package(self, lot): "message_type": "error", "body": _("Lot {} is not a package.").format(lot.name), } + + def buffer_complete(self): + return { + "message_type": "success", + "body": _("Scanned destination packages processed."), + } + + def picking_type_complete(self, picking_type): + return { + "message_type": "success", + "body": _("Picking type {} complete.").format(picking_type.name), + } + + def barcode_no_match(self, barcode): + return { + "message_type": "warning", + "body": _("Barcode does not match with {}.").format(barcode), + } diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index d12ee07ad2..d911a2fbf8 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -157,6 +157,7 @@ def _response_for_confirm_unload_all( def _response_for_unload_single( self, zone_location, picking_type, move_line, message=None, popup=None ): + # TODO add picking completion_info return self._response( next_state="unload_single", data=self._data_for_move_line(zone_location, picking_type, move_line), @@ -260,20 +261,27 @@ def _sort_key_move_lines(order): False, ) - def _find_buffer_move_lines_domain(self, zone_location, picking_type): - return [ + def _find_buffer_move_lines_domain( + self, zone_location, picking_type, dest_package=None + ): + domain = [ ("location_id", "child_of", zone_location.id), ("qty_done", ">", 0), ("state", "not in", ("cancel", "done")), ("result_package_id", "!=", False), ("shopfloor_user_id", "=", self.env.user.id), ] + if dest_package: + domain.append(("result_package_id", "=", dest_package.id)) + return domain - def _find_buffer_move_lines(self, zone_location, picking_type): + def _find_buffer_move_lines(self, zone_location, picking_type, dest_package=None): """Find lines that belongs to the operator's buffer and return them grouped by destination package. """ - domain = self._find_buffer_move_lines_domain(zone_location, picking_type) + domain = self._find_buffer_move_lines_domain( + zone_location, picking_type, dest_package + ) return self.env["stock.move.line"].search(domain) def _group_buffer_move_lines_by_package(self, move_lines): @@ -944,6 +952,33 @@ def unload_split(self, zone_location_id, picking_type_id): """ return self._response() + def _unload_response(self, zone_location, picking_type, unload_single_message=None): + """Prepare the right response depending on the move lines to process.""" + # if there are still move lines to process from the buffer + move_lines = self._find_buffer_move_lines(zone_location, picking_type) + if move_lines: + return self._response_for_unload_single( + zone_location, + picking_type, + first(move_lines), + message=unload_single_message, + ) + # if there are still move lines to process from the picking type + # => buffer complete! + move_lines = self._find_location_move_lines(zone_location, picking_type) + if move_lines: + return self._response_for_select_line( + zone_location, + picking_type, + move_lines, + message=self.msg_store.buffer_complete(), + ) + # no more move lines to process from the current picking type + # => picking type complete! + return self._response_for_start( + message=self.msg_store.picking_type_complete(picking_type) + ) + def unload_scan_pack(self, zone_location_id, picking_type_id, package_id, barcode): """Scan the destination package to check user moves the good one @@ -954,11 +989,38 @@ def unload_scan_pack(self, zone_location_id, picking_type_id, package_id, barcod Transitions: * unload_single: the scanned barcode does not match the package * unload_set_destination: the scanned barcode matches the package - * confirm_unload_set_destination: the scanned location is not in the - expected one but is valid (in picking type's default destination) * select_line: no remaining move lines in buffer + * start: no remaining move lines in picking type """ - return self._response() + zone_location = self.env["stock.location"].browse(zone_location_id) + if not zone_location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + picking_type = self.env["stock.picking.type"].browse(picking_type_id) + if not picking_type.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + package = self.env["stock.quant.package"].browse(package_id) + if not package.exists(): + return self._unload_response( + zone_location, + picking_type, + unload_single_message=self.msg_store.record_not_found(), + ) + search = self.actions_for("search") + scanned_package = search.package_from_scan(barcode) + # the scanned barcode matches the package + if scanned_package == package: + move_lines = self._find_buffer_move_lines( + zone_location, picking_type, dest_package=scanned_package + ) + if move_lines: + return self._response_for_unload_set_destination( + zone_location, picking_type, first(move_lines) + ) + return self._unload_response( + zone_location, + picking_type, + unload_single_message=self.msg_store.barcode_no_match(package.name), + ) def unload_set_destination( self, zone_location_id, picking_type_id, package_id, barcode, confirmation=False @@ -1085,6 +1147,7 @@ def unload_scan_pack(self): "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"}, "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"}, "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": False, "nullable": True, "type": "string"}, } def unload_set_destination(self): @@ -1175,7 +1238,12 @@ def unload_split(self): def unload_scan_pack(self): return self._response_schema( - next_states={"unload_single", "unload_set_destination", "select_line"} + next_states={ + "unload_single", + "unload_set_destination", + "select_line", + "start", + } ) def unload_set_destination(self): diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 2dea4bfe54..1c0b0f65e8 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -46,3 +46,4 @@ from . import test_zone_picking_zero_check from . import test_zone_picking_stock_issue from . import test_zone_picking_change_pack_lot +from . import test_zone_picking_unload_single diff --git a/shopfloor/tests/test_zone_picking_unload_single.py b/shopfloor/tests/test_zone_picking_unload_single.py new file mode 100644 index 0000000000..ca9c7b37b0 --- /dev/null +++ b/shopfloor/tests/test_zone_picking_unload_single.py @@ -0,0 +1,159 @@ +from .test_zone_picking_base import ZonePickingCommonCase + + +class ZonePickingUnloadSingleCase(ZonePickingCommonCase): + """Tests for endpoint used from unload_single + + * /unload_scan_pack + + """ + + def test_unload_scan_pack_wrong_parameters(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + package = move_line.package_id + response = self.service.dispatch( + "unload_scan_pack", + params={ + "zone_location_id": 1234567890, + "picking_type_id": picking_type.id, + "package_id": package.id, + "barcode": "UNKNOWN", + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + response = self.service.dispatch( + "unload_scan_pack", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": 1234567890, + "package_id": package.id, + "barcode": "UNKNOWN", + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + # wrong package ID, and there is still a move line to unload + # => get back on 'unload_single' screen + self.service._set_destination_package( + zone_location, + picking_type, + move_line, + move_line.product_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_scan_pack", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "package_id": 1234567890, + "barcode": "UNKNOWN", + }, + ) + self.assert_response_unload_single( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.record_not_found(), + ) + # wrong package ID, and there is no more move line to unload from the buffer + # => get back on 'select_line' screen + move_line.write( + {"qty_done": 0, "shopfloor_user_id": False, "result_package_id": False} + ) + response = self.service.dispatch( + "unload_scan_pack", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "package_id": 1234567890, + "barcode": "UNKNOWN", + }, + ) + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.buffer_complete(), + ) + # wrong package ID, and there is no more move line to process in picking type + # => get back on 'start' screen + self.pickings.move_lines._do_unreserve() + response = self.service.dispatch( + "unload_scan_pack", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "package_id": 1234567890, + "barcode": "UNKNOWN", + }, + ) + self.assert_response_start( + response, + message=self.service.msg_store.picking_type_complete(picking_type), + ) + + def test_unload_scan_pack_barcode_match(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + # set the destination package + self.service._set_destination_package( + zone_location, + picking_type, + move_line, + move_line.product_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_scan_pack", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "package_id": move_line.result_package_id.id, + "barcode": self.free_package.name, + }, + ) + self.assert_response_unload_set_destination( + response, zone_location, picking_type, move_line, + ) + + def test_unload_scan_pack_barcode_not_match(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + self.wrong_package = self.env["stock.quant.package"].create( + {"name": "WRONG_PACKAGE"} + ) + # set the destination package + self.service._set_destination_package( + zone_location, + picking_type, + move_line, + move_line.product_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_scan_pack", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "package_id": move_line.result_package_id.id, + "barcode": self.wrong_package.name, + }, + ) + self.assert_response_unload_single( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.barcode_no_match(self.free_package.name), + ) From 718ff8832af79401b4a040f8d0edc8afb01ad356 Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 30 Jul 2020 15:14:20 +0200 Subject: [PATCH 304/940] shopfloor: move package storage_type info from 'data_detail' to 'data' --- shopfloor/actions/data.py | 1 + shopfloor/actions/data_detail.py | 1 - shopfloor/services/schema.py | 1 + shopfloor/services/schema_detail.py | 1 - shopfloor/tests/test_actions_data.py | 20 ++++++++++++++++++++ shopfloor/tests/test_actions_data_detail.py | 4 ++++ 6 files changed, 26 insertions(+), 2 deletions(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 10f04f8269..9d4d045d66 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -87,6 +87,7 @@ def _package_parser(self): "id", "name", "pack_weight:weight", + ("package_storage_type_id:storage_type", ["id", "name"]), ] @property diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py index 7e577ba9cc..4e25abbe42 100644 --- a/shopfloor/actions/data_detail.py +++ b/shopfloor/actions/data_detail.py @@ -71,7 +71,6 @@ def _package_detail_parser(self): "reserved_move_line_ids:move_lines", lambda record, fname: self.move_lines(record[fname]), ), - ("package_storage_type_id:storage_type", ["id", "name"]), ] def lot_detail(self, record, **kw): diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 59c085c033..62d4647f70 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -115,6 +115,7 @@ def package(self, with_packaging=False): "name": {"type": "string", "nullable": False, "required": True}, "weight": {"required": True, "nullable": True, "type": "float"}, "move_line_count": {"required": False, "nullable": True, "type": "integer"}, + "storage_type": self._schema_dict_of(self._simple_record()), } if with_packaging: schema["packaging"] = self._schema_dict_of(self.packaging()) diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py index 03fea1c23a..8f8e283c85 100644 --- a/shopfloor/services/schema_detail.py +++ b/shopfloor/services/schema_detail.py @@ -48,7 +48,6 @@ def package_detail(self): schema.update( { "pickings": self._schema_list_of(self.picking()), - "storage_type": self._schema_dict_of(self._simple_record()), "move_lines": self._schema_list_of(self.move_line()), } ) diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index fbd1942025..6ea9f5f984 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -17,6 +17,9 @@ def setUpClassVars(cls): super().setUpClassVars() cls.wh = cls.env.ref("stock.warehouse0") cls.picking_type = cls.wh.out_type_id + cls.storage_type_pallet = cls.env.ref( + "stock_storage_type.package_storage_type_pallets" + ) @classmethod def setUpClassBaseData(cls): @@ -88,11 +91,20 @@ def _expected_packaging(self, record, **kw): data.update(kw) return data + def _expected_storage_type(self, record, **kw): + data = { + "id": record.id, + "name": record.name, + } + data.update(kw) + return data + def _expected_package(self, record, **kw): data = { "id": record.id, "name": record.name, "weight": record.pack_weight, + "storage_type": None, } data.update(kw) return data @@ -131,6 +143,7 @@ def test_data_lot(self): def test_data_package(self): package = self.move_a.move_line_ids.package_id package.product_packaging_id = self.packaging.id + package.package_storage_type_id = self.storage_type_pallet data = self.data.package(package, picking=self.picking, with_packaging=True) self.assert_schema(self.schema.package(with_packaging=True), data) expected = { @@ -138,6 +151,9 @@ def test_data_package(self): "name": package.name, "move_line_count": 1, "packaging": self._expected_packaging(package.product_packaging_id), + "storage_type": self._expected_storage_type( + package.package_storage_type_id + ), "weight": 0.0, } self.assertDictEqual(data, expected) @@ -222,6 +238,7 @@ def test_data_move_line_package(self): "move_line_count": 1, # TODO "weight": 0.0, + "storage_type": None, }, "package_dest": { "id": result_package.id, @@ -229,6 +246,7 @@ def test_data_move_line_package(self): "move_line_count": 0, # TODO "weight": 0.0, + "storage_type": None, }, "location_src": self._expected_location(move_line.location_id), "location_dest": self._expected_location(move_line.location_dest_id), @@ -276,6 +294,7 @@ def test_data_move_line_package_lot(self): "move_line_count": 1, # TODO "weight": 0, + "storage_type": None, }, "package_dest": { "id": move_line.result_package_id.id, @@ -283,6 +302,7 @@ def test_data_move_line_package_lot(self): "move_line_count": 1, # TODO "weight": 0, + "storage_type": None, }, "location_src": self._expected_location(move_line.location_id), "location_dest": self._expected_location(move_line.location_dest_id), diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index 0fef6ab21f..c3315a5f2d 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -204,12 +204,14 @@ def test_data_move_line_package(self): "name": move_line.package_id.name, "move_line_count": 1, "weight": 0.0, + "storage_type": None, }, "package_dest": { "id": result_package.id, "name": result_package.name, "move_line_count": 0, "weight": 0.0, + "storage_type": None, }, "location_src": self._expected_location(move_line.location_id), "location_dest": self._expected_location(move_line.location_dest_id), @@ -259,12 +261,14 @@ def test_data_move_line_package_lot(self): "name": move_line.package_id.name, "move_line_count": 1, "weight": 0.0, + "storage_type": None, }, "package_dest": { "id": move_line.result_package_id.id, "name": move_line.result_package_id.name, "move_line_count": 1, "weight": 0.0, + "storage_type": None, }, "location_src": self._expected_location(move_line.location_id), "location_dest": self._expected_location(move_line.location_dest_id), From 8565566a419c2b2652e7865d44492f00498fe95f Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 30 Jul 2020 18:00:12 +0200 Subject: [PATCH 305/940] zone picking: implement /set_destination_all --- shopfloor/actions/message.py | 6 + shopfloor/services/zone_picking.py | 104 ++++++-- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_zone_picking_base.py | 19 +- .../tests/test_zone_picking_unload_all.py | 242 ++++++++++++++++++ 5 files changed, 347 insertions(+), 25 deletions(-) create mode 100644 shopfloor/tests/test_zone_picking_unload_all.py diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index f8b02276a6..91bbd908d4 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -441,3 +441,9 @@ def barcode_no_match(self, barcode): "message_type": "warning", "body": _("Barcode does not match with {}.").format(barcode), } + + def lines_different_dest_location(self): + return { + "message_type": "error", + "body": _("Lines have different destination location."), + } diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index d911a2fbf8..793ebe4b6c 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -137,22 +137,18 @@ def _response_for_change_pack_lot( ) def _response_for_unload_all( - self, zone_location, picking_type, move_lines, message=None - ): - return self._response( - next_state="unload_all", - data=self._data_for_move_lines(zone_location, picking_type, move_lines), - message=message, - ) - - def _response_for_confirm_unload_all( - self, zone_location, picking_type, move_lines, message=None + self, + zone_location, + picking_type, + move_lines, + message=None, + confirmation_required=False, ): - return self._response( - next_state="confirm_unload_all", - data=self._data_for_move_lines(zone_location, picking_type, move_lines), - message=message, - ) + if confirmation_required and not message: + message = self.msg_store.need_confirmation() + data = self._data_for_move_lines(zone_location, picking_type, move_lines) + data["confirmation_required"] = confirmation_required + return self._response(next_state="unload_all", data=data, message=message,) def _response_for_unload_single( self, zone_location, picking_type, move_line, message=None, popup=None @@ -912,6 +908,20 @@ def prepare_unload(self, zone_location_id, picking_type_id): move_lines = self._find_location_move_lines(zone_location, picking_type,) return self._response_for_select_line(zone_location, picking_type, move_lines) + def _set_destination_all_response( + self, zone_location, picking_type, buffer_lines, message=None + ): + if buffer_lines: + return self._response_for_unload_all( + zone_location, picking_type, buffer_lines, message=message, + ) + move_lines = self._find_location_move_lines(zone_location, picking_type) + if move_lines: + return self._response_for_select_line( + zone_location, picking_type, move_lines, message=message, + ) + return self._response_for_start(message=message) + def set_destination_all( self, zone_location_id, picking_type_id, barcode, confirmation=False ): @@ -923,19 +933,65 @@ def set_destination_all( a destination package, qty done > 0, and have the same destination location. - A scanned location outside of the source location of the operation type is - invalid. + A scanned location outside of the destination location of the operation + type is invalid. The move lines are then set to done, without backorders. Transitions: * unload_all: the scanned destination is invalid, user has to scan another one - * confirm_unload_all: the scanned location is not in the + * unload_all + confirmation: the scanned location is not in the expected one but is valid (in picking type's default destination) * select_line: no remaining move lines in buffer """ - return self._response() + zone_location = self.env["stock.location"].browse(zone_location_id) + if not zone_location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + picking_type = self.env["stock.picking.type"].browse(picking_type_id) + if not picking_type.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + search = self.actions_for("search") + location = search.location_from_scan(barcode) + message = None + buffer_lines = self._find_buffer_move_lines(zone_location, picking_type) + if location: + error = None + location_dest = buffer_lines.mapped("location_dest_id") + # check if move lines share the same destination + if len(location_dest) != 1: + error = self.msg_store.lines_different_dest_location() + # check if the scanned location is allowed + if not location.is_sublocation_of(picking_type.default_location_dest_id): + error = self.msg_store.location_not_allowed() + if error: + return self._set_destination_all_response( + zone_location, picking_type, buffer_lines, message=error + ) + # check if the destination location is not the expected one + if location != picking_type.default_location_dest_id: + if not confirmation: + return self._response_for_unload_all( + zone_location, + picking_type, + buffer_lines, + message=self.msg_store.confirm_location_changed( + picking_type.default_location_dest_id, location + ), + confirmation_required=True, + ) + # the scanned location is still valid, use it + buffer_lines.location_dest_id = location + # set lines to done + refresh buffer lines (should be empty) + moves = buffer_lines.mapped("move_id") + moves.with_context(_sf_no_backorder=True)._action_done() + message = self.msg_store.buffer_complete() + buffer_lines = self._find_buffer_move_lines(zone_location, picking_type) + else: + message = self.msg_store.no_location_found() + return self._set_destination_all_response( + zone_location, picking_type, buffer_lines, message=message, + ) def unload_split(self, zone_location_id, picking_type_id): """Indicates that now the buffer must be treated line per line @@ -1181,7 +1237,6 @@ def _states(self): "zero_check": self._schema_for_zero_check, "change_pack_lot": self._schema_for_move_line, "unload_all": self._schema_for_move_lines, - "confirm_unload_all": self._schema_for_move_lines, "unload_single": self._schema_for_move_line, "unload_set_destination": self._schema_for_move_line, "confirm_unload_set_destination": self._schema_for_move_line, @@ -1227,9 +1282,7 @@ def prepare_unload(self): ) def set_destination_all(self): - return self._response_schema( - next_states={"unload_all", "confirm_unload_all", "select_line"} - ) + return self._response_schema(next_states={"unload_all", "select_line"}) def unload_split(self): return self._response_schema( @@ -1284,6 +1337,11 @@ def _schema_for_move_lines(self): "zone_location": self.schemas._schema_dict_of(self.schemas.location()), "picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()), "move_lines": self.schemas._schema_list_of(self.schemas.move_line()), + "confirmation_required": { + "type": "boolean", + "nullable": True, + "required": False, + }, } return schema diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 1c0b0f65e8..e7a0ace758 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -47,3 +47,4 @@ from . import test_zone_picking_stock_issue from . import test_zone_picking_change_pack_lot from . import test_zone_picking_unload_single +from . import test_zone_picking_unload_all diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 8031c291da..3d66f32bfa 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -350,7 +350,14 @@ def assert_response_unload_set_destination( ) def _assert_response_unload_all( - self, state, response, zone_location, picking_type, move_lines, message=None, + self, + state, + response, + zone_location, + picking_type, + move_lines, + message=None, + confirmation_required=False, ): self.assert_response( response, @@ -359,12 +366,19 @@ def _assert_response_unload_all( "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), "move_lines": self.data.move_lines(move_lines), + "confirmation_required": confirmation_required, }, message=message, ) def assert_response_unload_all( - self, response, zone_location, picking_type, move_lines, message=None, + self, + response, + zone_location, + picking_type, + move_lines, + message=None, + confirmation_required=False, ): self._assert_response_unload_all( "unload_all", @@ -373,6 +387,7 @@ def assert_response_unload_all( picking_type, move_lines, message=message, + confirmation_required=confirmation_required, ) def _assert_response_unload_single( diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py new file mode 100644 index 0000000000..2cff790f7b --- /dev/null +++ b/shopfloor/tests/test_zone_picking_unload_all.py @@ -0,0 +1,242 @@ +from .test_zone_picking_base import ZonePickingCommonCase + + +class ZonePickingUnloadAllCase(ZonePickingCommonCase): + """Tests for endpoint used from unload_all + + * /set_destination_all + + """ + + def test_set_destination_all_wrong_parameters(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "set_destination_all", + params={ + "zone_location_id": 1234567890, + "picking_type_id": picking_type.id, + "barcode": "UNKNOWN", + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + response = self.service.dispatch( + "set_destination_all", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": 1234567890, + "barcode": "UNKNOWN", + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + + def test_set_destination_all_different_destination(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line1 = self.picking5.move_line_ids[0] + move_line2 = self.picking5.move_line_ids[1] + another_package = self.env["stock.quant.package"].create( + {"name": "ANOTHER_PACKAGE"} + ) + # change the destination location of one move line + move_line2.location_dest_id = self.zone_sublocation3 + # set the destination package on lines + self.service._set_destination_package( + zone_location, + picking_type, + move_line1, + move_line1.product_uom_qty, + self.free_package, + ) + self.service._set_destination_package( + zone_location, + picking_type, + move_line2, + move_line2.product_uom_qty, + another_package, + ) + # set destination location for all lines in the buffer + response = self.service.dispatch( + "set_destination_all", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": self.packing_location.barcode, + }, + ) + # check response + buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) + self.assert_response_unload_all( + response, + zone_location, + picking_type, + buffer_lines, + message=self.service.msg_store.lines_different_dest_location(), + ) + + def test_set_destination_all_confirm_destination(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line1 = self.picking5.move_line_ids[0] + move_line2 = self.picking5.move_line_ids[1] + another_package = self.env["stock.quant.package"].create( + {"name": "ANOTHER_PACKAGE"} + ) + packing_sublocation = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Packing sublocation", + "location_id": self.packing_location.id, + "barcode": "PACKING_SUBLOCATION", + } + ) + ) + # set the destination package on lines + self.service._set_destination_package( + zone_location, + picking_type, + move_line1, + move_line1.product_uom_qty, + self.free_package, + ) + self.service._set_destination_package( + zone_location, + picking_type, + move_line2, + move_line2.product_uom_qty, + another_package, + ) + # set destination location for all lines in the buffer + response = self.service.dispatch( + "set_destination_all", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": packing_sublocation.barcode, + }, + ) + # check response + buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) + self.assert_response_unload_all( + response, + zone_location, + picking_type, + buffer_lines, + message=self.service.msg_store.confirm_location_changed( + picking_type.default_location_dest_id, packing_sublocation, + ), + confirmation_required=True, + ) + + def test_set_destination_all_ok(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line1 = self.picking5.move_line_ids[0] + move_line2 = self.picking5.move_line_ids[1] + another_package = self.env["stock.quant.package"].create( + {"name": "ANOTHER_PACKAGE"} + ) + # set the destination package on lines + self.service._set_destination_package( + zone_location, + picking_type, + move_line1, + move_line1.product_uom_qty, + self.free_package, + ) + self.service._set_destination_package( + zone_location, + picking_type, + move_line2, + move_line2.product_uom_qty, + another_package, + ) + # set destination location for all lines in the buffer + response = self.service.dispatch( + "set_destination_all", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": self.packing_location.barcode, + }, + ) + # check data + self.assertEqual(self.picking5.state, "done") + # buffer should be empty + buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) + self.assertFalse(buffer_lines) + # check response + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.buffer_complete(), + ) + + def test_set_destination_all_location_not_allowed(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + # set the destination package on lines + self.service._set_destination_package( + zone_location, + picking_type, + move_line, + move_line.product_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "set_destination_all", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": self.customer_location.barcode, + }, + ) + # check response + buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) + self.assert_response_unload_all( + response, + zone_location, + picking_type, + buffer_lines, + message=self.service.msg_store.location_not_allowed(), + ) + + def test_set_destination_all_location_not_found(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + # set the destination package on lines + self.service._set_destination_package( + zone_location, + picking_type, + move_line, + move_line.product_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "set_destination_all", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": "UNKNOWN", + }, + ) + # check response + buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) + self.assert_response_unload_all( + response, + zone_location, + picking_type, + buffer_lines, + message=self.service.msg_store.no_location_found(), + ) From b7ea1c318a54c2dc41eec8c658962d1fb7d3a719 Mon Sep 17 00:00:00 2001 From: sebalix Date: Fri, 31 Jul 2020 09:25:52 +0200 Subject: [PATCH 306/940] zone picking: implement /unload_split --- shopfloor/services/zone_picking.py | 26 ++++++- .../tests/test_zone_picking_unload_all.py | 77 +++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 793ebe4b6c..edb9feac10 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -1006,7 +1006,31 @@ def unload_split(self, zone_location_id, picking_type_id): * unload_set_destination: there is only one remaining line in the buffer * select_line: no remaining move lines in buffer """ - return self._response() + zone_location = self.env["stock.location"].browse(zone_location_id) + if not zone_location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + picking_type = self.env["stock.picking.type"].browse(picking_type_id) + if not picking_type.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + buffer_lines = self._find_buffer_move_lines(zone_location, picking_type) + # more than one remaining move line in the buffer + if len(buffer_lines) > 1: + return self._response_for_unload_single( + zone_location, picking_type, first(buffer_lines), + ) + # only one move line to process in the buffer + elif len(buffer_lines) == 1: + return self._response_for_unload_set_destination( + zone_location, picking_type, first(buffer_lines) + ) + # no remaining move lines in buffer + move_lines = self._find_location_move_lines(zone_location, picking_type) + return self._response_for_select_line( + zone_location, + picking_type, + move_lines, + message=self.msg_store.buffer_complete(), + ) def _unload_response(self, zone_location, picking_type, unload_single_message=None): """Prepare the right response depending on the move lines to process.""" diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py index 2cff790f7b..3666e15141 100644 --- a/shopfloor/tests/test_zone_picking_unload_all.py +++ b/shopfloor/tests/test_zone_picking_unload_all.py @@ -5,6 +5,7 @@ class ZonePickingUnloadAllCase(ZonePickingCommonCase): """Tests for endpoint used from unload_all * /set_destination_all + * /unload_split """ @@ -240,3 +241,79 @@ def test_set_destination_all_location_not_found(self): buffer_lines, message=self.service.msg_store.no_location_found(), ) + + def test_unload_split_buffer_empty(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "unload_split", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + }, + ) + # check response + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.buffer_complete(), + ) + + def test_unload_split_buffer_one_line(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + # put one line in the buffer + self.service._set_destination_package( + zone_location, + picking_type, + move_line, + move_line.product_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_split", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + }, + ) + # check response + buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) + self.assert_response_unload_set_destination( + response, zone_location, picking_type, buffer_lines, + ) + + def test_unload_split_buffer_multi_lines(self): + zone_location = self.zone_location + picking_type = self.picking5.picking_type_id + move_line = self.picking5.move_line_ids + # put several lines in the buffer + self.another_package = self.env["stock.quant.package"].create( + {"name": "ANOTHER_PACKAGE"} + ) + for move_line, package_dest in zip( + self.picking5.move_line_ids, self.free_package | self.another_package + ): + self.service._set_destination_package( + zone_location, + picking_type, + move_line, + move_line.product_uom_qty, + package_dest, + ) + response = self.service.dispatch( + "unload_split", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + }, + ) + # check response + buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) + self.assert_response_unload_single( + response, zone_location, picking_type, buffer_lines[0], + ) From ccef757e6b281ac6c830f6491855bf3b5c861a26 Mon Sep 17 00:00:00 2001 From: sebalix Date: Fri, 31 Jul 2020 14:06:35 +0200 Subject: [PATCH 307/940] zone picking: implement /unload_set_destination --- shopfloor/services/zone_picking.py | 113 ++++++-- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_zone_picking_base.py | 19 +- ...est_zone_picking_unload_set_destination.py | 246 ++++++++++++++++++ 4 files changed, 351 insertions(+), 28 deletions(-) create mode 100644 shopfloor/tests/test_zone_picking_unload_set_destination.py diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index edb9feac10..565b53e215 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -162,20 +162,19 @@ def _response_for_unload_single( ) def _response_for_unload_set_destination( - self, zone_location, picking_type, move_line, message=None - ): - return self._response( - next_state="unload_set_destination", - data=self._data_for_move_line(zone_location, picking_type, move_line), - message=message, - ) - - def _response_for_confirm_unload_set_destination( - self, zone_location, picking_type, move_line + self, + zone_location, + picking_type, + move_line, + message=None, + confirmation_required=False, ): + if confirmation_required and not message: + message = self.msg_store.need_confirmation() + data = self._data_for_move_line(zone_location, picking_type, move_line) + data["confirmation_required"] = confirmation_required return self._response( - next_state="confirm_unload_set_destination", - data=self._data_for_move_line(zone_location, picking_type, move_line), + next_state="unload_set_destination", data=data, message=message, ) def _data_for_select_picking_type(self, zone_location, picking_types): @@ -1102,6 +1101,11 @@ def unload_scan_pack(self, zone_location_id, picking_type_id, package_id, barcod unload_single_message=self.msg_store.barcode_no_match(package.name), ) + def _unload_set_destination_lock_lines(self, lines): + """Lock move lines""" + sql = "SELECT id FROM %s WHERE ID IN %%s FOR UPDATE" % lines._table + self.env.cr.execute(sql, (tuple(lines.ids),), log_exceptions=False) + def unload_set_destination( self, zone_location_id, picking_type_id, package_id, barcode, confirmation=False ): @@ -1119,15 +1123,78 @@ def unload_set_destination( * unload_single: buffer still contains move lines, unload the next package * unload_set_destination: the scanned location is invalid, user has to scan another one - * confirm_unload_set_destination: the scanned location is not in the - expected one but is valid (in picking type's default destination) + * unload_set_destination+confirmation_required: the scanned location is not + in the expected one but is valid (in picking type's default destination) * select_line: no remaining move lines in buffer + * start: no remaining move lines to process in the picking type """ - # TODO on _action_done, use ``_sf_no_backorder`` in the - # context to disable backorders (see override in stock_picking.py). - # TODO return a popup with completion info alongside the response, - # see in cluster_picking.py how it's done - return self._response() + zone_location = self.env["stock.location"].browse(zone_location_id) + if not zone_location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + picking_type = self.env["stock.picking.type"].browse(picking_type_id) + if not picking_type.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + package = self.env["stock.quant.package"].browse(package_id) + if not package.exists(): + move_lines = self._find_location_move_lines(zone_location, picking_type) + return self._response_for_select_line( + zone_location, + picking_type, + move_lines, + message=self.msg_store.record_not_found(), + ) + buffer_lines = self._find_buffer_move_lines( + zone_location, picking_type, dest_package=package + ) + search = self.actions_for("search") + location = search.location_from_scan(barcode) + if location: + if not location.is_sublocation_of(picking_type.default_location_dest_id): + return self._response_for_unload_set_destination( + zone_location, + picking_type, + first(buffer_lines), + message=self.msg_store.dest_location_not_allowed(), + ) + if location != picking_type.default_location_dest_id: + if not confirmation: + return self._response_for_unload_set_destination( + zone_location, + picking_type, + first(buffer_lines), + message=self.msg_store.confirm_location_changed( + picking_type.default_location_dest_id, location + ), + confirmation_required=True, + ) + # the scanned location is valid, use it + self._unload_set_destination_lock_lines(buffer_lines) + buffer_lines.location_dest_id = location + # set lines to done + refresh buffer lines (should be empty) + moves = buffer_lines.mapped("move_id") + moves.with_context(_sf_no_backorder=True)._action_done() + buffer_lines = self._find_buffer_move_lines(zone_location, picking_type) + if buffer_lines: + return self._response_for_unload_single( + zone_location, picking_type, first(buffer_lines), + ) + move_lines = self._find_location_move_lines(zone_location, picking_type) + if move_lines: + return self._response_for_select_line( + zone_location, + picking_type, + move_lines, + message=self.msg_store.buffer_complete(), + ) + return self._response_for_start( + message=self.msg_store.picking_type_complete(picking_type) + ) + return self._response_for_unload_set_destination( + zone_location, + picking_type, + first(buffer_lines), + message=self.msg_store.no_location_found(), + ) class ShopfloorZonePickingValidator(Component): @@ -1263,7 +1330,6 @@ def _states(self): "unload_all": self._schema_for_move_lines, "unload_single": self._schema_for_move_line, "unload_set_destination": self._schema_for_move_line, - "confirm_unload_set_destination": self._schema_for_move_line, } def scan_location(self): @@ -1325,12 +1391,7 @@ def unload_scan_pack(self): def unload_set_destination(self): return self._response_schema( - next_states={ - "unload_single", - "unload_set_destination", - "confirm_unload_set_destination", - "select_line", - } + next_states={"unload_single", "unload_set_destination", "select_line"} ) @property diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index e7a0ace758..8f64051db9 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -48,3 +48,4 @@ from . import test_zone_picking_change_pack_lot from . import test_zone_picking_unload_single from . import test_zone_picking_unload_all +from . import test_zone_picking_unload_set_destination diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 3d66f32bfa..1b9d28a6e0 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -324,7 +324,14 @@ def assert_response_change_pack_lot( ) def _assert_response_unload_set_destination( - self, state, response, zone_location, picking_type, move_line, message=None, + self, + state, + response, + zone_location, + picking_type, + move_line, + message=None, + confirmation_required=False, ): self.assert_response( response, @@ -333,12 +340,19 @@ def _assert_response_unload_set_destination( "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), "move_line": self.data.move_line(move_line), + "confirmation_required": confirmation_required, }, message=message, ) def assert_response_unload_set_destination( - self, response, zone_location, picking_type, move_line, message=None, + self, + response, + zone_location, + picking_type, + move_line, + message=None, + confirmation_required=False, ): self._assert_response_unload_set_destination( "unload_set_destination", @@ -347,6 +361,7 @@ def assert_response_unload_set_destination( picking_type, move_line, message=message, + confirmation_required=confirmation_required, ) def _assert_response_unload_all( diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py new file mode 100644 index 0000000000..985b6260ee --- /dev/null +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -0,0 +1,246 @@ +from .test_zone_picking_base import ZonePickingCommonCase + + +class ZonePickingUnloadSetDestinationCase(ZonePickingCommonCase): + """Tests for endpoint used from unload_set_destination + + * /unload_set_destination + + """ + + def test_unload_set_destination_wrong_parameters(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + package = move_line.package_id + response = self.service.dispatch( + "unload_set_destination", + params={ + "zone_location_id": 1234567890, + "picking_type_id": picking_type.id, + "package_id": package.id, + "barcode": "BARCODE", + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + response = self.service.dispatch( + "unload_set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": 1234567890, + "package_id": package.id, + "barcode": "BARCODE", + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + response = self.service.dispatch( + "unload_set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "package_id": 1234567890, + "barcode": "BARCODE", + }, + ) + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.record_not_found(), + ) + + def test_unload_set_destination_no_location_found(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + # set the destination package + self.service._set_destination_package( + zone_location, + picking_type, + move_line, + move_line.product_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "package_id": self.free_package.id, + "barcode": "UNKNOWN", + }, + ) + self.assert_response_unload_set_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.no_location_found(), + ) + + def test_unload_set_destination_location_not_allowed(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + # set the destination package + self.service._set_destination_package( + zone_location, + picking_type, + move_line, + move_line.product_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "package_id": self.free_package.id, + "barcode": self.customer_location.barcode, + }, + ) + self.assert_response_unload_set_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.dest_location_not_allowed(), + ) + + def test_unload_set_destination_confirm_location(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + packing_sublocation = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Packing sublocation", + "location_id": self.packing_location.id, + "barcode": "PACKING_SUBLOCATION", + } + ) + ) + # set the destination package + self.service._set_destination_package( + zone_location, + picking_type, + move_line, + move_line.product_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "package_id": self.free_package.id, + "barcode": packing_sublocation.barcode, + }, + ) + self.assert_response_unload_set_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.confirm_location_changed( + picking_type.default_location_dest_id, packing_sublocation + ), + confirmation_required=True, + ) + + def test_unload_set_destination_ok_buffer_empty(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + packing_sublocation = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Packing sublocation", + "location_id": self.packing_location.id, + "barcode": "PACKING_SUBLOCATION", + } + ) + ) + # set the destination package + self.service._set_destination_package( + zone_location, + picking_type, + move_line, + move_line.product_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "package_id": self.free_package.id, + "barcode": packing_sublocation.barcode, + "confirmation": True, + }, + ) + # check data + self.assertEqual(move_line.location_dest_id, packing_sublocation) + self.assertEqual(move_line.move_id.state, "done") + # check response + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.buffer_complete(), + ) + + def test_unload_set_destination_ok_buffer_not_empty(self): + zone_location = self.zone_location + picking_type = self.picking5.picking_type_id + # put several lines in the buffer + self.another_package = self.env["stock.quant.package"].create( + {"name": "ANOTHER_PACKAGE"} + ) + for move_line, package_dest in zip( + self.picking5.move_line_ids, self.free_package | self.another_package + ): + self.service._set_destination_package( + zone_location, + picking_type, + move_line, + move_line.product_uom_qty, + package_dest, + ) + # process 1/2 buffer line + response = self.service.dispatch( + "unload_set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "package_id": self.free_package.id, + "barcode": self.packing_location.barcode, + }, + ) + # check data + move_line = self.picking5.move_line_ids.filtered( + lambda l: l.result_package_id == self.free_package + ) + self.assertEqual(move_line.location_dest_id, self.packing_location) + self.assertEqual(move_line.move_id.state, "done") + # check response + buffer_line = self.service._find_buffer_move_lines(zone_location, picking_type) + self.assert_response_unload_single( + response, + zone_location, + picking_type, + buffer_line, + # TODO check completion_info + ) From 127a0b5193d0410bd95df2494de919a30d5b5cb7 Mon Sep 17 00:00:00 2001 From: sebalix Date: Fri, 31 Jul 2020 14:44:22 +0200 Subject: [PATCH 308/940] zone picking: send completion info popup on 'unload_single' screen --- shopfloor/services/zone_picking.py | 6 ++++-- shopfloor/tests/test_zone_picking_base.py | 13 +++++++++++-- shopfloor/tests/test_zone_picking_unload_all.py | 8 +++++++- .../test_zone_picking_unload_set_destination.py | 4 +++- shopfloor/tests/test_zone_picking_unload_single.py | 3 +++ 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 565b53e215..1d4e814546 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -153,12 +153,14 @@ def _response_for_unload_all( def _response_for_unload_single( self, zone_location, picking_type, move_line, message=None, popup=None ): - # TODO add picking completion_info + buffer_lines = self._find_buffer_move_lines(zone_location, picking_type) + completion_info = self.actions_for("completion.info") + completion_info_popup = completion_info.popup(buffer_lines) return self._response( next_state="unload_single", data=self._data_for_move_line(zone_location, picking_type, move_line), message=message, - popup=popup, + popup=popup or completion_info_popup, ) def _response_for_unload_set_destination( diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 1b9d28a6e0..1f66fde167 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -406,7 +406,14 @@ def assert_response_unload_all( ) def _assert_response_unload_single( - self, state, response, zone_location, picking_type, move_line, message=None, + self, + state, + response, + zone_location, + picking_type, + move_line, + message=None, + popup=None, ): self.assert_response( response, @@ -417,10 +424,11 @@ def _assert_response_unload_single( "move_line": self.data.move_line(move_line), }, message=message, + popup=popup, ) def assert_response_unload_single( - self, response, zone_location, picking_type, move_line, message=None, + self, response, zone_location, picking_type, move_line, message=None, popup=None ): self._assert_response_unload_single( "unload_single", @@ -429,4 +437,5 @@ def assert_response_unload_single( picking_type, move_line, message=message, + popup=popup, ) diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py index 3666e15141..86ebbc4ed9 100644 --- a/shopfloor/tests/test_zone_picking_unload_all.py +++ b/shopfloor/tests/test_zone_picking_unload_all.py @@ -314,6 +314,12 @@ def test_unload_split_buffer_multi_lines(self): ) # check response buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) + completion_info = self.service.actions_for("completion.info") + completion_info_popup = completion_info.popup(buffer_lines) self.assert_response_unload_single( - response, zone_location, picking_type, buffer_lines[0], + response, + zone_location, + picking_type, + buffer_lines[0], + popup=completion_info_popup, ) diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py index 985b6260ee..38492df979 100644 --- a/shopfloor/tests/test_zone_picking_unload_set_destination.py +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -237,10 +237,12 @@ def test_unload_set_destination_ok_buffer_not_empty(self): self.assertEqual(move_line.move_id.state, "done") # check response buffer_line = self.service._find_buffer_move_lines(zone_location, picking_type) + completion_info = self.service.actions_for("completion.info") + completion_info_popup = completion_info.popup(buffer_line) self.assert_response_unload_single( response, zone_location, picking_type, buffer_line, - # TODO check completion_info + popup=completion_info_popup, ) diff --git a/shopfloor/tests/test_zone_picking_unload_single.py b/shopfloor/tests/test_zone_picking_unload_single.py index ca9c7b37b0..e6b67d7889 100644 --- a/shopfloor/tests/test_zone_picking_unload_single.py +++ b/shopfloor/tests/test_zone_picking_unload_single.py @@ -55,12 +55,15 @@ def test_unload_scan_pack_wrong_parameters(self): "barcode": "UNKNOWN", }, ) + completion_info = self.service.actions_for("completion.info") + completion_info_popup = completion_info.popup(move_line) self.assert_response_unload_single( response, zone_location, picking_type, move_line, message=self.service.msg_store.record_not_found(), + popup=completion_info_popup, ) # wrong package ID, and there is no more move line to unload from the buffer # => get back on 'select_line' screen From 7275f81173e524cb9c340f0258b89450fa94f0f5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 31 Jul 2020 17:33:59 +0200 Subject: [PATCH 309/940] bknd: zone picking misc fixes --- shopfloor/actions/data.py | 9 +++- shopfloor/services/schema.py | 15 ++++++- shopfloor/services/schema_detail.py | 5 --- shopfloor/services/zone_picking.py | 17 +++++--- shopfloor/tests/test_actions_data.py | 24 +++++++++++ shopfloor/tests/test_actions_data_detail.py | 6 ++- shopfloor/tests/test_checkout_list_package.py | 1 + shopfloor/tests/test_zone_picking_base.py | 12 +++--- .../test_zone_picking_set_line_destination.py | 42 +++++++++++++++---- 9 files changed, 102 insertions(+), 29 deletions(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 9d4d045d66..dc156516f7 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -59,6 +59,7 @@ def _picking_parser(self): ("partner_id:partner", self._partner_parser), "move_line_count", "total_weight:weight", + "scheduled_date", ] def package(self, record, picking=None, with_packaging=False, **kw): @@ -116,9 +117,12 @@ def lots(self, record, **kw): def _lot_parser(self): return self._simple_record_parser() + ["ref"] - def move_line(self, record, **kw): + def move_line(self, record, with_picking=False, **kw): record = record.with_context(location=record.location_id.id) - data = self._jsonify(record, self._move_line_parser) + parser = self._move_line_parser + if with_picking: + parser += [("picking_id:picking", self._picking_parser)] + data = self._jsonify(record, parser) if data: data.update( { @@ -147,6 +151,7 @@ def _move_line_parser(self): ("lot_id:lot", self._lot_parser), ("location_id:location_src", self._location_parser), ("location_dest_id:location_dest", self._location_parser), + ("move_id:priority", lambda rec, fname: rec.move_id.priority or "",), ] def package_level(self, record, **kw): diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 62d4647f70..cf0689f480 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -69,10 +69,11 @@ def picking(self): "name": {"type": "string", "nullable": False, "required": True}, }, }, + "scheduled_date": {"type": "string", "nullable": False, "required": True}, } - def move_line(self, with_packaging=False): - return { + def move_line(self, with_packaging=False, with_picking=False): + schema = { "id": {"type": "integer", "required": True}, "qty_done": {"type": "float", "required": True}, "quantity": {"type": "float", "required": True}, @@ -91,6 +92,16 @@ def move_line(self, with_packaging=False): ), "location_src": self._schema_dict_of(self.location()), "location_dest": self._schema_dict_of(self.location()), + "priority": {"type": "string", "nullable": True, "required": False}, + } + if with_picking: + schema["picking"] = self._schema_dict_of(self.picking()) + return schema + + def move(self): + return { + "id": {"required": True, "type": "integer"}, + "priority": {"type": "string", "required": False, "nullable": True}, } def product(self): diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py index 8f8e283c85..077df97344 100644 --- a/shopfloor/services/schema_detail.py +++ b/shopfloor/services/schema_detail.py @@ -31,11 +31,6 @@ def picking_detail(self): schema.update( { "priority": {"type": "string", "nullable": True, "required": False}, - "scheduled_date": { - "type": "string", - "nullable": False, - "required": True, - }, "operation_type": self._schema_dict_of(self._simple_record()), "carrier": self._schema_dict_of(self._simple_record()), "move_lines": self._schema_list_of(self.move_line()), diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 1d4e814546..a2eadd1a92 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -190,14 +190,14 @@ def _data_for_move_line(self, zone_location, picking_type, move_line): return { "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), - "move_line": self.data.move_line(move_line), + "move_line": self.data.move_line(move_line, with_picking=True), } def _data_for_move_lines(self, zone_location, picking_type, move_lines): return { "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), - "move_lines": self.data.move_lines(move_lines), + "move_lines": self.data.move_lines(move_lines, with_picking=True), } def _data_for_location(self, zone_location, picking_type, location): @@ -666,7 +666,10 @@ def set_destination( if response: return response # Process the next line - return self.list_move_lines(zone_location.id, picking_type.id) + response = self.list_move_lines(zone_location.id, picking_type.id) + return self._response( + base_response=response, message=self.msg_store.confirm_pack_moved(), + ) def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero): """Confirm or not if the source location of a move has zero qty @@ -1409,7 +1412,9 @@ def _schema_for_move_line(self): schema = { "zone_location": self.schemas._schema_dict_of(self.schemas.location()), "picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()), - "move_line": self.schemas._schema_dict_of(self.schemas.move_line()), + "move_line": self.schemas._schema_dict_of( + self.schemas.move_line(with_picking=True) + ), "confirmation_required": { "type": "boolean", "nullable": True, @@ -1423,7 +1428,9 @@ def _schema_for_move_lines(self): schema = { "zone_location": self.schemas._schema_dict_of(self.schemas.location()), "picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()), - "move_lines": self.schemas._schema_list_of(self.schemas.move_line()), + "move_lines": self.schemas._schema_list_of( + self.schemas.move_line(with_picking=True) + ), "confirmation_required": { "type": "boolean", "nullable": True, diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 6ea9f5f984..7249d1874e 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -191,6 +191,7 @@ def test_data_picking(self): "weight": 110.0, "partner": {"id": self.customer.id, "name": self.customer.name}, } + self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-08-03") self.assertDictEqual(data, expected) def test_data_product(self): @@ -250,6 +251,7 @@ def test_data_move_line_package(self): }, "location_src": self._expected_location(move_line.location_id), "location_dest": self._expected_location(move_line.location_dest_id), + "priority": "1", } self.assertDictEqual(data, expected) @@ -271,6 +273,7 @@ def test_data_move_line_lot(self): "package_dest": None, "location_src": self._expected_location(move_line.location_id), "location_dest": self._expected_location(move_line.location_dest_id), + "priority": "1", } self.assertDictEqual(data, expected) @@ -306,6 +309,7 @@ def test_data_move_line_package_lot(self): }, "location_src": self._expected_location(move_line.location_id), "location_dest": self._expected_location(move_line.location_dest_id), + "priority": "1", } self.assertDictEqual(data, expected) @@ -323,6 +327,26 @@ def test_data_move_line_raw(self): "package_dest": None, "location_src": self._expected_location(move_line.location_id), "location_dest": self._expected_location(move_line.location_dest_id), + "priority": "1", + } + self.assertDictEqual(data, expected) + + def test_data_move_line_with_picking(self): + move_line = self.move_d.move_line_ids + data = self.data.move_line(move_line, with_picking=True) + self.assert_schema(self.schema.move_line(with_picking=True), data) + expected = { + "id": move_line.id, + "qty_done": 0.0, + "quantity": move_line.product_uom_qty, + "product": self._expected_product(self.product_d), + "lot": None, + "package_src": None, + "package_dest": None, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), + "picking": self.data.picking(move_line.picking_id), + "priority": "1", } self.assertDictEqual(data, expected) diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index c3315a5f2d..54f280bbc1 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -181,7 +181,6 @@ def test_data_picking(self): "move_lines": self.data_detail.move_lines(picking.move_line_ids), } self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-05-13") - self.maxDiff = None self.assertDictEqual(data, expected) def test_data_move_line_package(self): @@ -215,6 +214,7 @@ def test_data_move_line_package(self): }, "location_src": self._expected_location(move_line.location_id), "location_dest": self._expected_location(move_line.location_dest_id), + "priority": "1", } self.assertDictEqual(data, expected) @@ -237,11 +237,11 @@ def test_data_move_line_lot(self): "package_dest": None, "location_src": self._expected_location(move_line.location_id), "location_dest": self._expected_location(move_line.location_dest_id), + "priority": "1", } self.assertDictEqual(data, expected) def test_data_move_line_package_lot(self): - self.maxDiff = None move_line = self.move_c.move_line_ids data = self.data_detail.move_line(move_line) self.assert_schema(self.schema_detail.move_line(), data) @@ -272,6 +272,7 @@ def test_data_move_line_package_lot(self): }, "location_src": self._expected_location(move_line.location_id), "location_dest": self._expected_location(move_line.location_dest_id), + "priority": "1", } self.assertDictEqual(data, expected) @@ -290,6 +291,7 @@ def test_data_move_line_raw(self): "package_dest": None, "location_src": self._expected_location(move_line.location_id), "location_dest": self._expected_location(move_line.location_dest_id), + "priority": "1", } self.assertDictEqual(data, expected) diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py index e5d21d8fa3..fe81fbb366 100644 --- a/shopfloor/tests/test_checkout_list_package.py +++ b/shopfloor/tests/test_checkout_list_package.py @@ -18,6 +18,7 @@ def _assert_response_select_dest_package( "weight": 110.0, "move_line_count": len(picking.move_line_ids), "partner": {"id": self.customer.id, "name": self.customer.name}, + "scheduled_date": picking.scheduled_date.isoformat() + "+00:00", }, "packages": [ self._package_data(package, picking) for package in packages diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 1f66fde167..ddd2897411 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -205,7 +205,7 @@ def _assert_response_select_line( data={ "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), - "move_lines": self.data.move_lines(move_lines), + "move_lines": self.data.move_lines(move_lines, with_picking=True), }, message=message, popup=popup, @@ -246,7 +246,7 @@ def _assert_response_set_line_destination( data={ "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), - "move_line": self.data.move_line(move_line), + "move_line": self.data.move_line(move_line, with_picking=True), "confirmation_required": confirmation_required, }, message=message, @@ -306,7 +306,7 @@ def _assert_response_change_pack_lot( data={ "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), - "move_line": self.data.move_line(move_line), + "move_line": self.data.move_line(move_line, with_picking=True), }, message=message, ) @@ -339,7 +339,7 @@ def _assert_response_unload_set_destination( data={ "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), - "move_line": self.data.move_line(move_line), + "move_line": self.data.move_line(move_line, with_picking=True), "confirmation_required": confirmation_required, }, message=message, @@ -380,7 +380,7 @@ def _assert_response_unload_all( data={ "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), - "move_lines": self.data.move_lines(move_lines), + "move_lines": self.data.move_lines(move_lines, with_picking=True), "confirmation_required": confirmation_required, }, message=message, @@ -421,7 +421,7 @@ def _assert_response_unload_single( data={ "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), - "move_line": self.data.move_line(move_line), + "move_line": self.data.move_line(move_line, with_picking=True), }, message=message, popup=popup, diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py index 88568e70fd..bf2c13f50f 100644 --- a/shopfloor/tests/test_zone_picking_set_line_destination.py +++ b/shopfloor/tests/test_zone_picking_set_line_destination.py @@ -122,7 +122,11 @@ def test_set_destination_location_confirm(self): move_lines = self.service._find_location_move_lines(zone_location, picking_type) move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) self.assert_response_select_line( - response, zone_location, picking_type, move_lines, + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.confirm_pack_moved(), ) def test_set_destination_location_no_other_move_line_full_qty(self): @@ -165,7 +169,11 @@ def test_set_destination_location_no_other_move_line_full_qty(self): move_lines = self.service._find_location_move_lines(zone_location, picking_type) move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) self.assert_response_select_line( - response, zone_location, picking_type, move_lines + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.confirm_pack_moved(), ) def test_set_destination_location_no_other_move_line_partial_qty(self): @@ -218,7 +226,11 @@ def test_set_destination_location_no_other_move_line_partial_qty(self): move_lines = self.service._find_location_move_lines(zone_location, picking_type) move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) self.assert_response_select_line( - response, zone_location, picking_type, move_lines + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.confirm_pack_moved(), ) def test_set_destination_location_several_move_line_full_qty(self): @@ -275,7 +287,11 @@ def test_set_destination_location_several_move_line_full_qty(self): move_lines = self.service._find_location_move_lines(zone_location, picking_type) move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) self.assert_response_select_line( - response, zone_location, picking_type, move_lines + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.confirm_pack_moved(), ) def test_set_destination_location_several_move_line_partial_qty(self): @@ -337,7 +353,11 @@ def test_set_destination_location_several_move_line_partial_qty(self): move_lines = self.service._find_location_move_lines(zone_location, picking_type) move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) self.assert_response_select_line( - response, zone_location, picking_type, move_lines + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.confirm_pack_moved(), ) def test_set_destination_location_zero_check(self): @@ -418,7 +438,11 @@ def test_set_destination_package_full_qty(self): move_lines = self.service._find_location_move_lines(zone_location, picking_type) move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) self.assert_response_select_line( - response, zone_location, picking_type, move_lines + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.confirm_pack_moved(), ) def test_set_destination_package_partial_qty(self): @@ -485,7 +509,11 @@ def test_set_destination_package_partial_qty(self): move_lines = self.service._find_location_move_lines(zone_location, picking_type) move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) self.assert_response_select_line( - response, zone_location, picking_type, move_lines + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.confirm_pack_moved(), ) def test_set_destination_package_zero_check(self): From 67c8eb91ebc12e4867726f9d01d6e2644ad25f89 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 10 Aug 2020 11:20:43 +0200 Subject: [PATCH 310/940] checkout: fix packaging field to use --- shopfloor/actions/data.py | 2 +- shopfloor/services/checkout.py | 4 ++-- shopfloor/tests/test_actions_data.py | 6 +++--- shopfloor/tests/test_actions_data_detail.py | 6 +++--- shopfloor/tests/test_checkout_change_packaging.py | 4 ++-- shopfloor/tests/test_checkout_scan_package_action.py | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index dc156516f7..f2d7d1bd76 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -94,7 +94,7 @@ def _package_parser(self): @property def _package_packaging_parser(self): return [ - ("product_packaging_id:packaging", self._packaging_parser), + ("packaging_id:packaging", self._packaging_parser), ] def packaging(self, record, **kw): diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 3f08861d8a..b24e9f7b14 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -625,7 +625,7 @@ def _put_lines_in_allowed_package(self, picking, selected_lines, package): def _prepare_vals_package_from_packaging(self, packaging): return { - "product_packaging_id": packaging.id, + "packaging_id": packaging.id, "lngth": packaging.lngth, "width": packaging.width, "height": packaging.height, @@ -895,7 +895,7 @@ def set_packaging(self, picking_id, package_id, packaging_id): return self._response_for_summary( picking, message=self.msg_store.record_not_found() ) - package.product_packaging_id = packaging + package.packaging_id = packaging return self._response_for_summary( picking, message={ diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 7249d1874e..877883a99a 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -142,7 +142,7 @@ def test_data_lot(self): def test_data_package(self): package = self.move_a.move_line_ids.package_id - package.product_packaging_id = self.packaging.id + package.packaging_id = self.packaging.id package.package_storage_type_id = self.storage_type_pallet data = self.data.package(package, picking=self.picking, with_packaging=True) self.assert_schema(self.schema.package(with_packaging=True), data) @@ -150,7 +150,7 @@ def test_data_package(self): "id": package.id, "name": package.name, "move_line_count": 1, - "packaging": self._expected_packaging(package.product_packaging_id), + "packaging": self._expected_packaging(package.packaging_id), "storage_type": self._expected_storage_type( package.package_storage_type_id ), @@ -222,7 +222,7 @@ def test_data_product(self): def test_data_move_line_package(self): move_line = self.move_a.move_line_ids result_package = self.env["stock.quant.package"].create( - {"product_packaging_id": self.packaging.id} + {"packaging_id": self.packaging.id} ) move_line.write({"qty_done": 3.0, "result_package_id": result_package.id}) data = self.data.move_line(move_line) diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index 54f280bbc1..22821ee0a5 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -125,7 +125,7 @@ def test_data_lot(self): def test_data_package(self): package = self.move_a.move_line_ids.package_id - package.product_packaging_id = self.packaging.id + package.packaging_id = self.packaging.id package.package_storage_type_id = self.storage_type_pallet # package.invalidate_cache() data = self.data_detail.package_detail(package, picking=self.picking) @@ -139,7 +139,7 @@ def test_data_package(self): "id": package.id, "name": package.name, "move_line_count": 1, - "packaging": self.data_detail.packaging(package.product_packaging_id), + "packaging": self.data_detail.packaging(package.packaging_id), "weight": 0, "pickings": self.data_detail.pickings(pickings), "move_lines": self.data_detail.move_lines(lines), @@ -186,7 +186,7 @@ def test_data_picking(self): def test_data_move_line_package(self): move_line = self.move_a.move_line_ids result_package = self.env["stock.quant.package"].create( - {"product_packaging_id": self.packaging.id} + {"packaging_id": self.packaging.id} ) move_line.write({"qty_done": 3.0, "result_package_id": result_package.id}) data = self.data_detail.move_line(move_line) diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py index 0fc93b7674..5f79c6ec40 100644 --- a/shopfloor/tests/test_checkout_change_packaging.py +++ b/shopfloor/tests/test_checkout_change_packaging.py @@ -51,7 +51,7 @@ def setUpClassBaseData(cls): cls._fill_stock_for_moves(cls.picking.move_lines, in_package=True) cls.picking.action_assign() cls.package = cls.picking.move_line_ids.result_package_id - cls.package.product_packaging_id = cls.packaging_pallet + cls.package.packaging_id = cls.packaging_pallet def test_list_packaging_ok(self): response = self.service.dispatch( @@ -101,7 +101,7 @@ def test_set_packaging_ok(self): }, ) self.assertRecordValues( - self.package, [{"product_packaging_id": self.packaging_inner_box.id}] + self.package, [{"packaging_id": self.packaging_inner_box.id}] ) self.assert_response( response, diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py index c708ec47ea..26ab339947 100644 --- a/shopfloor/tests/test_checkout_scan_package_action.py +++ b/shopfloor/tests/test_checkout_scan_package_action.py @@ -325,7 +325,7 @@ def test_scan_package_action_scan_packaging_ok(self): new_package, [ { - "product_packaging_id": packaging.id, + "packaging_id": packaging.id, "lngth": packaging.lngth, "width": packaging.width, "height": packaging.height, From e263cfbc2d70f40cb8bb118a59de995d9e8921a6 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 10 Aug 2020 12:44:24 +0200 Subject: [PATCH 311/940] zone picking: fix test about picking scheduled_date --- shopfloor/tests/test_actions_data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 877883a99a..efa988a2b1 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -35,6 +35,7 @@ def setUpClassBaseData(cls): (cls.product_d, 10), ] ) + cls.picking.scheduled_date = "2020-08-03" # put product A in a package cls.move_a = cls.picking.move_lines[0] cls._fill_stock_for_moves(cls.move_a, in_package=True) From 57e2a7629d3595fe6592cb564be36a1ebc4c8882 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 13 Aug 2020 09:43:46 +0200 Subject: [PATCH 312/940] bknd: single_pack_transfer remove confirm states --- shopfloor/services/single_pack_transfer.py | 72 ++++++++++---------- shopfloor/tests/test_single_pack_transfer.py | 20 ++++-- 2 files changed, 52 insertions(+), 40 deletions(-) diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 2306432b18..0982093dc0 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -27,30 +27,21 @@ def _response_for_start(self, message=None, popup=None): return self._response(next_state="start", message=message, popup=popup) def _response_for_confirm_start(self, package_level, message=None): - return self._response( - next_state="confirm_start", - data=self._data_after_package_scanned(package_level), - message=message, - ) - - def _response_for_scan_location(self, package_level, message=None): - return self._response( - next_state="scan_location", - data=self._data_after_package_scanned(package_level), - message=message, - ) + data = self._data_after_package_scanned(package_level) + data["confirmation_required"] = True + return self._response(next_state="start", data=data, message=message,) - def _response_for_confirm_location(self, package_level, message=None): - return self._response( - next_state="confirm_location", - data=self._data_after_package_scanned(package_level), - message=message, - ) + def _response_for_scan_location( + self, package_level, message=None, confirmation_required=False + ): + data = self._data_after_package_scanned(package_level) + data["confirmation_required"] = confirmation_required + return self._response(next_state="scan_location", data=data, message=message,) def _response_for_show_completion_info(self, message=None): return self._response(next_state="show_completion_info", message=message) - def start(self, barcode): + def start(self, barcode, confirmation=False): search = self.actions_for("search") picking_types = self.picking_types location = search.location_from_scan(barcode) @@ -107,8 +98,7 @@ def start(self, barcode): return self._response_for_start( message=self.msg_store.no_pending_operation_for_pack(package) ) - - if package_level.is_done: + if package_level.is_done and not confirmation: return self._response_for_confirm_start( package_level, message=self.msg_store.already_running_ask_confirmation() ) @@ -188,8 +178,9 @@ def validate(self, package_level_id, location_barcode, confirmation=False): if not scanned_location.is_sublocation_of(move.location_dest_id): move.location_dest_id = scanned_location.id else: - return self._response_for_confirm_location( + return self._response_for_scan_location( package_level, + confirmation_required=True, message=self.msg_store.confirm_location_changed( move_line.location_dest_id, scanned_location ), @@ -241,7 +232,10 @@ class SinglePackTransferValidator(Component): _usage = "single_pack_transfer.validator" def start(self): - return {"barcode": {"type": "string", "nullable": False, "required": True}} + return { + "barcode": {"type": "string", "nullable": False, "required": True}, + "confirmation": {"type": "boolean", "required": False}, + } def cancel(self): return { @@ -269,32 +263,40 @@ def _states(self): With the schema of the data send to the client to transition to the next state. """ + schema_for_start = self._schema_for_package_level_details() + schema_for_start.update(self._schema_confirmation_required()) + schema_for_scan_location = self._schema_for_package_level_details(required=True) + schema_for_scan_location.update(self._schema_confirmation_required()) return { - "start": {}, - "confirm_start": self._schema_for_package_level_details, - "scan_location": self._schema_for_package_level_details, - "confirm_location": self._schema_for_package_level_details, + "start": schema_for_start, + "scan_location": schema_for_scan_location, } def start(self): - return self._response_schema(next_states={"confirm_start", "scan_location"}) + return self._response_schema(next_states={"start", "scan_location"}) def cancel(self): return self._response_schema(next_states={"start"}) def validate(self): - return self._response_schema( - next_states={"scan_location", "start", "confirm_location"} - ) + return self._response_schema(next_states={"scan_location", "start"}) - @property - def _schema_for_package_level_details(self): + def _schema_for_package_level_details(self, required=False): # TODO use schemas.package_level (but the "name" moves in "package.name") return { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, + "id": {"required": required, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": required}, "location_src": {"type": "dict", "schema": self.schemas.location()}, "location_dest": {"type": "dict", "schema": self.schemas.location()}, "product": {"type": "dict", "schema": self.schemas.product()}, "picking": {"type": "dict", "schema": self.schemas.picking()}, } + + def _schema_confirmation_required(self): + return { + "confirmation_required": { + "type": "boolean", + "nullable": True, + "required": False, + }, + } diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index aec7b9ca9a..c98b8870cf 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -109,7 +109,10 @@ def test_start(self): self.assert_response( response, next_state="scan_location", - data=self._response_package_level_data(package_level), + data=dict( + self._response_package_level_data(package_level), + confirmation_required=False, + ), ) def test_start_no_operation(self): @@ -182,6 +185,7 @@ def test_start_no_operation_create(self): ), "picking": self.data.picking(package_level.picking_id), "product": self.data.product(self.product_a), + "confirmation_required": False, } self.assert_response( @@ -363,13 +367,16 @@ def test_start_already_started(self): self.assert_response( response, - next_state="confirm_start", + next_state="start", message={ "message_type": "warning", "body": "Operation's already running." " Would you like to take it over?", }, - data=self._response_package_level_data(package_level), + data=dict( + self._response_package_level_data(package_level), + confirmation_required=True, + ), ) def test_validate(self): @@ -601,9 +608,12 @@ def test_validate_location_to_confirm(self): ) self.assert_response( response, - next_state="confirm_location", + next_state="scan_location", message=message, - data=self._response_package_level_data(package_level), + data=dict( + self._response_package_level_data(package_level), + confirmation_required=True, + ), ) def test_validate_location_with_confirm(self): From f425f36dc32dbe90fe35212ba6b585da9e2c6e66 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 13 Aug 2020 15:00:39 +0200 Subject: [PATCH 313/940] single_pack_transfer: prevent usage w/out 'move entire packs' single_pack_transfer ATM works only with package levels. If you use it w/ picking types that do not move entire packages you'll get bad results. The long term is to do it as in location_content_transfer which works w/ both move lines and pkg levels. --- shopfloor/models/shopfloor_menu.py | 26 ++++++++++++++++++++++++++ shopfloor/models/stock_picking_type.py | 9 ++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index f0385f5717..dd3f3de93d 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -64,3 +64,29 @@ def _check_allow_move_create(self): raise exceptions.ValidationError( _("Creation of moves is not allowed for menu {}.").format(menu.name) ) + + # ATM the goal is to block using single_pack_transfer (SPT) + # w/out moving the full pkg. + # Is not optimal, but is mandatory as long as SPT does not work w/ moves + # but only w/ package levels. + # TODO: add tests. + _move_entire_packs_scenario = "single_pack_transfer" + + @api.constrains("scenario", "picking_type_ids") + def _check_move_entire_packages(self): + _get_scenario_name = self._fields["scenario"].convert_to_export + for menu in self: + # TODO: these kind of checks should be provided by the scenario itself. + bad_picking_types = [ + x.name for x in menu.picking_type_ids if not x.show_entire_packs + ] + if menu.scenario in self._move_entire_packs_scenario and bad_picking_types: + scenario_name = _get_scenario_name(menu["scenario"], menu) + raise exceptions.ValidationError( + _( + "Scenario `{}` require(s) " + "'Move Entire Packages' to be enabled.\n" + "These type(s) do not satisfy this constraint: \n{}.\n" + "Please, adjust your configuration." + ).format(scenario_name, "\n- ".join(bad_picking_types)) + ) diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py index cbe326fc8d..042ef4650c 100644 --- a/shopfloor/models/stock_picking_type.py +++ b/shopfloor/models/stock_picking_type.py @@ -1,4 +1,4 @@ -from odoo import fields, models +from odoo import api, fields, models class StockPickingType(models.Model): @@ -13,3 +13,10 @@ class StockPickingType(models.Model): " Discrete order Picking), the zero check step will be activated when" " a location becomes empty after a move.", ) + + @api.constrains("show_entire_packs") + def _check_move_entire_packages(self): + menu_items = self.env["shopfloor.menu"].search( + [("picking_type_ids", "in", self.ids)] + ) + menu_items._check_move_entire_packages() From af0c2ab0297f450e2bbbda5da75e341dbc41323d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 13 Aug 2020 15:03:50 +0200 Subject: [PATCH 314/940] bknd: zone_picking fix complete buffer msg --- shopfloor/actions/message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 91bbd908d4..4f46fb111c 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -427,7 +427,7 @@ def lot_is_not_a_package(self, lot): def buffer_complete(self): return { "message_type": "success", - "body": _("Scanned destination packages processed."), + "body": _("All packages processed."), } def picking_type_complete(self, picking_type): From 0b3f3cd914235215a71c9b1280ae697139cef2a7 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 13 Aug 2020 15:50:42 +0200 Subject: [PATCH 315/940] bknd: fix single_pack_transfer take over Writing on pkg_level.is_done w/out checking if already set is going to sum up the whole qty as done each time. Hence a pkg level w/ 100 units done, if scanned 5 times, will have 500 done. --- shopfloor/services/single_pack_transfer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 0982093dc0..91a098355f 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -102,8 +102,8 @@ def start(self, barcode, confirmation=False): return self._response_for_confirm_start( package_level, message=self.msg_store.already_running_ask_confirmation() ) - - package_level.is_done = True + if not package_level.is_done: + package_level.is_done = True return self._response_for_scan_location(package_level) def _create_package_level(self, package): From 3d50080db3c87d465b864f34bfaff333f5d20475 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 13 Aug 2020 16:53:20 +0200 Subject: [PATCH 316/940] bknd: fix actions data tests --- shopfloor/tests/test_actions_data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index efa988a2b1..0e9d12532e 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -48,6 +48,7 @@ def setUpClassBaseData(cls): # product D is raw cls.move_d = cls.picking.move_lines[3] cls._fill_stock_for_moves(cls.move_d) + (cls.move_a + cls.move_b + cls.move_c + cls.move_d).write({"priority": "1"}) cls.picking.action_assign() def assert_schema(self, schema, data): From bda162888fe25e2c6940f56875cfd39b67d0e06e Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Thu, 13 Aug 2020 10:15:22 +0200 Subject: [PATCH 317/940] Add packing information by customer Add a packing information field on partner that will be displayed on the stock.picking form when the picking type requires it --- shopfloor/__manifest__.py | 2 ++ shopfloor/models/__init__.py | 1 + shopfloor/models/res_partner.py | 7 +++++ shopfloor/models/stock_picking.py | 8 ++++++ shopfloor/models/stock_picking_type.py | 5 ++++ shopfloor/views/res_partner.xml | 15 +++++++++++ shopfloor/views/stock_picking_type.xml | 4 +++ shopfloor/views/stock_picking_views.xml | 34 +++++++++++++++++++++++++ 8 files changed, 76 insertions(+) create mode 100644 shopfloor/models/res_partner.py create mode 100644 shopfloor/views/res_partner.xml create mode 100644 shopfloor/views/stock_picking_views.xml diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 2d0d521699..7f89d4184a 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -40,10 +40,12 @@ "data/ir_config_parameter_data.xml", "data/ir_cron_data.xml", "security/ir.model.access.csv", + "views/res_partner.xml", "views/shopfloor_menu.xml", "views/stock_picking_type.xml", "views/stock_location.xml", "views/stock_move_line.xml", + "views/stock_picking_views.xml", "views/shopfloor_profile_views.xml", "views/shopfloor_log_views.xml", "views/menus.xml", diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index 05a95579d3..cb9e4bf257 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -1,3 +1,4 @@ +from . import res_partner from . import shopfloor_menu from . import shopfloor_log from . import stock_picking_type diff --git a/shopfloor/models/res_partner.py b/shopfloor/models/res_partner.py new file mode 100644 index 0000000000..b9a8986af0 --- /dev/null +++ b/shopfloor/models/res_partner.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + shopfloor_packing_info = fields.Text(string="Checkout Packing Information") diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py index cdcd93cd08..3549f226ad 100644 --- a/shopfloor/models/stock_picking.py +++ b/shopfloor/models/stock_picking.py @@ -12,6 +12,14 @@ class StockPicking(models.Model): compute="_compute_picking_info", help="Technical field. Indicates number of move lines included.", ) + shopfloor_display_packing_info = fields.Boolean( + related="picking_type_id.shopfloor_display_packing_info", readonly=True, + ) + shopfloor_packing_info = fields.Text( + string="Packing information", + related="partner_id.shopfloor_packing_info", + readonly=True, + ) @api.depends( "move_line_ids", "move_line_ids.product_qty", "move_line_ids.product_id.weight" diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py index 042ef4650c..600fa3e58b 100644 --- a/shopfloor/models/stock_picking_type.py +++ b/shopfloor/models/stock_picking_type.py @@ -13,6 +13,11 @@ class StockPickingType(models.Model): " Discrete order Picking), the zero check step will be activated when" " a location becomes empty after a move.", ) + shopfloor_display_packing_info = fields.Boolean( + string="Display customer packing info", + help="For the Shopfloor Checkout/Packing scenarios to display the" + " customer packing info.", + ) @api.constrains("show_entire_packs") def _check_move_entire_packages(self): diff --git a/shopfloor/views/res_partner.xml b/shopfloor/views/res_partner.xml new file mode 100644 index 0000000000..3152277af6 --- /dev/null +++ b/shopfloor/views/res_partner.xml @@ -0,0 +1,15 @@ + + + + partner.shopfloor.form + res.partner + + + + + + + + + + diff --git a/shopfloor/views/stock_picking_type.xml b/shopfloor/views/stock_picking_type.xml index ad90a59d5b..81b809c27a 100644 --- a/shopfloor/views/stock_picking_type.xml +++ b/shopfloor/views/stock_picking_type.xml @@ -12,6 +12,10 @@ name="shopfloor_zero_check" attrs="{'invisible': [('shopfloor_menu_ids', '=', [])]}" /> + diff --git a/shopfloor/views/stock_picking_views.xml b/shopfloor/views/stock_picking_views.xml new file mode 100644 index 0000000000..308071f266 --- /dev/null +++ b/shopfloor/views/stock_picking_views.xml @@ -0,0 +1,34 @@ + + + + stock.picking.shopfloor.form + stock.picking + + + + + + + + + + + + + + + + + From 18bd820f1f249d0b546871ba5bb539cdca867088 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 13 Aug 2020 17:44:04 +0200 Subject: [PATCH 318/940] bknd: include packing info in checkout/select_package --- shopfloor/models/stock_picking.py | 6 ++--- shopfloor/services/checkout.py | 11 ++++++++- shopfloor/tests/test_checkout_scan_line.py | 20 ++++++++++++++-- .../test_checkout_select_package_base.py | 24 +++++++++++++++---- 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py index 3549f226ad..a47b4d87ec 100644 --- a/shopfloor/models/stock_picking.py +++ b/shopfloor/models/stock_picking.py @@ -13,12 +13,10 @@ class StockPicking(models.Model): help="Technical field. Indicates number of move lines included.", ) shopfloor_display_packing_info = fields.Boolean( - related="picking_type_id.shopfloor_display_packing_info", readonly=True, + related="picking_type_id.shopfloor_display_packing_info", ) shopfloor_packing_info = fields.Text( - string="Packing information", - related="partner_id.shopfloor_packing_info", - readonly=True, + string="Packing information", related="partner_id.shopfloor_packing_info", ) @api.depends( diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index b24e9f7b14..edfdda46ab 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -71,10 +71,16 @@ def _response_for_select_package(self, lines, message=None): data={ "selected_move_lines": self._data_for_move_lines(lines.sorted()), "picking": self.data.picking(picking), + "packing_info": self._data_for_packing_info(picking), }, message=message, ) + def _data_for_packing_info(self, picking): + if picking.picking_type_id.shopfloor_display_packing_info: + return picking.shopfloor_packing_info or "" + return "" + def _response_for_select_dest_package(self, picking, move_lines, message=None): packages = picking.mapped("move_line_ids.package_id") | picking.mapped( "move_line_ids.result_package_id" @@ -1181,7 +1187,10 @@ def _states(self): "select_document": {}, "manual_selection": self._schema_selection_list, "select_line": self._schema_stock_picking_details, - "select_package": self._schema_selected_lines, + "select_package": dict( + self._schema_selected_lines, + packing_info={"type": "string", "nullable": True}, + ), "change_quantity": self._schema_selected_lines, "select_dest_package": self._schema_select_package, "summary": self._schema_summary, diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py index e0063f515d..94b4b5d890 100644 --- a/shopfloor/tests/test_checkout_scan_line.py +++ b/shopfloor/tests/test_checkout_scan_line.py @@ -3,7 +3,7 @@ class CheckoutScanLineCase(CheckoutCommonCase, CheckoutSelectPackageMixin): - def _test_scan_line_ok(self, barcode, selected_lines): + def _test_scan_line_ok(self, barcode, selected_lines, packing_info=False): """Test /scan_line with a valid return :param barcode: the barcode we scan @@ -13,7 +13,7 @@ def _test_scan_line_ok(self, barcode, selected_lines): response = self.service.dispatch( "scan_line", params={"picking_id": picking.id, "barcode": barcode} ) - self._assert_selected(response, selected_lines) + self._assert_selected(response, selected_lines, packing_info=packing_info) def test_scan_line_package_ok(self): picking = self._create_picking( @@ -29,6 +29,22 @@ def test_scan_line_package_ok(self): move_line = move1.move_line_ids self._test_scan_line_ok(move_line.package_id.name, move_line) + def test_scan_line_package_ok_packing_info(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10)] + ) + picking.sudo().partner_id.shopfloor_packing_info = "Please do it like this!" + picking.sudo().picking_type_id.shopfloor_display_packing_info = True + move1 = picking.move_lines[0] + move2 = picking.move_lines[1] + # put the lines in 2 separate packages (only the first line should be selected + # by the package barcode) + self._fill_stock_for_moves(move1, in_package=True) + self._fill_stock_for_moves(move2, in_package=True) + picking.action_assign() + move_line = move1.move_line_ids + self._test_scan_line_ok(move_line.package_id.name, move_line, packing_info=True) + def test_scan_line_package_several_lines_ok(self): picking = self._create_picking( lines=[(self.product_a, 10), (self.product_b, 10)] diff --git a/shopfloor/tests/test_checkout_select_package_base.py b/shopfloor/tests/test_checkout_select_package_base.py index e82ac9f9af..93c7670485 100644 --- a/shopfloor/tests/test_checkout_select_package_base.py +++ b/shopfloor/tests/test_checkout_select_package_base.py @@ -1,5 +1,7 @@ class CheckoutSelectPackageMixin: - def _assert_selected_response(self, response, selected_lines, message=None): + def _assert_selected_response( + self, response, selected_lines, message=None, packing_info=False + ): picking = selected_lines.mapped("picking_id") self.assert_response( response, @@ -9,12 +11,18 @@ def _assert_selected_response(self, response, selected_lines, message=None): self._move_line_data(ml) for ml in selected_lines ], "picking": self._picking_summary_data(picking), + "packing_info": picking.shopfloor_packing_info if packing_info else "", }, message=message, ) def _assert_selected_qties( - self, response, selected_lines, lines_quantities, message=None + self, + response, + selected_lines, + lines_quantities, + message=None, + packing_info=False, ): picking = selected_lines.mapped("picking_id") deselected_lines = picking.move_line_ids - selected_lines @@ -23,9 +31,13 @@ def _assert_selected_qties( self.assertEqual(line.qty_done, quantity) for line in deselected_lines: self.assertEqual(line.qty_done, 0, "Lines deselected must have no qty done") - self._assert_selected_response(response, selected_lines, message=message) + self._assert_selected_response( + response, selected_lines, message=message, packing_info=packing_info + ) - def _assert_selected(self, response, selected_lines, message=None): + def _assert_selected( + self, response, selected_lines, message=None, packing_info=False + ): picking = selected_lines.mapped("picking_id") unselected_lines = picking.move_line_ids - selected_lines for line in selected_lines: @@ -36,4 +48,6 @@ def _assert_selected(self, response, selected_lines, message=None): ) for line in unselected_lines: self.assertEqual(line.qty_done, 0) - self._assert_selected_response(response, selected_lines, message=message) + self._assert_selected_response( + response, selected_lines, message=message, packing_info=packing_info + ) From dc72c91407b4c6b47a043a9026ffa68f2515d358 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 17 Aug 2020 09:31:10 +0200 Subject: [PATCH 319/940] search action: do not return location with no barcode defined --- shopfloor/actions/search.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py index 9b2beca215..a757257583 100644 --- a/shopfloor/actions/search.py +++ b/shopfloor/actions/search.py @@ -13,6 +13,8 @@ class SearchAction(Component): _usage = "search" def location_from_scan(self, barcode): + if not barcode: + return self.env["stock.location"].browse() return self.env["stock.location"].search([("barcode", "=", barcode)]) def package_from_scan(self, barcode): From f6154ca620c9c3d45a70b962f516a1cabe0ecfb2 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 17 Aug 2020 09:35:28 +0200 Subject: [PATCH 320/940] zone picking: fix sort filter move lines Unable to compare `False` and strings. --- shopfloor/services/zone_picking.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index a2eadd1a92..81292dd388 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -248,11 +248,11 @@ def _find_location_move_lines( def _sort_key_move_lines(order): """Return a `(sort_keys_func, reverse)` tuple for move lines.""" if order == "priority": - return lambda line: line.move_id.priority, True + return lambda line: line.move_id.priority or "", True elif order == "location": return ( lambda line: ( - line.location_id.shopfloor_picking_sequence, + line.location_id.shopfloor_picking_sequence or "", line.location_id.name, ), False, From 3544a2e7901ddace9ab4f2afbf6d17735486806d Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 17 Aug 2020 11:16:18 +0200 Subject: [PATCH 321/940] location transfer: add a test to reproduce an issue with several operators If several operators are working on the same source location, conflicts with actually processed move lines appears. --- shopfloor/tests/__init__.py | 1 + .../test_location_content_transfer_mix.py | 179 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 shopfloor/tests/test_location_content_transfer_mix.py diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 8f64051db9..b17bbc0db0 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -38,6 +38,7 @@ from . import test_location_content_transfer_set_destination_all from . import test_location_content_transfer_single from . import test_location_content_transfer_set_destination_package_or_line +from . import test_location_content_transfer_mix from . import test_zone_picking_base from . import test_zone_picking_start from . import test_zone_picking_select_picking_type diff --git a/shopfloor/tests/test_location_content_transfer_mix.py b/shopfloor/tests/test_location_content_transfer_mix.py new file mode 100644 index 0000000000..176e6d94ea --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_mix.py @@ -0,0 +1,179 @@ +from .test_location_content_transfer_base import LocationContentTransferCommonCase + + +class LocationContentTransferMixCase(LocationContentTransferCommonCase): + """Tests where we mix location content transfer with other scenarios.""" + + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.zp_menu = cls.env.ref("shopfloor.shopfloor_menu_zone_picking") + cls.wh.sudo().delivery_steps = "pick_pack_ship" + cls.pack_location = cls.wh.wh_pack_stock_loc_id + cls.ship_location = cls.wh.wh_output_stock_loc_id + # Allows location content transfer to process PACK picking type + cls.menu.sudo().picking_type_ids = cls.wh.pack_type_id + cls.wh.pack_type_id.sudo().default_location_dest_id = cls.env.ref( + "stock.stock_location_output" + ) + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.packing_location.sudo().active = True + products = cls.product_a + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + # Put product_a quantities in different packages to get + # two stock move lines (6 and 4 to satisfy 10 qties) + cls.package_1 = cls.env["stock.quant.package"].create({"name": "PACKAGE_1"}) + cls.package_2 = cls.env["stock.quant.package"].create({"name": "PACKAGE_2"}) + cls.package_3 = cls.env["stock.quant.package"].create({"name": "PACKAGE_3"}) + cls._update_qty_in_location( + cls.stock_location, cls.product_a, 6, package=cls.package_1 + ) + cls._update_qty_in_location( + cls.stock_location, cls.product_a, 4, package=cls.package_2 + ) + cls._update_qty_in_location( + cls.stock_location, cls.product_a, 5, package=cls.package_3 + ) + # Create the pick/pack/ship transfers + cls.ship_move_a = cls.env["stock.move"].create( + { + "name": cls.product_a.display_name, + "product_id": cls.product_a.id, + "product_uom_qty": 15.0, + "product_uom": cls.product_a.uom_id.id, + "location_id": cls.ship_location.id, + "location_dest_id": cls.customer_location.id, + "warehouse_id": cls.wh.id, + "picking_type_id": cls.wh.out_type_id.id, + "procure_method": "make_to_order", + "state": "draft", + } + ) + cls.ship_move_a._assign_picking() + cls.ship_move_a._action_confirm() + cls.pack_move_a = cls.ship_move_a.move_orig_ids[0] + cls.pick_move_a = cls.pack_move_a.move_orig_ids[0] + cls.picking1 = cls.pick_move_a.picking_id + cls.picking1.action_assign() + + def setUp(self): + super().setUp() + with self.work_on_services(menu=self.zp_menu, profile=self.profile) as work: + self.zp_service = work.component(usage="zone_picking") + + def _zone_picking_process_line(self, move_line): + picking = move_line.picking_id + zone_location = picking.location_id + picking_type = picking.picking_type_id + move_lines = picking.move_line_ids.filtered( + lambda m: m.state not in ("cancel", "done") + ) + # Select the picking type + response = self.zp_service.scan_location(barcode=zone_location.barcode) + available_picking_type_ids = [ + r["id"] for r in response["data"]["select_picking_type"]["picking_types"] + ] + assert picking_type.id in available_picking_type_ids + assert "message" not in response + # Check the move lines related to the picking type + response = self.zp_service.list_move_lines( + zone_location_id=zone_location.id, + picking_type_id=picking_type.id, + order="priority", + ) + available_move_line_ids = [ + r["id"] for r in response["data"]["select_line"]["move_lines"] + ] + assert not set(move_lines.ids) - set(available_move_line_ids) + assert "message" not in response + # Set the destination on the move line + qty = move_line.product_uom_qty + response = self.zp_service.set_destination( + zone_location.id, + picking_type.id, + move_line.id, + self.packing_location.barcode, + qty, + ) + assert response["message"]["message_type"] == "success" + self.assertEqual(move_line.state, "done") + self.assertEqual(move_line.move_id.product_uom_qty, qty) + + def _location_content_transfer_process_line(self, move_line, set_destination=False): + pack_location = move_line.location_id + out_location = move_line.location_dest_id + # Scan the location + response = self.service.scan_location(pack_location.barcode) + assert response["next_state"] == "scan_destination_all" + # Set the destination + if set_destination: + qty = move_line.product_uom_qty + response = self.service.set_destination_all( + pack_location.id, out_location.barcode + ) + assert response["message"]["message_type"] == "success" + self.assertEqual(move_line.state, "done") + self.assertEqual(move_line.move_id.product_uom_qty, qty) + + def test_with_zone_picking(self): + """Test the following scenario: + + 1) Operator-1 processes the first pallet with the "zone picking" scenario: + + move1 PICK -> PACK 'done' + + 2) Operator-2 with the "location content transfer" scenario scan + the location where this first pallet is (so the move line is still not + done, the operator is currently moving the goods to the destination location): + + move1 PACK -> SHIP 'assigned' while the operator is moving it + + 3) Operator-1 process the second pallet with the "zone picking" scenario: + + move2 PICK -> PACK 'done' + + 4) Operator-3 with the "location content transfer" scenario scan + the location where this second pallet is, Odoo should return only this + second pallet as the first one, even if not fully processed (done) + is not physically available in the scanned location. + + move2 PACK -> SHIP 'assigned' is proposed to the operator + move1 PACK -> SHIP while still 'assigned' is not proposed to the operator + """ + picking = self.picking1 + move_lines = picking.move_line_ids + pick_move_line1 = move_lines[0] + pick_move_line2 = move_lines[1] + # Operator-1 process the first pallet with the "zone picking" scenario + self._zone_picking_process_line(pick_move_line1) + # Operator-2 with the "location content transfer" scenario scan + # the location where this first pallet is (so the move line is still not + # done, the operator is currently moving the goods to the destination location) + pack_move_line1 = pick_move_line1.move_id.move_dest_ids.filtered( + lambda m: m.state not in ("cancel", "done") + ).move_line_ids.filtered(lambda l: not l.shopfloor_user_id) + self._location_content_transfer_process_line(pack_move_line1) + # Operator-1 process the second pallet with the "zone picking" scenario + self._zone_picking_process_line(pick_move_line2) + # Operator-3 with the "location content transfer" scenario scan + # the location where this second pallet is + pack_move_line2 = pick_move_line2.move_id.move_dest_ids.filtered( + lambda m: m.state not in ("cancel", "done") + ).move_line_ids.filtered(lambda l: not l.shopfloor_user_id) + assert ( + len(pack_move_line2) == 1 + ), "Operator-3 should end up with one move line taken from {}".format( + pack_move_line2.picking_id.name + ) + self._location_content_transfer_process_line(pack_move_line2) From ff66d245b05a86899a256c173cd0df685de9dafd Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 17 Aug 2020 11:21:40 +0200 Subject: [PATCH 322/940] location transfer: fix issue while several operators are working from the same source location --- shopfloor/services/location_content_transfer.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 02cfe4683d..5ebb6d7c93 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -179,6 +179,7 @@ def _find_location_move_lines_domain(self, location): ("location_id", "=", location.id), ("qty_done", "=", 0), ("state", "in", ("assigned", "partially_available")), + ("shopfloor_user_id", "=", False), ] def _find_location_move_lines(self, location): @@ -228,6 +229,10 @@ def scan_location(self, barcode): When move lines and package levels have different destinations, the first line without package level or package level is sent to the client. + The selected move lines to process are bound to the current operator, + this will allow another operator to find unprocessed lines in parallel + and not overlap with current ones. + Transitions: * start: location not found, ... * scan_destination_all: if the destination of all the lines and package @@ -291,6 +296,7 @@ def scan_location(self, barcode): for line in move_lines: line.qty_done = line.product_uom_qty + line.shopfloor_user_id = self.env.uid pickings.user_id = self.env.uid From 73fbbf03c3f4c17e5667a84f38bdd21b5cfce265 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 17 Aug 2020 15:10:07 +0200 Subject: [PATCH 323/940] location transfer: add a test to reproduce an issue if move lines have different src locations --- .../test_location_content_transfer_mix.py | 80 +++++++++++++++---- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/shopfloor/tests/test_location_content_transfer_mix.py b/shopfloor/tests/test_location_content_transfer_mix.py index 176e6d94ea..6ab2dd709a 100644 --- a/shopfloor/tests/test_location_content_transfer_mix.py +++ b/shopfloor/tests/test_location_content_transfer_mix.py @@ -72,7 +72,7 @@ def setUp(self): with self.work_on_services(menu=self.zp_menu, profile=self.profile) as work: self.zp_service = work.component(usage="zone_picking") - def _zone_picking_process_line(self, move_line): + def _zone_picking_process_line(self, move_line, dest_location=None): picking = move_line.picking_id zone_location = picking.location_id picking_type = picking.picking_type_id @@ -98,13 +98,11 @@ def _zone_picking_process_line(self, move_line): assert not set(move_lines.ids) - set(available_move_line_ids) assert "message" not in response # Set the destination on the move line + if not dest_location: + dest_location = move_line.location_dest_id qty = move_line.product_uom_qty response = self.zp_service.set_destination( - zone_location.id, - picking_type.id, - move_line.id, - self.packing_location.barcode, - qty, + zone_location.id, picking_type.id, move_line.id, dest_location.barcode, qty, ) assert response["message"]["message_type"] == "success" self.assertEqual(move_line.state, "done") @@ -115,18 +113,30 @@ def _location_content_transfer_process_line(self, move_line, set_destination=Fal out_location = move_line.location_dest_id # Scan the location response = self.service.scan_location(pack_location.barcode) - assert response["next_state"] == "scan_destination_all" + assert response["next_state"] in ("scan_destination_all", "start_single") # Set the destination if set_destination: qty = move_line.product_uom_qty - response = self.service.set_destination_all( - pack_location.id, out_location.barcode - ) - assert response["message"]["message_type"] == "success" - self.assertEqual(move_line.state, "done") - self.assertEqual(move_line.move_id.product_uom_qty, qty) - - def test_with_zone_picking(self): + if response["next_state"] == "scan_destination_all": + response = self.service.set_destination_all( + pack_location.id, out_location.barcode + ) + assert response["message"]["message_type"] == "success" + self.assertEqual(move_line.state, "done") + self.assertEqual(move_line.move_id.product_uom_qty, qty) + elif response["next_state"] == "start_single": + response = self.service.scan_line( + pack_location.id, move_line.id, move_line.product_id.barcode + ) + assert response["message"]["message_type"] == "success" + response = self.service.set_destination_line( + pack_location.id, move_line.id, qty, out_location.barcode + ) + assert response["message"]["message_type"] == "success" + assert move_line.state == "done" + assert move_line.qty_done == qty + + def test_with_zone_picking1(self): """Test the following scenario: 1) Operator-1 processes the first pallet with the "zone picking" scenario: @@ -177,3 +187,43 @@ def test_with_zone_picking(self): pack_move_line2.picking_id.name ) self._location_content_transfer_process_line(pack_move_line2) + + def test_with_zone_picking2(self): + """Test the following scenario: + + 1) Operator-1 processes the first pallet with the "zone picking" scenario + to move the goods to PACK-1: + + move1 PICK -> PACK-1 'done' + + 2) Operator-1 processes the second pallet with the "zone picking" scenario + to move the goods to PACK-2: + + move1 PICK -> PACK-2 'done' + + 3) Operator-2 with the "location content transfer" scenario scan + the location where the first pallet is and has to found it: + + move1 PACK-1 -> SHIP + """ + picking = self.picking1 + move_lines = picking.move_line_ids + pick_move_line1 = move_lines[0] + pick_move_line2 = move_lines[1] + # Operator-1 process the first pallet with the "zone picking" scenario + self._zone_picking_process_line(pick_move_line1) + # Operator-1 process the second pallet with the "zone picking" scenario + dest_location = pick_move_line2.location_dest_id.sudo().copy( + { + "name": pick_move_line2.location_dest_id.name + "_2", + "barcode": pick_move_line2.location_dest_id.barcode + "_2", + "location_id": pick_move_line2.location_dest_id.id, + } + ) + self._zone_picking_process_line(pick_move_line2, dest_location=dest_location) + # Operator-3 with the "location content transfer" scenario scan + # the location where the first pallet is + pack_move_line2 = pick_move_line2.move_id.move_dest_ids.filtered( + lambda m: m.state not in ("cancel", "done") + ).move_line_ids.filtered(lambda l: not l.shopfloor_user_id)[0] + self._location_content_transfer_process_line(pack_move_line2) From a93c5bf0abfe5f3ab8e752f39f017d1c0df19c84 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 17 Aug 2020 15:20:36 +0200 Subject: [PATCH 324/940] location transfer: fix issue if move lines have different src locations --- shopfloor/services/location_content_transfer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 5ebb6d7c93..a0e9845899 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -139,7 +139,9 @@ def _next_content(self, pickings): return next_content def _router_single_or_all_destination(self, pickings, message=None): - if len(pickings.mapped("move_line_ids.location_dest_id")) == 1: + location_dest = pickings.mapped("move_line_ids.location_dest_id") + location_src = pickings.mapped("move_line_ids.location_id") + if len(location_dest) == len(location_src) == 1: return self._response_for_scan_destination_all(pickings, message=message) else: return self._response_for_start_single(pickings, message=message) From 3bce0e4d5be713369943cbb8fa68d1cb431a87ab Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 19 Aug 2020 15:42:35 +0200 Subject: [PATCH 325/940] bknd: constraint 'delivery' to be used w/ entire packs as well --- shopfloor/models/shopfloor_menu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index dd3f3de93d..23b7f0bd9e 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -70,7 +70,7 @@ def _check_allow_move_create(self): # Is not optimal, but is mandatory as long as SPT does not work w/ moves # but only w/ package levels. # TODO: add tests. - _move_entire_packs_scenario = "single_pack_transfer" + _move_entire_packs_scenario = ("single_pack_transfer", "delivery") @api.constrains("scenario", "picking_type_ids") def _check_move_entire_packages(self): From 6e3c924629311fc2e1504f4f8a60c77f14ce028f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 20 Aug 2020 11:07:25 +0200 Subject: [PATCH 326/940] zone picking: return right amount of operations on picking types data (#41) --- shopfloor/actions/data.py | 37 --------------------- shopfloor/services/schema.py | 4 --- shopfloor/services/zone_picking.py | 40 +++++++++++++++++++++-- shopfloor/tests/test_zone_picking_base.py | 9 ++--- 4 files changed, 40 insertions(+), 50 deletions(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index f2d7d1bd76..b45da63a28 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -234,41 +234,4 @@ def _picking_type_parser(self): return [ "id", "name", - ("lines_count", self._picking_type_lines_count), - ("picking_count", self._picking_type_picking_count), - ("priority_lines_count", self._picking_type_priority_lines_count), - ("priority_picking_count", self._picking_type_priority_picking_count), ] - - def _picking_type_lines_count(self, rec, field): - return self.env["stock.move.line"].search_count( - [ - ("picking_id.picking_type_id", "=", rec.id), - ("qty_done", "=", 0), - ("state", "in", ("assigned", "partially_available")), - ] - ) - - def _picking_type_priority_lines_count(self, rec, field): - return self.env["stock.move.line"].search_count( - [ - ("picking_id.picking_type_id", "=", rec.id), - ("qty_done", "=", 0), - ("state", "in", ("assigned", "partially_available")), - ("picking_id.priority", "in", ["2", "3"]), - ] - ) - - def _picking_type_picking_count(self, rec, field): - return self.env["stock.picking"].search_count( - [("picking_type_id", "=", rec.id), ("state", "not in", ("cancel", "done"))] - ) - - def _picking_type_priority_picking_count(self, rec, field): - return self.env["stock.picking"].search_count( - [ - ("picking_type_id", "=", rec.id), - ("state", "not in", ("cancel", "done")), - ("priority", "in", ["2", "3"]), - ] - ) diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index cf0689f480..16da390bb2 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -181,8 +181,4 @@ def picking_type(self): return { "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, - "lines_count": {"type": "float", "required": True}, - "picking_count": {"type": "float", "required": True}, - "priority_lines_count": {"type": "float", "required": True}, - "priority_picking_count": {"type": "float", "required": True}, } diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 81292dd388..26135c74f0 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -180,11 +180,38 @@ def _response_for_unload_set_destination( ) def _data_for_select_picking_type(self, zone_location, picking_types): - return { + data = { "zone_location": self.data.location(zone_location), # available picking types to choose from "picking_types": self.data.picking_types(picking_types), } + for datum in data["picking_types"]: + picking_type = self.env["stock.picking.type"].browse(datum["id"]) + zone_lines = self._picking_type_zone_lines(zone_location, picking_type) + priority_lines = zone_lines.filtered( + lambda line: line.picking_id.priority in ["2", "3"] + ) + + datum.update( + { + "lines_count": len(zone_lines), + "picking_count": len(zone_lines.mapped("picking_id")), + "priority_lines_count": len(priority_lines), + "priority_picking_count": len(priority_lines.mapped("picking_id")), + } + ) + return data + + def _picking_type_zone_lines(self, zone_location, picking_type): + return self.env["stock.move.line"].search( + [ + ("location_id", "=", zone_location.id), + # we have auto_join on picking_id + ("picking_id.picking_type_id", "=", picking_type.id), + ("qty_done", "=", 0), + ("state", "in", ("assigned", "partially_available")), + ] + ) def _data_for_move_line(self, zone_location, picking_type, move_line): return { @@ -1401,9 +1428,18 @@ def unload_set_destination(self): @property def _schema_for_select_picking_type(self): + picking_type = self.schemas.picking_type() + picking_type.update( + { + "lines_count": {"type": "float", "required": True}, + "picking_count": {"type": "float", "required": True}, + "priority_lines_count": {"type": "float", "required": True}, + "priority_picking_count": {"type": "float", "required": True}, + } + ) schema = { "zone_location": self.schemas._schema_dict_of(self.schemas.location()), - "picking_types": self.schemas._schema_list_of(self.schemas.picking_type()), + "picking_types": self.schemas._schema_list_of(picking_type), } return schema diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index ddd2897411..097e7e0e15 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -168,14 +168,9 @@ def assert_response_start(self, response, message=None): def _assert_response_select_picking_type( self, state, response, zone_location, picking_types, message=None ): + data = self.service._data_for_select_picking_type(zone_location, picking_types) self.assert_response( - response, - next_state=state, - data={ - "zone_location": self.data.location(zone_location), - "picking_types": self.data.picking_types(picking_types), - }, - message=message, + response, next_state=state, data=data, message=message, ) def assert_response_select_picking_type( From 7d32d10182279be44809e09d6af18765303c521c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 20 Aug 2020 12:29:44 +0200 Subject: [PATCH 327/940] bknd: fix zone_picking handling of set destination * if qty is partial -> accept only packages as destination * fix message when nothing has been processed (no location and no pkg) --- shopfloor/services/zone_picking.py | 95 ++++++++++++++----- .../test_zone_picking_set_line_destination.py | 63 +++--------- 2 files changed, 84 insertions(+), 74 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 26135c74f0..812f74a94e 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -1,7 +1,7 @@ import functools from odoo.fields import first -from odoo.tools.float_utils import float_compare +from odoo.tools.float_utils import float_compare, float_is_zero from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component @@ -488,13 +488,15 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit def _set_destination_location( self, zone_location, picking_type, move_line, quantity, confirmation, location ): + location_changed = False + response = None # Ask confirmation to the user if the scanned location is not in the # expected ones but is valid (in picking type's default destination) if not location.is_sublocation_of(move_line.location_dest_id) and ( not confirmation and location.is_sublocation_of(picking_type.default_location_dest_id) ): - return self._response_for_set_line_destination( + response = self._response_for_set_line_destination( zone_location, picking_type, move_line, @@ -503,6 +505,7 @@ def _set_destination_location( ), confirmation_required=True, ) + return (location_changed, response) # A valid location is a sub-location of the original destination, or a # sub-location of the picking type's default destination location if # `confirmation is True @@ -510,20 +513,22 @@ def _set_destination_location( confirmation and not location.is_sublocation_of(picking_type.default_location_dest_id) ): - return self._response_for_set_line_destination( + response = self._response_for_set_line_destination( zone_location, picking_type, move_line, message=self.msg_store.dest_location_not_allowed(), ) + return (location_changed, response) # If no destination package if not move_line.result_package_id: - return self._response_for_set_line_destination( + response = self._response_for_set_line_destination( zone_location, picking_type, move_line, message=self.msg_store.dest_package_required(), ) + return (location_changed, response) # destination location set to the scanned one move_line.location_dest_id = location # the quantity done is set to the passed quantity @@ -535,12 +540,14 @@ def _set_destination_location( # try to re-assign any split move (in case of partial qty) if "confirmed" in move_line.picking_id.move_lines.mapped("state"): move_line.picking_id.action_assign() + location_changed = True # Zero check zero_check = picking_type.shopfloor_zero_check if zero_check and move_line.location_id.planned_qty_in_location_is_empty(): - return self._response_for_zero_check( + response = self._response_for_zero_check( zone_location, picking_type, move_line.location_id ) + return (location_changed, response) def _is_package_empty(self, package): return not bool(package.quant_ids) @@ -555,41 +562,55 @@ def _is_package_already_used(self, package): ) ) + def _move_line_compare_qty(self, move_line, qty): + rounding = move_line.product_uom_id.rounding + return float_compare( + qty, move_line.product_uom_qty, precision_rounding=rounding + ) + + def _move_line_full_qty(self, move_line, qty): + rounding = move_line.product_uom_id.rounding + return float_is_zero( + move_line.product_uom_qty - qty, precision_rounding=rounding + ) + def _set_destination_package( self, zone_location, picking_type, move_line, quantity, package ): + package_changed = False + response = None # A valid package is: # * an empty package # * not used as destination for another move line if not self._is_package_empty(package): - return self._response_for_set_line_destination( + response = self._response_for_set_line_destination( zone_location, picking_type, move_line, message=self.msg_store.package_not_empty(package), ) + return (package_changed, response) if self._is_package_already_used(package): - return self._response_for_set_line_destination( + response = self._response_for_set_line_destination( zone_location, picking_type, move_line, message=self.msg_store.package_already_used(package), ) + return (package_changed, response) # the quantity done is set to the passed quantity # but if we move a partial qty, we need to split the move line - rounding = move_line.product_uom_id.rounding - compare = float_compare( - quantity, move_line.product_uom_qty, precision_rounding=rounding - ) + compare = self._move_line_compare_qty(move_line, quantity) qty_lesser = compare == -1 qty_greater = compare == 1 if qty_greater: - return self._response_for_set_line_destination( + response = self._response_for_set_line_destination( zone_location, picking_type, move_line, message=self.msg_store.unable_to_pick_more(move_line.product_uom_qty), ) + return (package_changed, response) elif qty_lesser: # split the move line which will be processed later remaining = move_line.product_uom_qty - quantity @@ -605,12 +626,14 @@ def _set_destination_package( move_line.result_package_id = package # the field ``shopfloor_user_id`` is updated with the current user move_line.shopfloor_user_id = self.env.user + package_changed = True # Zero check zero_check = picking_type.shopfloor_zero_check if zero_check and move_line.location_id.planned_qty_in_location_is_empty(): - return self._response_for_zero_check( + response = self._response_for_zero_check( zone_location, picking_type, move_line.location_id ) + return (package_changed, response) def set_destination( self, @@ -674,29 +697,50 @@ def set_destination( move_line = self.env["stock.move.line"].browse(move_line_id) if not move_line.exists(): return self._response_for_start(message=self.msg_store.record_not_found()) + + pkg_moved = False search = self.actions_for("search") - # When the barcode is a location - location = search.location_from_scan(barcode) - if location: - response = self._set_destination_location( - zone_location, picking_type, move_line, quantity, confirmation, location - ) - if response: - return response + accept_only_package = not self._move_line_full_qty(move_line, quantity) + + if not accept_only_package: + # When the barcode is a location + location = search.location_from_scan(barcode) + if location: + pkg_moved, response = self._set_destination_location( + zone_location, + picking_type, + move_line, + quantity, + confirmation, + location, + ) + if response: + return response + # When the barcode is a package package = search.package_from_scan(barcode) if package: location = move_line.location_dest_id - response = self._set_destination_package( + pkg_moved, response = self._set_destination_package( zone_location, picking_type, move_line, quantity, package ) if response: return response + + message = None + + if not pkg_moved and not package and accept_only_package: + message = self.msg_store.package_not_found_for_barcode(barcode) + return self._response_for_set_line_destination( + zone_location, picking_type, move_line, message=message + ) + + if pkg_moved: + message = self.msg_store.confirm_pack_moved() + # Process the next line response = self.list_move_lines(zone_location.id, picking_type.id) - return self._response( - base_response=response, message=self.msg_store.confirm_pack_moved(), - ) + return self._response(base_response=response, message=message,) def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero): """Confirm or not if the source location of a move has zero qty @@ -1175,6 +1219,7 @@ def unload_set_destination( move_lines, message=self.msg_store.record_not_found(), ) + buffer_lines = self._find_buffer_move_lines( zone_location, picking_type, dest_package=package ) diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py index bf2c13f50f..f1414d6823 100644 --- a/shopfloor/tests/test_zone_picking_set_line_destination.py +++ b/shopfloor/tests/test_zone_picking_set_line_destination.py @@ -188,13 +188,12 @@ def test_set_destination_location_no_other_move_line_partial_qty(self): Then the operator move 6 qty on 10, we get: - move qty 6 (done): - -> move_line qty 6 from location X - move qty 4 (assigned): - -> move_line qty 4 from location Y (remaining) + an error because we can move only full qty by location + and only a package barcode is allowed on scan. """ zone_location = self.zone_location picking_type = self.picking3.picking_type_id + barcode = self.packing_location.barcode moves_before = self.picking3.move_lines self.assertEqual(len(moves_before), 1) self.assertEqual(len(moves_before.move_line_ids), 1) @@ -207,30 +206,17 @@ def test_set_destination_location_no_other_move_line_partial_qty(self): "zone_location_id": zone_location.id, "picking_type_id": picking_type.id, "move_line_id": move_line.id, - "barcode": self.packing_location.barcode, + "barcode": barcode, "quantity": 6, "confirmation": False, }, ) - # Check picking data (move has been split in two, 6 done and 4 remaining) - moves_after = self.picking3.move_lines - self.assertEqual(len(moves_after), 2) - self.assertEqual(moves_after[0].product_uom_qty, 6) - self.assertEqual(moves_after[0].state, "done") - self.assertEqual(moves_after[0].move_line_ids.product_uom_qty, 0) - self.assertEqual(moves_after[1].product_uom_qty, 4) - self.assertEqual(moves_after[1].state, "assigned") - self.assertEqual(moves_after[1].move_line_ids.product_uom_qty, 4) - self.assertEqual(move_line.qty_done, 6) - # Check response - move_lines = self.service._find_location_move_lines(zone_location, picking_type) - move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) - self.assert_response_select_line( + self.assert_response_set_line_destination( response, zone_location, picking_type, - move_lines, - message=self.service.msg_store.confirm_pack_moved(), + move_line, + message=self.service.msg_store.package_not_found_for_barcode(barcode), ) def test_set_destination_location_several_move_line_full_qty(self): @@ -308,56 +294,35 @@ def test_set_destination_location_several_move_line_partial_qty(self): Then the operator move 4 qty on 6 (from the first move line), we get: - move qty 4 (done): - -> move_line qty 4 from location X - move qty 2 (assigned): - -> move_line qty 2 from location X (remaining) - move qty 4 (assigned): - -> move_line qty 4 from location Y (untouched) + an error because we can move only full qty by location + and only a package barcode is allowed on scan. """ zone_location = self.zone_location picking_type = self.picking4.picking_type_id + barcode = self.packing_location.barcode moves_before = self.picking4.move_lines self.assertEqual(len(moves_before), 1) # 10 qty self.assertEqual(len(moves_before.move_line_ids), 2) # 6+4 qty move_line = moves_before.move_line_ids[0] # we need a destination package if we want to scan a destination location move_line.result_package_id = self.free_package - other_move_line = moves_before.move_line_ids[1] response = self.service.dispatch( "set_destination", params={ "zone_location_id": zone_location.id, "picking_type_id": picking_type.id, "move_line_id": move_line.id, - "barcode": self.packing_location.barcode, + "barcode": barcode, "quantity": 4, # 4/6 qty "confirmation": False, }, ) - # Check picking data (move has been split in three, 4 done, 2+4 remaining) - moves_after = self.picking4.move_lines - self.assertEqual(len(moves_after), 3) - self.assertEqual(moves_after[0].product_uom_qty, 4) - self.assertEqual(moves_after[0].state, "done") - self.assertEqual(moves_after[0].move_line_ids.product_uom_qty, 0) - self.assertEqual(moves_after[1].product_uom_qty, 4) - self.assertEqual(moves_after[1].state, "assigned") - self.assertEqual(moves_after[1].move_line_ids.product_uom_qty, 4) - self.assertEqual(moves_after[2].product_uom_qty, 2) - self.assertEqual(moves_after[2].state, "assigned") - self.assertEqual(moves_after[2].move_line_ids.product_uom_qty, 2) - self.assertEqual(move_line.qty_done, 4) - self.assertNotEqual(move_line.move_id, other_move_line.move_id) - # Check response - move_lines = self.service._find_location_move_lines(zone_location, picking_type) - move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) - self.assert_response_select_line( + self.assert_response_set_line_destination( response, zone_location, picking_type, - move_lines, - message=self.service.msg_store.confirm_pack_moved(), + move_line, + message=self.service.msg_store.package_not_found_for_barcode(barcode), ) def test_set_destination_location_zero_check(self): From bd0855fc69813ec59e255c0ead68d5dfa9dc9653 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 20 Aug 2020 12:30:41 +0200 Subject: [PATCH 328/940] zone_picking: handle case where set line dest has no line --- shopfloor/services/zone_picking.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 812f74a94e..762658029b 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -1266,6 +1266,9 @@ def unload_set_destination( return self._response_for_start( message=self.msg_store.picking_type_complete(picking_type) ) + # TODO: when we have no lines here + # we should not redirect to `unload_set_destination` + # because we'll have nothing to display (currently the UI is broken). return self._response_for_unload_set_destination( zone_location, picking_type, From ed9b3f1dbaf66de1740edddad2df90c81d6f0d8d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 21 Aug 2020 13:23:05 +0200 Subject: [PATCH 329/940] bknd: fix checkout._response_for_select_package 'picking' is always required. --- shopfloor/services/checkout.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index edfdda46ab..91ba2565b8 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -64,8 +64,7 @@ def _response_for_manual_selection(self, message=None): data = {"pickings": self.data.pickings(pickings)} return self._response(next_state="manual_selection", data=data, message=message) - def _response_for_select_package(self, lines, message=None): - picking = lines.mapped("picking_id") + def _response_for_select_package(self, picking, lines, message=None): return self._response( next_state="select_package", data={ @@ -87,6 +86,7 @@ def _response_for_select_dest_package(self, picking, move_lines, message=None): ) if not packages: return self._response_for_select_package( + picking, move_lines, message={ "message_type": "warning", @@ -353,7 +353,7 @@ def _select_lines_from_package(self, picking, selection_lines, package): }, ) self._select_lines(lines) - return self._response_for_select_package(lines) + return self._response_for_select_package(picking, lines) def _select_lines_from_product(self, picking, selection_lines, product): if product.tracking in ("lot", "serial"): @@ -390,7 +390,7 @@ def _select_lines_from_product(self, picking, selection_lines, product): return self._select_lines_from_package(picking, selection_lines, packages) self._select_lines(lines) - return self._response_for_select_package(lines) + return self._response_for_select_package(picking, lines) def _select_lines_from_lot(self, picking, selection_lines, lot): lines = selection_lines.filtered(lambda l: l.lot_id == lot) @@ -422,7 +422,7 @@ def _select_lines_from_lot(self, picking, selection_lines, lot): return self._select_lines_from_package(picking, selection_lines, packages) self._select_lines(lines) - return self._response_for_select_package(lines) + return self._response_for_select_package(picking, lines) def _select_line_package(self, picking, selection_lines, package): if not package: @@ -443,7 +443,7 @@ def _select_line_move_line(self, picking, selection_lines, move_line): picking, selection_lines, move_line.package_id ) self._select_lines(move_line) - return self._response_for_select_package(move_line) + return self._response_for_select_package(picking, move_line) def select_line(self, picking_id, package_id=None, move_line_id=None): """Select move lines of the stock picking @@ -513,6 +513,7 @@ def _change_line_qty( else: move_line.qty_done = qty_done return self._response_for_select_package( + picking, self.env["stock.move.line"].browse(selected_line_ids).exists(), message=message, ) @@ -607,6 +608,7 @@ def _put_lines_in_package(self, picking, selected_lines, package): """ if not self._is_package_allowed(picking, package): return self._response_for_select_package( + picking, selected_lines, message={ "message_type": "error", @@ -692,6 +694,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): if product: if product.tracking in ("lot", "serial"): return self._response_for_select_package( + picking, selected_lines, message=self.msg_store.scan_lot_on_product_tracked_by_lot(), ) @@ -714,7 +717,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): ) return self._response_for_select_package( - selected_lines, message=self.msg_store.barcode_not_found() + picking, selected_lines, message=self.msg_store.barcode_not_found() ) def new_package(self, picking_id, selected_line_ids): From 18bad292781e41686e65e4372c540503bc857fad Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 21 Aug 2020 14:36:21 +0200 Subject: [PATCH 330/940] zone picking/location content transfer: Add sync of move lines destinations --- shopfloor/services/change_pack_lot_mixin.py | 2 ++ shopfloor/services/cluster_picking.py | 4 +-- .../services/location_content_transfer.py | 32 +++++++++++++------ shopfloor/services/zone_picking.py | 13 +++++--- 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/shopfloor/services/change_pack_lot_mixin.py b/shopfloor/services/change_pack_lot_mixin.py index bf6cb12f75..e1d9b078d2 100644 --- a/shopfloor/services/change_pack_lot_mixin.py +++ b/shopfloor/services/change_pack_lot_mixin.py @@ -1,6 +1,8 @@ from odoo import _ +# TODO use a component instead (in actions) +# delegation > inheritance ;) class ChangePackLotMixin: def _change_lot(self, move_line, lot, response_ok_func, response_error_func): """Change the lot on the move line. diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 0871afb452..a1924e1fad 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -1074,7 +1074,7 @@ def unload_scan_destination( batch, package, lines, barcode, confirmation=confirmation ) - def _unload_scan_destination_lock_lines(self, lines): + def _lock_lines(self, lines): """Lock move lines""" sql = "SELECT id FROM %s WHERE ID IN %%s FOR UPDATE" % lines._table self.env.cr.execute(sql, (tuple(lines.ids),), log_exceptions=False) @@ -1083,7 +1083,7 @@ def _unload_scan_destination_lines( self, batch, package, lines, barcode, confirmation=False ): # Lock move lines that will be updated - self._unload_scan_destination_lock_lines(lines) + self._lock_lines(lines) first_line = fields.first(lines) picking_type = fields.first(batch.picking_ids).picking_type_id scanned_location = self.actions_for("search").location_from_scan(barcode) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index a0e9845899..4a64454e89 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -67,7 +67,7 @@ def _response_for_scan_destination_all( if confirmation_required and not message: message = self.msg_store.need_confirmation() return self._response( - next_state="scan_destination_all", data=data, message=message, + next_state="scan_destination_all", data=data, message=message ) def _response_for_start_single(self, pickings, message=None): @@ -97,9 +97,7 @@ def _response_for_scan_destination( data["confirmation_required"] = confirmation_required if confirmation_required and not message: message = self.msg_store.need_confirmation() - return self._response( - next_state="scan_destination", data=data, message=message, - ) + return self._response(next_state="scan_destination", data=data, message=message) def _data_content_all_for_location(self, pickings): sorter = self.actions_for("location_content_transfer.sorter") @@ -320,11 +318,20 @@ def _find_transfer_move_lines(self, location): ) return lines - def _set_destination_lines(self, pickings, move_lines, dest_location): - move_lines.location_dest_id = dest_location - move_lines.package_level_id.location_dest_id = dest_location + # hook used in module shopfloor_checkout_sync + def _write_destination_on_lines(self, lines, location): + lines.location_dest_id = location + lines.package_level_id.location_dest_id = location + + def _set_all_destination_lines_and_done(self, pickings, move_lines, dest_location): + self._write_destination_on_lines(move_lines, dest_location) pickings.action_done() + def _lock_lines(self, lines): + """Lock move lines""" + sql = "SELECT id FROM %s WHERE ID IN %%s FOR UPDATE" % lines._table + self.env.cr.execute(sql, (tuple(lines.ids),), log_exceptions=False) + def set_destination_all(self, location_id, barcode, confirmation=False): """Scan destination location for all the moves of the location @@ -359,8 +366,9 @@ def set_destination_all(self, location_id, barcode, confirmation=False): return self._response_for_scan_destination_all( pickings, confirmation_required=True ) + self._lock_lines(move_lines) - self._set_destination_lines(pickings, move_lines, scanned_location) + self._set_all_destination_lines_and_done(pickings, move_lines, scanned_location) return self._response_for_start( message=self.msg_store.location_content_transfer_complete( @@ -542,13 +550,14 @@ def set_destination_package( location, package_level, confirmation_required=True ) package_move_lines = package_level.move_line_ids + self._lock_lines(package_move_lines) package_moves = package_move_lines.mapped("move_id") for package_move in package_moves: # Check if there is no other lines linked to the move others than # the lines related to the package itself. In such case we have to # split the move to process only the lines related to the package. package_move.split_other_move_lines(package_move_lines) - package_level.location_dest_id = scanned_location + self._write_destination_on_lines(package_level.move_line_ids, scanned_location) package_moves.with_context(_sf_no_backorder=True)._action_done() move_lines = self._find_transfer_move_lines(location) message = self.msg_store.location_content_transfer_item_complete( @@ -605,6 +614,9 @@ def set_destination_line( return self._response_for_scan_destination( location, move_line, confirmation_required=True ) + + self._lock_lines(move_line) + if quantity < move_line.product_uom_qty: # Update the current move line quantity and # put the scanned qty (the move line) in its own move @@ -620,7 +632,7 @@ def set_destination_line( for remaining_move_line in current_move.move_line_ids: remaining_move_line.qty_done = remaining_move_line.product_uom_qty move_line.move_id.split_other_move_lines(move_line) - move_line.location_dest_id = scanned_location + self._write_destination_on_lines(move_line, scanned_location) move_line.move_id.with_context(_sf_no_backorder=True)._action_done() move_lines = self._find_transfer_move_lines(location) message = self.msg_store.location_content_transfer_item_complete( diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 762658029b..e95b00deed 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -530,7 +530,7 @@ def _set_destination_location( ) return (location_changed, response) # destination location set to the scanned one - move_line.location_dest_id = location + self._write_destination_on_lines(move_line, location) # the quantity done is set to the passed quantity move_line.qty_done = quantity # if the move has other move lines, it is split to have only this move line @@ -1056,7 +1056,7 @@ def set_destination_all( confirmation_required=True, ) # the scanned location is still valid, use it - buffer_lines.location_dest_id = location + self._write_destination_on_lines(buffer_lines, location) # set lines to done + refresh buffer lines (should be empty) moves = buffer_lines.mapped("move_id") moves.with_context(_sf_no_backorder=True)._action_done() @@ -1068,6 +1068,10 @@ def set_destination_all( zone_location, picking_type, buffer_lines, message=message, ) + def _write_destination_on_lines(self, lines, location): + self._lock_lines(lines) + lines.location_dest_id = location + def unload_split(self, zone_location_id, picking_type_id): """Indicates that now the buffer must be treated line per line @@ -1177,7 +1181,7 @@ def unload_scan_pack(self, zone_location_id, picking_type_id, package_id, barcod unload_single_message=self.msg_store.barcode_no_match(package.name), ) - def _unload_set_destination_lock_lines(self, lines): + def _lock_lines(self, lines): """Lock move lines""" sql = "SELECT id FROM %s WHERE ID IN %%s FOR UPDATE" % lines._table self.env.cr.execute(sql, (tuple(lines.ids),), log_exceptions=False) @@ -1245,8 +1249,7 @@ def unload_set_destination( confirmation_required=True, ) # the scanned location is valid, use it - self._unload_set_destination_lock_lines(buffer_lines) - buffer_lines.location_dest_id = location + self._write_destination_on_lines(buffer_lines, location) # set lines to done + refresh buffer lines (should be empty) moves = buffer_lines.mapped("move_id") moves.with_context(_sf_no_backorder=True)._action_done() From c3765dd07679a1d7598745814f412f03caed9ad1 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 26 Aug 2020 07:46:34 +0200 Subject: [PATCH 331/940] bknd: improve exception tracing * store full traceback in log entry * return log entry URL to ease reporting --- shopfloor/services/service.py | 37 +++++++++++++++++++++++++++++---- shopfloor/services/validator.py | 1 + 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 10f9f088d9..877d561a45 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -1,3 +1,7 @@ +import traceback + +from werkzeug.urls import url_encode, url_join + from odoo import _, exceptions, registry from odoo.exceptions import MissingError from odoo.http import request @@ -15,6 +19,15 @@ def to_float(val): return None +class ShopfloorServiceDispatchException(Exception): + + rest_json_info = {} + + def __init__(self, message, log_entry_url): + super().__init__(message) + self.rest_json_info = {"log_entry_url": log_entry_url} + + class BaseShopfloorService(AbstractComponent): """Base class for REST services""" @@ -43,14 +56,30 @@ def _dispatch_with_db_logging(self, method_name, _id=None, params=None): try: result = super().dispatch(method_name, _id=_id, params=params) except Exception as err: + tb = traceback.format_exc() self.env.cr.rollback() with registry(self.env.cr.dbname).cursor() as cr: env = self.env(cr=cr) - self._log_call_in_db(env, request, _id, params, error=err) - raise - self._log_call_in_db(self.env, request, _id, params, result=result) + log_entry = self._log_call_in_db(env, request, _id, params, error=tb) + log_entry_url = self._get_log_entry_url(log_entry) + raise ShopfloorServiceDispatchException(str(err), log_entry_url) from err + + log_entry = self._log_call_in_db(self.env, request, _id, params, result=result) + log_entry_url = self._get_log_entry_url(log_entry) + result["log_entry_url"] = log_entry_url return result + def _get_log_entry_url(self, entry): + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + url_params = { + "action": self.env.ref("shopfloor.action_shopfloor_log").id, + "view_type": "form", + "model": entry._name, + "id": entry.id, + } + url = "/web?#%s" % url_encode(url_params) + return url_join(base_url, url) + @property def _log_call_header_strip(self): return ("Cookie", "Api-Key") @@ -79,7 +108,7 @@ def _log_call_in_db(self, env, _request, _id, params, result=None, error=None): ) if not values: return - env["shopfloor.log"].sudo().create(values) + return env["shopfloor.log"].sudo().create(values) def _get(self, _id): domain = expression.normalize_domain(self._get_base_search_domain()) diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py index 8bdf4d75cf..21c46847de 100644 --- a/shopfloor/services/validator.py +++ b/shopfloor/services/validator.py @@ -107,6 +107,7 @@ def _response_schema(self, data_schema=None, next_states=None): "required": False, "schema": {"body": {"type": "string", "required": True}}, }, + "log_entry_url": {"type": "string", "required": False}, } if not data_schema: data_schema = {} From 42b4699592b16c9b3d32909a58dc21002e46e580 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 26 Aug 2020 07:48:01 +0200 Subject: [PATCH 332/940] bknd: zone picking fix typo raise/return on scan source --- shopfloor/services/zone_picking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index e95b00deed..baccf9dbd8 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -382,7 +382,7 @@ def _scan_source_location(self, zone_location, picking_type, location): move_line = self.env["stock.move.line"].search(domain) if len(move_line) == 1: return move_line - raise False + return False def _scan_source_package(self, zone_location, picking_type, package, order): move_lines = self._find_location_move_lines( From 5b8d2485ca35e5220e583bc2055960580fc4ccee Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 26 Aug 2020 08:17:33 +0200 Subject: [PATCH 333/940] bknd: add package name unique constrain --- shopfloor/models/stock_quant_package.py | 8 +++++++- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_misc.py | 20 ++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 shopfloor/tests/test_misc.py diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py index c3bc49511d..b768c06280 100644 --- a/shopfloor/models/stock_quant_package.py +++ b/shopfloor/models/stock_quant_package.py @@ -1,4 +1,4 @@ -from odoo import api, fields, models +from odoo import _, api, exceptions, fields, models class StockQuantPackage(models.Model): @@ -37,3 +37,9 @@ def _compute_reserved_move_lines(self): # destination_planned_move_line_ids # filter out done/cancel lines + + @api.constrains("name") + def _constrain_name_unique(self): + for rec in self: + if self.search_count([("name", "=", rec.name), ("id", "!=", rec.id)]): + raise exceptions.UserError(_("Package name must be unique!")) diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index b17bbc0db0..bfa6c153a4 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -50,3 +50,4 @@ from . import test_zone_picking_unload_single from . import test_zone_picking_unload_all from . import test_zone_picking_unload_set_destination +from . import test_misc diff --git a/shopfloor/tests/test_misc.py b/shopfloor/tests/test_misc.py new file mode 100644 index 0000000000..3e29f16b8b --- /dev/null +++ b/shopfloor/tests/test_misc.py @@ -0,0 +1,20 @@ +from odoo import exceptions +from odoo.tests.common import SavepointCase + + +class MiscTestCase(SavepointCase): + tracking_disable = True + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict(cls.env.context, tracking_disable=cls.tracking_disable) + ) + + def test_package_name_unique(self): + create = self.env["stock.quant.package"].create + create({"name": "GOOD_NAME"}) + with self.assertRaises(exceptions.UserError) as exc: + create({"name": "GOOD_NAME"}) + self.assertEqual(exc.exception.name, "Package name must be unique!") From b98ae92aa8deb276a621520f42f120b9c8d29692 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 26 Aug 2020 12:10:09 +0200 Subject: [PATCH 334/940] zone picking: return error when no package or location is found --- shopfloor/services/zone_picking.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index baccf9dbd8..d9f36bc3da 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -729,8 +729,12 @@ def set_destination( message = None - if not pkg_moved and not package and accept_only_package: - message = self.msg_store.package_not_found_for_barcode(barcode) + if not pkg_moved and not package: + if accept_only_package: + message = self.msg_store.package_not_found_for_barcode(barcode) + else: + # we don't know if user wanted to scan a location or a package + message = self.msg_store.barcode_not_found() return self._response_for_set_line_destination( zone_location, picking_type, move_line, message=message ) From 38489e11ff14b5de265a29f7e8aa84dbb251bd37 Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 18 Aug 2020 18:42:06 +0200 Subject: [PATCH 335/940] location transfer: ensure to process pickings sharing the same source location And test a complete flow: 1) Operator-1 processes the first pallet with the "zone picking" scenario to move the goods to PACK-1: move1 PICK -> PACK-1 'done' 2) Operator-1 processes the second pallet with the "zone picking" scenario to move the goods to PACK-2: move1 PICK -> PACK-2 'done' 3) Operator-2 with the "location content transfer" scenario scan the location where the first pallet is (PACK-1): - the app should found one move line - this move line will be put in its own transfer as its sibling lines are in another source location - as such the app should ask the destination location (as there is only one line) move1 PACK-2 -> SHIP (still handled by the operator so not 'done') 4) Operator-3 with the "location content transfer" scenario scan the location where the first pallet is (PACK-1): - nothing is found as the pallet is currently handled by Operator-2 5) If Operator-2 is unable to finish the flow with the first pallet (barcode device out of battery... etc), he should be able to recover what he started. 6) Operator-2 then finishes its operation regarding the first pallet, and scan the location where the second pallet is (PACK-2). He should find only this pallet available. --- shopfloor/actions/message.py | 6 + shopfloor/models/stock_move.py | 10 +- shopfloor/models/stock_move_line.py | 88 +++++++- .../services/location_content_transfer.py | 18 +- shopfloor/tests/common.py | 4 +- .../test_location_content_transfer_base.py | 2 +- .../test_location_content_transfer_mix.py | 194 +++++++++++++++--- shopfloor/tests/test_stock_split.py | 128 ++++++++++++ 8 files changed, 412 insertions(+), 38 deletions(-) create mode 100644 shopfloor/tests/test_stock_split.py diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 4f46fb111c..b713be668c 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -447,3 +447,9 @@ def lines_different_dest_location(self): "message_type": "error", "body": _("Lines have different destination location."), } + + def new_move_lines_not_assigned(self): + return { + "message_type": "error", + "body": _("New move lines cannot be assigned: canceled."), + } diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py index 0194457f9d..f700f1bec7 100644 --- a/shopfloor/models/stock_move.py +++ b/shopfloor/models/stock_move.py @@ -4,12 +4,18 @@ class StockMove(models.Model): _inherit = "stock.move" - def split_other_move_lines(self, move_lines): + def split_other_move_lines(self, move_lines, intersection=False): """Substract `move_lines` from `move.move_line_ids`, put the result in a new move and returns it. + + If `intersection` is set to `True`, this is the common lines between + `move_lines` and `move.move_line_ids` which will be put in a new move. """ self.ensure_one() - other_move_lines = self.move_line_ids - move_lines + if intersection: + other_move_lines = self.move_line_ids & move_lines + else: + other_move_lines = self.move_line_ids - move_lines if other_move_lines: qty_to_split = sum(other_move_lines.mapped("product_uom_qty")) backorder_move_id = self._split(qty_to_split) diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py index 7bb68b899c..f92dedea7a 100644 --- a/shopfloor/models/stock_move_line.py +++ b/shopfloor/models/stock_move_line.py @@ -1,4 +1,5 @@ -from odoo import fields, models +from odoo import _, fields, models +from odoo.exceptions import UserError class StockMoveLine(models.Model): @@ -21,3 +22,88 @@ class StockMoveLine(models.Model): # allow domain on picking_id.xxx without too much perf penalty picking_id = fields.Many2one(auto_join=True) + + def _split_pickings_from_source_location(self): + """Ensure that the related pickings will have the same source location. + + Some pickings related could have other unrelated move lines, as such we + have to split them to contain only the move lines related to the expected + source location. + + Example: + + Initial data: + + PICK1: + - move line with source location LOC1 + - move line with source location LOC2 + PICK2: + - move line with source location LOC2 + - move line with source location LOC3 + + Then we process move lines related to LOC2 with this method, we get: + + PICK1: + - move line with source location LOC1 + PICK2: + - move line with source location LOC3 + PICK3: + - move line with source location LOC2 + - move line with source location LOC2 + + Return the new picking (in case a split has been made), or the current + related pickings. + """ + location_src_to_process = self.location_id + if location_src_to_process and len(location_src_to_process) != 1: + raise UserError( + _("Move lines processed have to share the same source location.") + ) + pickings = self.picking_id + move_lines_to_process_ids = [] + for picking in pickings: + location_src = picking.mapped("move_line_ids.location_id") + if len(location_src) == 1: + continue + # Get the related move lines among the picking and split them + move_lines_to_process_ids.extend( + set(picking.move_line_ids.ids) & set(self.ids) + ) + # Put all move lines related to the source location in a separate picking + move_lines_to_process = self.browse(move_lines_to_process_ids) + new_move_ids = [] + for move_line in move_lines_to_process: + new_move = move_line.move_id.split_other_move_lines( + move_line, intersection=True + ) + new_move._recompute_state() + new_move_ids.append(new_move.id) + # If we have new moves, create the backorder picking + # NOTE: code copy/pasted & adapted from OCA module 'stock_split_picking' + new_moves = self.env["stock.move"].browse(new_move_ids) + if new_moves: + picking = pickings[0] + new_picking = picking.copy( + { + "name": "/", + "move_lines": [], + "move_line_ids": [], + "backorder_id": picking.id, + } + ) + pickings.message_post( + body=_( + 'The backorder %s has been created.' + ) + % (new_picking.id, new_picking.name) + ) + new_moves.write({"picking_id": new_picking.id}) + new_moves.mapped("move_line_ids").write({"picking_id": new_picking.id}) + new_moves.move_line_ids.package_level_id.write( + {"picking_id": new_picking.id} + ) + new_moves._action_assign() + pickings = new_picking + return pickings diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 4a64454e89..5552dae16a 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -243,7 +243,7 @@ def scan_location(self, barcode): if not location: return self._response_for_start(message=self.msg_store.barcode_not_found()) move_lines = self._find_location_move_lines(location) - pickings = move_lines.mapped("picking_id") + pickings = move_lines.picking_id picking_types = pickings.mapped("picking_type_id") if len(picking_types) > 1: @@ -260,6 +260,13 @@ def scan_location(self, barcode): "body": _("This location content can't be moved using this menu."), } ) + # Ensure we process move lines related to pickings having only one source + # location among all their move lines. If there are different source + # locations, we put the move lines we are interested in in a separate picking. + # This is required as we can only deal within this scenario with pickings + # that share the same source location. + pickings = move_lines._split_pickings_from_source_location() + # If the following criteria are met: # - no move lines have been found # - the menu is configured to allow the creation of moves @@ -276,15 +283,16 @@ def scan_location(self, barcode): ) ): new_moves = self._create_moves_from_location(location) + if not new_moves: + return self._response_for_start( + message=self.msg_store.no_pack_in_location(location) + ) new_moves._action_confirm(merge=False) new_moves._action_assign() if not all([x.state == "assigned" for x in new_moves]): new_moves._action_cancel() return self._response_for_start( - message={ - "message_type": "error", - "body": _("New move lines cannot be assigned: canceled."), - } + message=self.msg_store.new_move_lines_not_assigned() ) pickings = new_moves.mapped("picking_id") move_lines = new_moves.move_line_ids diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index b55dc7f269..7569e5aa4b 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -52,9 +52,9 @@ class CommonCase(SavepointCase, ComponentMixin): maxDiff = None @contextmanager - def work_on_services(self, **params): + def work_on_services(self, env=None, **params): params = params or {} - collection = _PseudoCollection("shopfloor.service", self.env) + collection = _PseudoCollection("shopfloor.service", env or self.env) yield WorkContext( model_name="rest.service.registration", collection=collection, **params ) diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py index 0bf62b4ef7..6c89326b34 100644 --- a/shopfloor/tests/test_location_content_transfer_base.py +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -57,7 +57,7 @@ def _assert_response_scan_destination_all( # data methods have their own tests lines = pickings.move_line_ids.filtered(lambda line: not line.package_level_id) package_levels = pickings.package_level_ids - location = lines.mapped("location_id") + location = pickings.mapped("move_line_ids.location_id") self.assert_response( response, next_state=state, diff --git a/shopfloor/tests/test_location_content_transfer_mix.py b/shopfloor/tests/test_location_content_transfer_mix.py index 6ab2dd709a..b4c6252dba 100644 --- a/shopfloor/tests/test_location_content_transfer_mix.py +++ b/shopfloor/tests/test_location_content_transfer_mix.py @@ -1,9 +1,30 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + from .test_location_content_transfer_base import LocationContentTransferCommonCase class LocationContentTransferMixCase(LocationContentTransferCommonCase): """Tests where we mix location content transfer with other scenarios.""" + @classmethod + def setUpClassUsers(cls): + super().setUpClassUsers() + Users = ( + cls.env["res.users"] + .sudo() + .with_context({"no_reset_password": True, "mail_create_nosubscribe": True}) + ) + cls.stock_user2 = Users.create( + { + "name": "Paul Posichon", + "login": "paulposichon", + "email": "paul.posichon@example.com", + "notification_type": "inbox", + "groups_id": [(6, 0, [cls.env.ref("stock.group_stock_user").id])], + } + ) + @classmethod def setUpClassVars(cls, *args, **kwargs): super().setUpClassVars(*args, **kwargs) @@ -35,22 +56,18 @@ def setUpClassBaseData(cls): # two stock move lines (6 and 4 to satisfy 10 qties) cls.package_1 = cls.env["stock.quant.package"].create({"name": "PACKAGE_1"}) cls.package_2 = cls.env["stock.quant.package"].create({"name": "PACKAGE_2"}) - cls.package_3 = cls.env["stock.quant.package"].create({"name": "PACKAGE_3"}) cls._update_qty_in_location( cls.stock_location, cls.product_a, 6, package=cls.package_1 ) cls._update_qty_in_location( cls.stock_location, cls.product_a, 4, package=cls.package_2 ) - cls._update_qty_in_location( - cls.stock_location, cls.product_a, 5, package=cls.package_3 - ) # Create the pick/pack/ship transfers cls.ship_move_a = cls.env["stock.move"].create( { "name": cls.product_a.display_name, "product_id": cls.product_a.id, - "product_uom_qty": 15.0, + "product_uom_qty": 10.0, "product_uom": cls.product_a.uom_id.id, "location_id": cls.ship_location.id, "location_dest_id": cls.customer_location.id, @@ -65,6 +82,7 @@ def setUpClassBaseData(cls): cls.pack_move_a = cls.ship_move_a.move_orig_ids[0] cls.pick_move_a = cls.pack_move_a.move_orig_ids[0] cls.picking1 = cls.pick_move_a.picking_id + cls.packing1 = cls.pack_move_a.picking_id cls.picking1.action_assign() def setUp(self): @@ -102,39 +120,59 @@ def _zone_picking_process_line(self, move_line, dest_location=None): dest_location = move_line.location_dest_id qty = move_line.product_uom_qty response = self.zp_service.set_destination( - zone_location.id, picking_type.id, move_line.id, dest_location.barcode, qty, + zone_location.id, + picking_type.id, + move_line.id, + dest_location.barcode, + qty, + confirmation=True, ) assert response["message"]["message_type"] == "success" self.assertEqual(move_line.state, "done") self.assertEqual(move_line.move_id.product_uom_qty, qty) - def _location_content_transfer_process_line(self, move_line, set_destination=False): + def _location_content_transfer_process_line( + self, move_line, set_destination=False, user=None + ): + service = self.service + if user: + env = self.env(user=user) + with self.work_on_services( + env=env, menu=self.menu, profile=self.profile + ) as work: + service = work.component(usage="location_content_transfer") pack_location = move_line.location_id out_location = move_line.location_dest_id # Scan the location - response = self.service.scan_location(pack_location.barcode) - assert response["next_state"] in ("scan_destination_all", "start_single") + response = service.scan_location(pack_location.barcode) # Set the destination if set_destination: + assert response["next_state"] in ("scan_destination_all", "start_single") qty = move_line.product_uom_qty if response["next_state"] == "scan_destination_all": - response = self.service.set_destination_all( + response = service.set_destination_all( pack_location.id, out_location.barcode ) - assert response["message"]["message_type"] == "success" + self.assert_response_start( + response, + message=service.msg_store.location_content_transfer_complete( + pack_location, out_location, + ), + ) self.assertEqual(move_line.state, "done") self.assertEqual(move_line.move_id.product_uom_qty, qty) elif response["next_state"] == "start_single": - response = self.service.scan_line( + response = service.scan_line( pack_location.id, move_line.id, move_line.product_id.barcode ) assert response["message"]["message_type"] == "success" - response = self.service.set_destination_line( + response = service.set_destination_line( pack_location.id, move_line.id, qty, out_location.barcode ) assert response["message"]["message_type"] == "success" assert move_line.state == "done" assert move_line.qty_done == qty + return response def test_with_zone_picking1(self): """Test the following scenario: @@ -202,28 +240,130 @@ def test_with_zone_picking2(self): move1 PICK -> PACK-2 'done' 3) Operator-2 with the "location content transfer" scenario scan - the location where the first pallet is and has to found it: + the location where the first pallet is (PACK-1): + - the app should found one move line + - this move line will be put in its own transfer as its sibling lines + are in another source location + - as such the app should ask the destination location (as there is + only one line) + + move1 PACK-2 -> SHIP (still handled by the operator so not 'done') + + 4) Operator-3 with the "location content transfer" scenario scan + the location where the first pallet is (PACK-1): + - nothing is found as the pallet is currently handled by Operator-2 + + 5) If Operator-2 is unable to finish the flow with the first pallet + (barcode device out of battery... etc), he should be able to recover + what he started. - move1 PACK-1 -> SHIP + 6) Operator-2 then finishes its operation regarding the first pallet, and + scan the location where the second pallet is (PACK-2). He should find + only this pallet available. """ - picking = self.picking1 - move_lines = picking.move_line_ids + move_lines = self.picking1.move_line_ids pick_move_line1 = move_lines[0] pick_move_line2 = move_lines[1] # Operator-1 process the first pallet with the "zone picking" scenario - self._zone_picking_process_line(pick_move_line1) + orig_dest_location = pick_move_line2.location_dest_id + dest_location1 = pick_move_line2.location_dest_id.sudo().copy( + { + "name": orig_dest_location.name + "_1", + "barcode": orig_dest_location.barcode + "_1", + "location_id": orig_dest_location.id, + } + ) + self._zone_picking_process_line(pick_move_line1, dest_location=dest_location1) # Operator-1 process the second pallet with the "zone picking" scenario - dest_location = pick_move_line2.location_dest_id.sudo().copy( + dest_location2 = orig_dest_location.sudo().copy( { - "name": pick_move_line2.location_dest_id.name + "_2", - "barcode": pick_move_line2.location_dest_id.barcode + "_2", - "location_id": pick_move_line2.location_dest_id.id, + "name": orig_dest_location.name + "_2", + "barcode": orig_dest_location.barcode + "_2", + "location_id": orig_dest_location.id, } ) - self._zone_picking_process_line(pick_move_line2, dest_location=dest_location) + self._zone_picking_process_line(pick_move_line2, dest_location=dest_location2) + pack_move_a = pick_move_line1.move_id.move_dest_ids.filtered( + lambda m: m.state not in ("cancel", "done") + ) + self.assertEqual(pack_move_a, self.pack_move_a) + pack_first_pallet = pack_move_a.move_line_ids.filtered( + lambda l: not l.shopfloor_user_id and l.location_id == dest_location1 + ) + self.assertEqual(pack_first_pallet.product_uom_qty, 4) + self.assertEqual(pack_first_pallet.qty_done, 0) + pack_second_pallet = pack_move_a.move_line_ids.filtered( + lambda l: not l.shopfloor_user_id and l.location_id == dest_location2 + ) + self.assertEqual(pack_second_pallet.product_uom_qty, 6) + self.assertEqual(pack_second_pallet.qty_done, 0) + # Operator-2 with the "location content transfer" scenario scan + # the location where the first pallet is. + # This pallet/move line will be put in its own transfer as its sibling + # lines are in another source location. + previous_picking = pack_first_pallet.picking_id + response = self._location_content_transfer_process_line(pack_first_pallet) + new_picking = pack_first_pallet.picking_id + self.assertTrue(previous_picking != new_picking) + self.assert_response_scan_destination_all(response, new_picking) + response_packages = response["data"]["scan_destination_all"]["package_levels"] + self.assertEqual(len(response_packages), 1) + self.assertEqual( + response_packages[0]["package_src"]["id"], pack_first_pallet.package_id.id + ) + # Ensure that the second pallet is untouched + self.assertEqual(pack_second_pallet.qty_done, 0) # Operator-3 with the "location content transfer" scenario scan - # the location where the first pallet is - pack_move_line2 = pick_move_line2.move_id.move_dest_ids.filtered( + # the location where the first pallet is: he should found nothing + response = self._location_content_transfer_process_line( + pack_first_pallet, user=self.stock_user2 + ) + self.assert_response_start( + response, message=self.service.msg_store.new_move_lines_not_assigned() + ) + # Check if Operator-2 is able to recover its session + expected_picking = pack_first_pallet.picking_id + response = self.service.start_or_recover() + self.assert_response_scan_destination_all( + response, + expected_picking, + message=self.service.msg_store.recovered_previous_session(), + ) + # Operator-2 finishes its operation regarding the first pallet + qty = pack_first_pallet.product_uom_qty + response = self.service.set_destination_all( + pack_first_pallet.location_id.id, pack_first_pallet.location_dest_id.barcode + ) + self.assert_response_start( + response, + message=self.service.msg_store.location_content_transfer_complete( + pack_first_pallet.location_id, pack_first_pallet.location_dest_id, + ), + ) + self.assertEqual(pack_first_pallet.qty_done, 4) + self.assertEqual(pack_first_pallet.state, "done") + self.assertEqual(pack_first_pallet.move_id.product_uom_qty, qty) + # Ensure that the second pallet is untouched + self.assertEqual(pack_second_pallet.qty_done, 0) + # Operator-2 (still with the "location content transfer" scenario) scan + # the location where the second pallet is + pack_move_a = pick_move_line2.move_id.move_dest_ids.filtered( lambda m: m.state not in ("cancel", "done") - ).move_line_ids.filtered(lambda l: not l.shopfloor_user_id)[0] - self._location_content_transfer_process_line(pack_move_line2) + ) + self.assertEqual(pack_move_a, self.pack_move_a) + pack_second_pallet = pack_move_a.move_line_ids.filtered( + lambda l: not l.shopfloor_user_id and l.location_id == dest_location2 + ) + picking_before = pack_second_pallet.picking_id + move_lines = self.service._find_location_move_lines( + pack_second_pallet.location_id + ) + response = self._location_content_transfer_process_line(pack_second_pallet) + response_packages = response["data"]["scan_destination_all"]["package_levels"] + self.assertEqual(len(response_packages), 1) + self.assertEqual( + response_packages[0]["package_src"]["id"], pack_second_pallet.package_id.id + ) + picking_after = pack_second_pallet.picking_id + self.assertTrue(picking_before == picking_after) # no picking split + self.assert_response_scan_destination_all(response, picking_after) diff --git a/shopfloor/tests/test_stock_split.py b/shopfloor/tests/test_stock_split.py new file mode 100644 index 0000000000..9a3ac8e852 --- /dev/null +++ b/shopfloor/tests/test_stock_split.py @@ -0,0 +1,128 @@ +from odoo.tests import tagged +from odoo.tests.common import SavepointCase + + +@tagged("post_install", "-at_install") +class TestStockSplit(SavepointCase): + @classmethod + def setUpClass(cls): + super(TestStockSplit, cls).setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.warehouse = cls.env.ref("stock.warehouse0") + cls.warehouse.delivery_steps = "pick_pack_ship" + cls.customer_location = cls.env.ref("stock.stock_location_customers") + cls.pack_location = cls.warehouse.wh_pack_stock_loc_id + cls.ship_location = cls.warehouse.wh_output_stock_loc_id + cls.stock_location = cls.env.ref("stock.stock_location_stock") + # Create a product + cls.product_a = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product A", + "type": "product", + "default_code": "A", + "barcode": "A", + "weight": 2, + } + ) + ) + cls.product_a_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box", + "product_id": cls.product_a.id, + "barcode": "ProductABox", + } + ) + ) + # Put product_a quantities in different packages to get several move lines + cls.package_1 = cls.env["stock.quant.package"].create({"name": "PACKAGE_1"}) + cls.package_2 = cls.env["stock.quant.package"].create({"name": "PACKAGE_2"}) + cls.package_3 = cls.env["stock.quant.package"].create({"name": "PACKAGE_3"}) + cls.package_4 = cls.env["stock.quant.package"].create({"name": "PACKAGE_4"}) + cls._update_qty_in_location( + cls.stock_location, cls.product_a, 6, package=cls.package_1 + ) + cls._update_qty_in_location( + cls.stock_location, cls.product_a, 4, package=cls.package_2 + ) + cls._update_qty_in_location( + cls.stock_location, cls.product_a, 5, package=cls.package_3 + ) + # Create the pick/pack/ship transfer + cls.ship_move_a = cls.env["stock.move"].create( + { + "name": cls.product_a.display_name, + "product_id": cls.product_a.id, + "product_uom_qty": 15.0, + "product_uom": cls.product_a.uom_id.id, + "location_id": cls.ship_location.id, + "location_dest_id": cls.customer_location.id, + "warehouse_id": cls.warehouse.id, + "picking_type_id": cls.warehouse.out_type_id.id, + "procure_method": "make_to_order", + "state": "draft", + } + ) + cls.ship_move_a._assign_picking() + cls.ship_move_a._action_confirm(merge=False) + cls.pack_move = cls.ship_move_a.move_orig_ids[0] + cls.pick_move = cls.pack_move.move_orig_ids[0] + cls.picking = cls.pick_move.picking_id + cls.packing = cls.pack_move.picking_id + cls.picking.action_assign() + + @classmethod + def _update_qty_in_location( + cls, location, product, quantity, package=None, lot=None + ): + quants = cls.env["stock.quant"]._gather( + product, location, lot_id=lot, package_id=package, strict=True + ) + # this method adds the quantity to the current quantity, so remove it + quantity -= sum(quants.mapped("quantity")) + cls.env["stock.quant"]._update_available_quantity( + product, location, quantity, package_id=package, lot_id=lot + ) + + def test_split_pickings_from_source_location(self): + dest_location = self.pick_move.location_dest_id.sudo().copy( + { + "name": self.pick_move.location_dest_id.name + "_2", + "barcode": self.pick_move.location_dest_id.barcode + "_2", + "location_id": self.pick_move.location_dest_id.id, + } + ) + # Pick goods from stock and move some of them to a different destination + self.assertEqual(self.pick_move.state, "assigned") + for i, move_line in enumerate(self.pick_move.move_line_ids): + move_line.qty_done = move_line.product_uom_qty + if i % 2: + move_line.location_dest_id = dest_location + self.pick_move.with_context(_sf_no_backorder=True)._action_done() + self.assertEqual(self.pick_move.state, "done") + # Pack step, we want to split move lines from common source location + self.assertEqual(self.pack_move.state, "assigned") + move_lines_to_process = self.pack_move.move_line_ids.filtered( + lambda ml: ml.location_id == dest_location + ) + self.assertEqual(len(self.pack_move.move_line_ids), 3) + self.assertEqual(len(self.packing.package_level_ids), 3) + self.assertEqual(len(move_lines_to_process), 1) + new_packing = move_lines_to_process._split_pickings_from_source_location() + self.assertEqual(len(self.packing.package_level_ids), 2) + self.assertEqual(len(new_packing.package_level_ids), 1) + self.assertEqual(len(new_packing.move_line_ids), 1) + self.assertTrue(new_packing != self.packing) + self.assertEqual(new_packing.backorder_id, self.packing) + self.assertEqual( + self.pick_move.move_dest_ids.picking_id, self.packing | new_packing + ) + self.assertEqual(move_lines_to_process.state, "assigned") + self.assertEqual( + set(self.pack_move.move_line_ids.mapped("state")), {"assigned"} + ) From 618c33624846f092be2ea5deaeeecc2e1ca2faad Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 26 Aug 2020 13:15:15 +0200 Subject: [PATCH 336/940] api: return related move line's location instead of picking's --- shopfloor/actions/data.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index b45da63a28..d8173033a8 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -1,3 +1,5 @@ +from odoo import fields + from odoo.addons.component.core import Component @@ -170,7 +172,11 @@ def _package_level_parser(self): ("location_dest_id:location_dest", self._location_parser), ( "location_id:location_src", - lambda rec, fname: self.location(rec.picking_id.location_id), + lambda rec, fname: self.location( + fields.first(rec.move_line_ids).location_id + or fields.first(rec.move_ids).location_id + or rec.picking_id.location_id + ), ), # tnx to stock_quant_package_product_packaging ( From 18e86e8f7ce077822254df02b51760282410c023 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 26 Aug 2020 15:41:10 +0200 Subject: [PATCH 337/940] bknd: fix zone picking types counters --- shopfloor/services/zone_picking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index d9f36bc3da..1150b68599 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -205,7 +205,7 @@ def _data_for_select_picking_type(self, zone_location, picking_types): def _picking_type_zone_lines(self, zone_location, picking_type): return self.env["stock.move.line"].search( [ - ("location_id", "=", zone_location.id), + ("location_id", "child_of", zone_location.id), # we have auto_join on picking_id ("picking_id.picking_type_id", "=", picking_type.id), ("qty_done", "=", 0), From b5d332bcd6a3f4ecc75e15a741795ab5380349ee Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 26 Aug 2020 14:13:49 +0200 Subject: [PATCH 338/940] location transfer: exit with proper message when no line is found in set_destination_all, likely because someone did it meanwhile --- shopfloor/services/location_content_transfer.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 5552dae16a..0222626737 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -354,6 +354,10 @@ def set_destination_all(self, location_id, barcode, confirmation=False): return self._response_for_start(message=self.msg_store.record_not_found()) move_lines = self._find_transfer_move_lines(location) pickings = move_lines.mapped("picking_id") + if not pickings: + # if we can't find the lines anymore, they likely have been done + # by someone else + return self._response_for_start(message=self.msg_store.already_done()) scanned_location = self.actions_for("search").location_from_scan(barcode) if not scanned_location: return self._response_for_scan_destination_all( From 0f2600cfcdc18982c0546093d2cc7c8c350bf716 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 26 Aug 2020 14:54:48 +0200 Subject: [PATCH 339/940] backend: Write destintation location on package levels In every place that write a destination location on move lines, ensure we write it on the package level too. If the lines have no package level, it'll only be an empty write. --- shopfloor/services/cluster_picking.py | 1 + shopfloor/services/single_pack_transfer.py | 4 +++- shopfloor/services/zone_picking.py | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index a1924e1fad..a1867cb7db 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -957,6 +957,7 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): def _unload_write_destination_on_lines(self, lines, location): lines.write({"shopfloor_unloaded": True, "location_dest_id": location.id}) + lines.package_level_id.location_dest_id = location for line in lines: # We set the picking to done only when the last line is # unloaded to avoid backorders. diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 91a098355f..308a420b94 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -204,7 +204,9 @@ def _router_validate_success(self, package_level): return self._response_for_start(message=message, popup=completion_info_popup) def _set_destination_and_done(self, move, scanned_location): - move.move_line_ids[0].location_dest_id = scanned_location.id + # when writing the destination on the package level, it writes + # on the move lines + move.move_line_ids.package_level_id.location_dest_id = scanned_location move._action_done() def cancel(self, package_level_id): diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 1150b68599..5e8956eb52 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -1075,6 +1075,7 @@ def set_destination_all( def _write_destination_on_lines(self, lines, location): self._lock_lines(lines) lines.location_dest_id = location + lines.package_level_id.location_dest_id = location def unload_split(self, zone_location_id, picking_type_id): """Indicates that now the buffer must be treated line per line From d6f887df77830d8386b505dcd2d2192f352d9c64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 27 Aug 2020 11:31:23 +0200 Subject: [PATCH 340/940] shopfloor: ensure that confirmation email is sent when moves are validated (#53) --- shopfloor/models/stock_move.py | 13 ++ shopfloor/models/stock_picking.py | 13 ++ ...ransfer_set_destination_package_or_line.py | 114 +++++++++++------- shopfloor/tests/test_single_pack_transfer.py | 78 +++++++----- .../test_zone_picking_set_line_destination.py | 106 +++++++++------- .../tests/test_zone_picking_unload_all.py | 22 ++-- ...est_zone_picking_unload_set_destination.py | 48 +++++--- 7 files changed, 247 insertions(+), 147 deletions(-) diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py index f700f1bec7..c641389dcb 100644 --- a/shopfloor/models/stock_move.py +++ b/shopfloor/models/stock_move.py @@ -24,3 +24,16 @@ def split_other_move_lines(self, move_lines, intersection=False): backorder_move._action_assign() return backorder_move return False + + def _action_done(self, cancel_backorder=False): + # Overloaded to send the email when the last move of a picking is validated. + # The method 'stock.picking._send_confirmation_email' is called only from + # the 'stock.picking.action_done()' method but never when moves are + # validated partially through the current method. + moves = super()._action_done(cancel_backorder) + if not self.env.context.get("_action_done_from_picking"): + pickings = moves.picking_id + for picking in pickings: + if picking.state == "done": + picking._send_confirmation_email() + return moves diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py index a47b4d87ec..90e10fd131 100644 --- a/shopfloor/models/stock_picking.py +++ b/shopfloor/models/stock_picking.py @@ -42,3 +42,16 @@ def _create_backorder(self): return self.browse() else: return super()._create_backorder() + + def action_done(self): + self = self.with_context(_action_done_from_picking=True) + return super().action_done() + + def _send_confirmation_email(self): + # Avoid sending the confirmation email twice (one when the + # 'picking.action_done()' is called, and one when the last move of this + # picking is validated through 'move._action_done()') + # We send the confirmation email + if self.env.context.get("_action_done_from_picking"): + return + super()._send_confirmation_email() diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py index b46855f270..f8fd7db29b 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py @@ -1,3 +1,5 @@ +from unittest import mock + from .test_location_content_transfer_base import LocationContentTransferCommonCase @@ -130,14 +132,18 @@ def test_set_destination_package_dest_location_to_confirm(self): def test_set_destination_package_dest_location_ok(self): """Scanned destination location valid, moves set to done.""" package_level = self.picking1.package_level_ids[0] - response = self.service.dispatch( - "set_destination_package", - params={ - "location_id": self.content_loc.id, - "package_level_id": package_level.id, - "barcode": self.dest_location.barcode, - }, - ) + with mock.patch.object( + type(self.picking1), "_send_confirmation_email" + ) as send_confirmation_email: + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.dest_location.barcode, + }, + ) + send_confirmation_email.assert_called_once() move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( response, @@ -301,15 +307,19 @@ def test_set_destination_line_partial_qty(self): self.assertEqual(move_line_c.move_id.state, "done") # Scan remaining qty (4/10) remaining_move_line_c = move_product_c_splitted.move_line_ids - response = self.service.dispatch( - "set_destination_line", - params={ - "location_id": self.content_loc.id, - "move_line_id": remaining_move_line_c.id, - "quantity": remaining_move_line_c.product_uom_qty, - "barcode": self.dest_location.barcode, - }, - ) + with mock.patch.object( + type(self.picking2), "_send_confirmation_email" + ) as send_confirmation_email: + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": remaining_move_line_c.id, + "quantity": remaining_move_line_c.product_uom_qty, + "barcode": self.dest_location.barcode, + }, + ) + send_confirmation_email.assert_not_called() # Check move line data self.assertEqual(remaining_move_line_c.move_id.product_uom_qty, 4) self.assertEqual(remaining_move_line_c.product_uom_qty, 0) @@ -329,20 +339,24 @@ def test_set_destination_line_partial_qty(self): move_line_d = self.picking2.move_line_ids.filtered( lambda m: m.product_id == self.product_d ) - response = self.service.dispatch( - "set_destination_line", - params={ - "location_id": self.content_loc.id, - "move_line_id": move_line_d.id, - "quantity": move_line_d.product_uom_qty, - "barcode": self.dest_location.barcode, - }, - ) - self.assertEqual(move_line_d.move_id.product_uom_qty, 10) - self.assertEqual(move_line_d.product_uom_qty, 0) - self.assertEqual(move_line_d.qty_done, 10) - self.assertEqual(move_line_d.state, "done") - self.assertEqual(self.picking2.state, "done") + with mock.patch.object( + type(self.picking2), "_send_confirmation_email" + ) as send_confirmation_email: + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line_d.id, + "quantity": move_line_d.product_uom_qty, + "barcode": self.dest_location.barcode, + }, + ) + self.assertEqual(move_line_d.move_id.product_uom_qty, 10) + self.assertEqual(move_line_d.product_uom_qty, 0) + self.assertEqual(move_line_d.qty_done, 10) + self.assertEqual(move_line_d.state, "done") + self.assertEqual(self.picking2.state, "done") + send_confirmation_email.assert_called_once() class LocationContentTransferSetDestinationXSpecialCase( @@ -506,24 +520,32 @@ def test_set_destination_line_split_move(self): remaining_move_lines = self.picking.move_line_ids_without_package.filtered( lambda ml: ml.state == "assigned" ) - for ml in remaining_move_lines: + with mock.patch.object( + type(self.picking), "_send_confirmation_email" + ) as send_confirmation_email: + for ml in remaining_move_lines: + self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": ml.id, + "quantity": ml.product_uom_qty, + "barcode": self.dest_location.barcode, + }, + ) + self.assertEqual(self.picking.state, "assigned") + send_confirmation_email.assert_not_called() + package_level = self.picking.package_level_ids[0] + with mock.patch.object( + type(self.picking), "_send_confirmation_email" + ) as send_confirmation_email: self.service.dispatch( - "set_destination_line", + "set_destination_package", params={ "location_id": self.content_loc.id, - "move_line_id": ml.id, - "quantity": ml.product_uom_qty, + "package_level_id": package_level.id, "barcode": self.dest_location.barcode, }, ) - self.assertEqual(self.picking.state, "assigned") - package_level = self.picking.package_level_ids[0] - self.service.dispatch( - "set_destination_package", - params={ - "location_id": self.content_loc.id, - "package_level_id": package_level.id, - "barcode": self.dest_location.barcode, - }, - ) - self.assertEqual(self.picking.state, "done") + self.assertEqual(self.picking.state, "done") + send_confirmation_email.assert_called_once() diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index c98b8870cf..3397279830 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -1,3 +1,5 @@ +from unittest import mock + from odoo.tests.common import Form from .common import CommonCase @@ -397,13 +399,17 @@ def test_validate(self): # now, call the service to proceed with validation of the # movement - response = self.service.dispatch( - "validate", - params={ - "package_level_id": package_level.id, - "location_barcode": self.shelf2.barcode, - }, - ) + with mock.patch.object( + type(self.picking), "_send_confirmation_email" + ) as send_confirmation_email: + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": self.shelf2.barcode, + }, + ) + send_confirmation_email.assert_called_once() self.assert_response( response, @@ -465,13 +471,17 @@ def test_validate_completion_info(self): # now, call the service to proceed with validation of the # movement - response = self.service.dispatch( - "validate", - params={ - "package_level_id": package_level.id, - "location_barcode": self.shelf2.barcode, - }, - ) + with mock.patch.object( + type(self.picking), "_send_confirmation_email" + ) as send_confirmation_email: + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": self.shelf2.barcode, + }, + ) + send_confirmation_email.assert_called_once() self.assert_response( response, @@ -595,13 +605,17 @@ def test_validate_location_to_confirm(self): # expected destination is 'shelf2', we'll scan shelf1 which must # ask a confirmation to the user (it's still in the same picking type) - response = self.service.dispatch( - "validate", - params={ - "package_level_id": package_level.id, - "location_barcode": self.shelf1.barcode, - }, - ) + with mock.patch.object( + type(self.picking), "_send_confirmation_email" + ) as send_confirmation_email: + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": self.shelf1.barcode, + }, + ) + send_confirmation_email.assert_not_called() message = self.service.actions_for("message").confirm_location_changed( self.shelf2, self.shelf1 @@ -641,15 +655,19 @@ def test_validate_location_with_confirm(self): # expected destination is 'shelf1', we'll scan shelf2 which must # ask a confirmation to the user (it's still in the same picking type) - response = self.service.dispatch( - "validate", - params={ - "package_level_id": package_level.id, - "location_barcode": self.shelf2.barcode, - # acknowledge the change of destination - "confirmation": True, - }, - ) + with mock.patch.object( + type(self.picking), "_send_confirmation_email" + ) as send_confirmation_email: + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": self.shelf2.barcode, + # acknowledge the change of destination + "confirmation": True, + }, + ) + send_confirmation_email.assert_called_once() self.assert_response( response, diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py index f1414d6823..da2f0e110f 100644 --- a/shopfloor/tests/test_zone_picking_set_line_destination.py +++ b/shopfloor/tests/test_zone_picking_set_line_destination.py @@ -1,3 +1,5 @@ +from unittest import mock + from .test_zone_picking_base import ZonePickingCommonCase @@ -150,17 +152,21 @@ def test_set_destination_location_no_other_move_line_full_qty(self): self.assertEqual(len(moves_before), 1) self.assertEqual(len(moves_before.move_line_ids), 1) move_line = moves_before.move_line_ids - response = self.service.dispatch( - "set_destination", - params={ - "zone_location_id": zone_location.id, - "picking_type_id": picking_type.id, - "move_line_id": move_line.id, - "barcode": self.packing_location.barcode, - "quantity": move_line.product_uom_qty, - "confirmation": False, - }, - ) + with mock.patch.object( + type(self.picking1), "_send_confirmation_email" + ) as send_confirmation_email: + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": move_line.product_uom_qty, + "confirmation": False, + }, + ) + send_confirmation_email.assert_called_once() # Check picking data moves_after = self.picking1.move_lines self.assertEqual(moves_before, moves_after) @@ -200,17 +206,21 @@ def test_set_destination_location_no_other_move_line_partial_qty(self): move_line = moves_before.move_line_ids # we need a destination package if we want to scan a destination location move_line.result_package_id = self.free_package - response = self.service.dispatch( - "set_destination", - params={ - "zone_location_id": zone_location.id, - "picking_type_id": picking_type.id, - "move_line_id": move_line.id, - "barcode": barcode, - "quantity": 6, - "confirmation": False, - }, - ) + with mock.patch.object( + type(self.picking3), "_send_confirmation_email" + ) as send_confirmation_email: + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": barcode, + "quantity": 6, + "confirmation": False, + }, + ) + send_confirmation_email.assert_not_called() self.assert_response_set_line_destination( response, zone_location, @@ -247,17 +257,21 @@ def test_set_destination_location_several_move_line_full_qty(self): # we need a destination package if we want to scan a destination location move_line.result_package_id = self.free_package other_move_line = moves_before.move_line_ids[1] - response = self.service.dispatch( - "set_destination", - params={ - "zone_location_id": zone_location.id, - "picking_type_id": picking_type.id, - "move_line_id": move_line.id, - "barcode": self.packing_location.barcode, - "quantity": move_line.product_uom_qty, # 6 qty - "confirmation": False, - }, - ) + with mock.patch.object( + type(self.picking4), "_send_confirmation_email" + ) as send_confirmation_email: + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": move_line.product_uom_qty, # 6 qty + "confirmation": False, + }, + ) + send_confirmation_email.assert_not_called() # Check picking data (move has been split in two, 6 done and 4 remaining) moves_after = self.picking4.move_lines self.assertEqual(len(moves_after), 2) @@ -306,17 +320,21 @@ def test_set_destination_location_several_move_line_partial_qty(self): move_line = moves_before.move_line_ids[0] # we need a destination package if we want to scan a destination location move_line.result_package_id = self.free_package - response = self.service.dispatch( - "set_destination", - params={ - "zone_location_id": zone_location.id, - "picking_type_id": picking_type.id, - "move_line_id": move_line.id, - "barcode": barcode, - "quantity": 4, # 4/6 qty - "confirmation": False, - }, - ) + with mock.patch.object( + type(self.picking4), "_send_confirmation_email" + ) as send_confirmation_email: + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": barcode, + "quantity": 4, # 4/6 qty + "confirmation": False, + }, + ) + send_confirmation_email.assert_not_called() self.assert_response_set_line_destination( response, zone_location, diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py index 86ebbc4ed9..d9ab4eb084 100644 --- a/shopfloor/tests/test_zone_picking_unload_all.py +++ b/shopfloor/tests/test_zone_picking_unload_all.py @@ -1,3 +1,5 @@ +from unittest import mock + from .test_zone_picking_base import ZonePickingCommonCase @@ -159,14 +161,18 @@ def test_set_destination_all_ok(self): another_package, ) # set destination location for all lines in the buffer - response = self.service.dispatch( - "set_destination_all", - params={ - "zone_location_id": zone_location.id, - "picking_type_id": picking_type.id, - "barcode": self.packing_location.barcode, - }, - ) + with mock.patch.object( + type(self.picking5), "_send_confirmation_email" + ) as send_confirmation_email: + response = self.service.dispatch( + "set_destination_all", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": self.packing_location.barcode, + }, + ) + send_confirmation_email.assert_called_once() # check data self.assertEqual(self.picking5.state, "done") # buffer should be empty diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py index 38492df979..afd3a92465 100644 --- a/shopfloor/tests/test_zone_picking_unload_set_destination.py +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -1,3 +1,5 @@ +from unittest import mock + from .test_zone_picking_base import ZonePickingCommonCase @@ -179,16 +181,20 @@ def test_unload_set_destination_ok_buffer_empty(self): move_line.product_uom_qty, self.free_package, ) - response = self.service.dispatch( - "unload_set_destination", - params={ - "zone_location_id": zone_location.id, - "picking_type_id": picking_type.id, - "package_id": self.free_package.id, - "barcode": packing_sublocation.barcode, - "confirmation": True, - }, - ) + with mock.patch.object( + type(self.picking1), "_send_confirmation_email" + ) as send_confirmation_email: + response = self.service.dispatch( + "unload_set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "package_id": self.free_package.id, + "barcode": packing_sublocation.barcode, + "confirmation": True, + }, + ) + send_confirmation_email.assert_called_once() # check data self.assertEqual(move_line.location_dest_id, packing_sublocation) self.assertEqual(move_line.move_id.state, "done") @@ -220,15 +226,19 @@ def test_unload_set_destination_ok_buffer_not_empty(self): package_dest, ) # process 1/2 buffer line - response = self.service.dispatch( - "unload_set_destination", - params={ - "zone_location_id": zone_location.id, - "picking_type_id": picking_type.id, - "package_id": self.free_package.id, - "barcode": self.packing_location.barcode, - }, - ) + with mock.patch.object( + type(self.picking5), "_send_confirmation_email" + ) as send_confirmation_email: + response = self.service.dispatch( + "unload_set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "package_id": self.free_package.id, + "barcode": self.packing_location.barcode, + }, + ) + send_confirmation_email.assert_not_called() # check data move_line = self.picking5.move_line_ids.filtered( lambda l: l.result_package_id == self.free_package From 2a8e1c697fd6f707088ec5e23530bcd4c1feb4eb Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 27 Aug 2020 14:48:12 +0200 Subject: [PATCH 341/940] bknd: map specific exceptions to odoo std exceptions We were losing the possibility to display the txt msg of the error because everything was wrapped by base_rest into InternalServerError. --- shopfloor/services/service.py | 55 +++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 877d561a45..35dbe6fcd5 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -28,6 +28,18 @@ def __init__(self, message, log_entry_url): self.rest_json_info = {"log_entry_url": log_entry_url} +class ShopfloorServiceUserErrorException( + ShopfloorServiceDispatchException, exceptions.UserError +): + """User error wrapped exception.""" + + +class ShopfloorServiceValidationErrorException( + ShopfloorServiceDispatchException, exceptions.ValidationError +): + """Validation error wrapped exception.""" + + class BaseShopfloorService(AbstractComponent): """Base class for REST services""" @@ -55,20 +67,45 @@ def _db_logging_active(self): def _dispatch_with_db_logging(self, method_name, _id=None, params=None): try: result = super().dispatch(method_name, _id=_id, params=params) - except Exception as err: - tb = traceback.format_exc() - self.env.cr.rollback() - with registry(self.env.cr.dbname).cursor() as cr: - env = self.env(cr=cr) - log_entry = self._log_call_in_db(env, request, _id, params, error=tb) - log_entry_url = self._get_log_entry_url(log_entry) - raise ShopfloorServiceDispatchException(str(err), log_entry_url) from err - + except exceptions.UserError as orig_exception: + self._dispatch_exception( + ShopfloorServiceUserErrorException, + orig_exception, + _id=_id, + params=params, + ) + except exceptions.ValidationError as orig_exception: + self._dispatch_exception( + ShopfloorServiceValidationErrorException, + orig_exception, + _id=_id, + params=params, + ) + except Exception as orig_exception: + self._dispatch_exception( + ShopfloorServiceDispatchException, + orig_exception, + _id=_id, + params=params, + ) log_entry = self._log_call_in_db(self.env, request, _id, params, result=result) log_entry_url = self._get_log_entry_url(log_entry) result["log_entry_url"] = log_entry_url return result + def _dispatch_exception( + self, exception_klass, orig_exception, _id=None, params=None + ): + tb = traceback.format_exc() + self.env.cr.rollback() + with registry(self.env.cr.dbname).cursor() as cr: + env = self.env(cr=cr) + log_entry = self._log_call_in_db(env, request, _id, params, error=tb) + log_entry_url = self._get_log_entry_url(log_entry) + # UserError and alike have `name` attribute to store the msg + exc_msg = getattr(orig_exception, "name", str(orig_exception)) + raise exception_klass(exc_msg, log_entry_url) from orig_exception + def _get_log_entry_url(self, entry): base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") url_params = { From 32fa231a2e70a8d846c0c4bbd7deb338bb6b3df5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Aug 2020 14:19:04 +0200 Subject: [PATCH 342/940] Display product vendor code everywhere --- shopfloor/actions/data.py | 4 ++++ shopfloor/services/schema.py | 1 + shopfloor/tests/test_actions_data.py | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index d8173033a8..86343d1013 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -206,6 +206,7 @@ def _product_parser(self): "barcode", ("packaging_ids:packaging", self._product_packaging), ("uom_id:uom", self._simple_record_parser() + ["factor", "rounding"]), + ("seller_ids:supplier_code", self._product_supplier_code), ] def _product_packaging(self, rec, field): @@ -215,6 +216,9 @@ def _product_packaging(self, rec, field): multi=True, ) + def _product_supplier_code(self, rec, field): + return rec.seller_ids[0].product_code or "" if rec.seller_ids else "" + def picking_batch(self, record, with_pickings=False, **kw): parser = self._picking_batch_parser if with_pickings: diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 16da390bb2..24d0480c8b 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -111,6 +111,7 @@ def product(self): "display_name": {"type": "string", "nullable": False, "required": True}, "default_code": {"type": "string", "nullable": False, "required": True}, "barcode": {"type": "string", "nullable": True, "required": False}, + "supplier_code": {"type": "string", "nullable": True, "required": False}, "packaging": self._schema_list_of(self.packaging()), "uom": self._schema_dict_of( self._simple_record( diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 0e9d12532e..23cf1c41e9 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -51,6 +51,21 @@ def setUpClassBaseData(cls): (cls.move_a + cls.move_b + cls.move_c + cls.move_d).write({"priority": "1"}) cls.picking.action_assign() + cls.supplier = cls.env["res.partner"].sudo().create({"name": "Supplier"}) + cls.vendor = ( + cls.env["product.supplierinfo"] + .sudo() + .create( + { + "name": cls.supplier.id, + "price": 8.0, + "product_code": "VENDOR_CODE", + "product_id": cls.product_a.id, + "product_tmpl_id": cls.product_a.product_tmpl_id.id, + } + ) + ) + def assert_schema(self, schema, data): validator = Validator(schema) self.assertTrue(validator.validate(data), validator.errors) @@ -80,6 +95,9 @@ def _expected_product(self, record, **kw): "name": record.uom_id.name, "rounding": record.uom_id.rounding, }, + "supplier_code": record.seller_ids[0].product_code + if record.seller_ids + else "", } data.update(kw) return data From 32daa0535f9d8ca07b7331e8e33915ad64ad1d91 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 2 Sep 2020 10:44:07 +0200 Subject: [PATCH 343/940] shopfloor.__manifest__ update references --- shopfloor/__manifest__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 7f89d4184a..6ae74a4daa 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -17,11 +17,11 @@ "base_rest", "base_jsonify", "auth_api_key", - # https://github.com/OCA/stock-logistics-warehouse/pull/808 + # OCA / stock-logistics-warehouse "stock_picking_completion_info", - # https://github.com/OCA/stock-logistics-workflow/pull/608 + # OCA / stock-logistics-warehouse "stock_quant_package_dimension", - # https://github.com/OCA/stock-logistics-workflow/pull/607 + # OCA / stock-logistics-warehouse "stock_quant_package_product_packaging", # TODO: used for manuf info on prod detail. # This must be an optional dep From 75b08e0ab17b01a628d3db9517fe698c1ab49e2c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 2 Sep 2020 10:50:50 +0200 Subject: [PATCH 344/940] Packaging data: rely on packaging type Packaging type gives more information on the packaging and its name is translatable. --- shopfloor/__manifest__.py | 2 ++ shopfloor/actions/data.py | 7 ++++++- shopfloor/services/schema.py | 1 + shopfloor/tests/test_actions_data.py | 14 ++++++++++++-- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 6ae74a4daa..4412ed12e6 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -35,6 +35,8 @@ # TODO: used for picking.carrier_id detail info. # This must be an optional dep "delivery", + # OCA / product-attribute + "product_packaging_type", ], "data": [ "data/ir_config_parameter_data.xml", diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 86343d1013..b6f687999d 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -107,7 +107,12 @@ def packaging_list(self, record, **kw): @property def _packaging_parser(self): - return self._simple_record_parser() + ["qty"] + return [ + "id", + ("packaging_type_id:name", lambda rec, fname: rec.packaging_type_id.name), + ("packaging_type_id:code", lambda rec, fname: rec.packaging_type_id.code), + "qty", + ] def lot(self, record, **kw): return self._jsonify(record, self._lot_parser, **kw) diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 24d0480c8b..d1d424c3a8 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -151,6 +151,7 @@ def packaging(self): return { "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, + "code": {"type": "string", "nullable": True, "required": True}, "qty": {"type": "float", "required": True}, } diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 23cf1c41e9..d0d61adaef 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -24,7 +24,16 @@ def setUpClassVars(cls): @classmethod def setUpClassBaseData(cls): super().setUpClassBaseData() - cls.packaging = cls.env["product.packaging"].sudo().create({"name": "Pallet"}) + cls.packaging_type = ( + cls.env["product.packaging.type"] + .sudo() + .create({"name": "Transport Box", "code": "TB", "sequence": 0}) + ) + cls.packaging = ( + cls.env["product.packaging"] + .sudo() + .create({"name": "Pallet", "packaging_type_id": cls.packaging_type.id}) + ) cls.product_b.tracking = "lot" cls.product_c.tracking = "lot" cls.picking = cls._create_picking( @@ -105,7 +114,8 @@ def _expected_product(self, record, **kw): def _expected_packaging(self, record, **kw): data = { "id": record.id, - "name": record.name, + "name": record.packaging_type_id.name, + "code": record.packaging_type_id.code, "qty": record.qty, } data.update(kw) From cd9401e65da29a6245c3d74bfb6d56ac75ae5aa6 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 28 Aug 2020 16:15:14 +0200 Subject: [PATCH 345/940] Add active field on shopfloor profile It already exists on the menus. --- shopfloor/models/shopfloor_profile.py | 1 + shopfloor/views/shopfloor_profile_views.xml | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/shopfloor/models/shopfloor_profile.py b/shopfloor/models/shopfloor_profile.py index ec9e66c792..eb6b6e1309 100644 --- a/shopfloor/models/shopfloor_profile.py +++ b/shopfloor/models/shopfloor_profile.py @@ -14,6 +14,7 @@ class ShopfloorProfile(models.Model): menu_ids = fields.Many2many( "shopfloor.menu", string="Menus", help="Menus visible for this profile" ) + active = fields.Boolean(default=True) @api.model def _default_warehouse_id(self): diff --git a/shopfloor/views/shopfloor_profile_views.xml b/shopfloor/views/shopfloor_profile_views.xml index 785aa6231d..7a95ac5506 100644 --- a/shopfloor/views/shopfloor_profile_views.xml +++ b/shopfloor/views/shopfloor_profile_views.xml @@ -16,12 +16,19 @@
+ + @@ -35,6 +42,12 @@ + + From decb9ce9a6433b00c57cd467c6d70aab72dab392 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 31 Aug 2020 12:11:07 +0200 Subject: [PATCH 346/940] bknd: fix vendor code by variant --- shopfloor/actions/data.py | 5 +++- shopfloor/tests/test_actions_data.py | 37 ++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index b6f687999d..0ad4f88d10 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -222,7 +222,10 @@ def _product_packaging(self, rec, field): ) def _product_supplier_code(self, rec, field): - return rec.seller_ids[0].product_code or "" if rec.seller_ids else "" + supplier_info = fields.first( + rec.seller_ids.filtered(lambda x: x.product_id == rec) + ) + return supplier_info.product_code if supplier_info else "" def picking_batch(self, record, with_pickings=False, **kw): parser = self._picking_batch_parser diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index d0d61adaef..43bff8b1e3 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -61,19 +61,44 @@ def setUpClassBaseData(cls): cls.picking.action_assign() cls.supplier = cls.env["res.partner"].sudo().create({"name": "Supplier"}) - cls.vendor = ( + cls.product_a_vendor = ( cls.env["product.supplierinfo"] .sudo() .create( { "name": cls.supplier.id, "price": 8.0, - "product_code": "VENDOR_CODE", + "product_code": "VENDOR_CODE_A", "product_id": cls.product_a.id, "product_tmpl_id": cls.product_a.product_tmpl_id.id, } ) ) + cls.product_a_variant = cls.product_a.copy( + { + "name": "Product A variant 1", + "type": "product", + "default_code": "A-VARIANT", + "barcode": "A-VARIANT", + } + ) + # create another supplier info w/ lower sequence + cls.product_a_vendor = ( + cls.env["product.supplierinfo"] + .sudo() + .create( + { + "name": cls.supplier.id, + "price": 12.0, + "product_code": "VENDOR_CODE_VARIANT", + "product_id": cls.product_a_variant.id, + "product_tmpl_id": cls.product_a.product_tmpl_id.id, + "sequence": 0, + } + ) + ) + cls.product_a_variant.flush() + cls.product_a_vendor.flush() def assert_schema(self, schema, data): validator = Validator(schema) @@ -104,13 +129,15 @@ def _expected_product(self, record, **kw): "name": record.uom_id.name, "rounding": record.uom_id.rounding, }, - "supplier_code": record.seller_ids[0].product_code - if record.seller_ids - else "", + "supplier_code": self._expected_supplier_code(record), } data.update(kw) return data + def _expected_supplier_code(self, product): + supplier_info = product.seller_ids.filtered(lambda x: x.product_id == product) + return supplier_info[0].product_code if supplier_info else "" + def _expected_packaging(self, record, **kw): data = { "id": record.id, From e415fdf6eb864c566ed2ce4d4e79e044b5a756d0 Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 27 Aug 2020 14:55:50 +0200 Subject: [PATCH 347/940] zone picking: fix check against destination location when unloading --- shopfloor/services/zone_picking.py | 25 +++++++--- .../tests/test_zone_picking_unload_all.py | 50 ++++++++++++++++--- ...est_zone_picking_unload_set_destination.py | 22 ++++++-- 3 files changed, 77 insertions(+), 20 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 5e8956eb52..16b850c0a9 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -1048,19 +1048,23 @@ def set_destination_all( zone_location, picking_type, buffer_lines, message=error ) # check if the destination location is not the expected one - if location != picking_type.default_location_dest_id: + # - OK if the scanned destination is a child of the current + # destination set on buffer lines + # - To confirm if the scanned destination is not a child of the + # current destination set on buffer lines + if not location.is_sublocation_of(buffer_lines.location_dest_id): if not confirmation: return self._response_for_unload_all( zone_location, picking_type, buffer_lines, message=self.msg_store.confirm_location_changed( - picking_type.default_location_dest_id, location + first(buffer_lines.location_dest_id), location ), confirmation_required=True, ) - # the scanned location is still valid, use it - self._write_destination_on_lines(buffer_lines, location) + # the scanned location is still valid, use it + self._write_destination_on_lines(buffer_lines, location) # set lines to done + refresh buffer lines (should be empty) moves = buffer_lines.mapped("move_id") moves.with_context(_sf_no_backorder=True)._action_done() @@ -1242,19 +1246,24 @@ def unload_set_destination( first(buffer_lines), message=self.msg_store.dest_location_not_allowed(), ) - if location != picking_type.default_location_dest_id: + # check if the destination location is not the expected one + # - OK if the scanned destination is a child of the current + # destination set on buffer lines + # - To confirm if the scanned destination is not a child of the + # current destination set on buffer lines + if not location.is_sublocation_of(buffer_lines.location_dest_id): if not confirmation: return self._response_for_unload_set_destination( zone_location, picking_type, first(buffer_lines), message=self.msg_store.confirm_location_changed( - picking_type.default_location_dest_id, location + first(buffer_lines.location_dest_id), location ), confirmation_required=True, ) - # the scanned location is valid, use it - self._write_destination_on_lines(buffer_lines, location) + # the scanned location is valid, use it + self._write_destination_on_lines(buffer_lines, location) # set lines to done + refresh buffer lines (should be empty) moves = buffer_lines.mapped("move_id") moves.with_context(_sf_no_backorder=True)._action_done() diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py index d9ab4eb084..7a37d01241 100644 --- a/shopfloor/tests/test_zone_picking_unload_all.py +++ b/shopfloor/tests/test_zone_picking_unload_all.py @@ -89,14 +89,25 @@ def test_set_destination_all_confirm_destination(self): another_package = self.env["stock.quant.package"].create( {"name": "ANOTHER_PACKAGE"} ) - packing_sublocation = ( + packing_sublocation1 = ( self.env["stock.location"] .sudo() .create( { - "name": "Packing sublocation", + "name": "Packing sublocation-1", "location_id": self.packing_location.id, - "barcode": "PACKING_SUBLOCATION", + "barcode": "PACKING_SUBLOCATION_1", + } + ) + ) + packing_sublocation2 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Packing sublocation-2", + "location_id": self.packing_location.id, + "barcode": "PACKING_SUBLOCATION_2", } ) ) @@ -115,16 +126,20 @@ def test_set_destination_all_confirm_destination(self): move_line2.product_uom_qty, another_package, ) - # set destination location for all lines in the buffer + # set an allowed destination location (inside the picking type default + # destination location) for all lines in the buffer with a non-expected + # one, meaning a destination which is not a child of the current buffer + # lines destination + (move_line1 | move_line2).location_dest_id = packing_sublocation1 response = self.service.dispatch( "set_destination_all", params={ "zone_location_id": zone_location.id, "picking_type_id": picking_type.id, - "barcode": packing_sublocation.barcode, + "barcode": packing_sublocation2.barcode, }, ) - # check response + # check response: this destination needs the user confirmation buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) self.assert_response_unload_all( response, @@ -132,10 +147,31 @@ def test_set_destination_all_confirm_destination(self): picking_type, buffer_lines, message=self.service.msg_store.confirm_location_changed( - picking_type.default_location_dest_id, packing_sublocation, + packing_sublocation1, packing_sublocation2, ), confirmation_required=True, ) + # set an allowed destination location (inside the picking type default + # destination location) for all lines in the buffer with an expected one + # meaning a destination which is a child of the current buffer lines + # destination + response = self.service.dispatch( + "set_destination_all", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": packing_sublocation1.barcode, + }, + ) + # check response: OK + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.buffer_complete(), + ) def test_set_destination_all_ok(self): zone_location = self.zone_location diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py index afd3a92465..2024d16524 100644 --- a/shopfloor/tests/test_zone_picking_unload_set_destination.py +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -119,14 +119,25 @@ def test_unload_set_destination_confirm_location(self): zone_location = self.zone_location picking_type = self.picking1.picking_type_id move_line = self.picking1.move_line_ids - packing_sublocation = ( + packing_sublocation1 = ( self.env["stock.location"] .sudo() .create( { - "name": "Packing sublocation", + "name": "Packing sublocation-1", "location_id": self.packing_location.id, - "barcode": "PACKING_SUBLOCATION", + "barcode": "PACKING_SUBLOCATIO_1", + } + ) + ) + packing_sublocation2 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Packing sublocation-2", + "location_id": self.packing_location.id, + "barcode": "PACKING_SUBLOCATIO_2", } ) ) @@ -138,13 +149,14 @@ def test_unload_set_destination_confirm_location(self): move_line.product_uom_qty, self.free_package, ) + move_line.location_dest_id = packing_sublocation1 response = self.service.dispatch( "unload_set_destination", params={ "zone_location_id": zone_location.id, "picking_type_id": picking_type.id, "package_id": self.free_package.id, - "barcode": packing_sublocation.barcode, + "barcode": packing_sublocation2.barcode, }, ) self.assert_response_unload_set_destination( @@ -153,7 +165,7 @@ def test_unload_set_destination_confirm_location(self): picking_type, move_line, message=self.service.msg_store.confirm_location_changed( - picking_type.default_location_dest_id, packing_sublocation + packing_sublocation1, packing_sublocation2 ), confirmation_required=True, ) From bb8e2a3ba5fb38aff563eb0766ef7498b2428e63 Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 27 Aug 2020 16:12:47 +0200 Subject: [PATCH 348/940] zone picking: if the buffer is empty when we want to set the destination, get back on 'select_line' --- shopfloor/services/zone_picking.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 16b850c0a9..3098e1b9c5 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -1224,7 +1224,10 @@ def unload_set_destination( if not picking_type.exists(): return self._response_for_start(message=self.msg_store.record_not_found()) package = self.env["stock.quant.package"].browse(package_id) - if not package.exists(): + buffer_lines = self._find_buffer_move_lines( + zone_location, picking_type, dest_package=package + ) + if not package.exists() or not buffer_lines: move_lines = self._find_location_move_lines(zone_location, picking_type) return self._response_for_select_line( zone_location, @@ -1232,10 +1235,6 @@ def unload_set_destination( move_lines, message=self.msg_store.record_not_found(), ) - - buffer_lines = self._find_buffer_move_lines( - zone_location, picking_type, dest_package=package - ) search = self.actions_for("search") location = search.location_from_scan(barcode) if location: From 9d748669403dca7a8202ba79f0197624348c8528 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 28 Aug 2020 14:05:05 +0200 Subject: [PATCH 349/940] Add check of scanned dest. location vs move's destination When we scan a location, generally, we have these rules: * If the scanned location is not a child (or =) of the picking type's default destination, reject it * If the scanned location is not a child (or =) of of the current move line's destination, ask for a confirmation However, we have no check on the move's destination location. Example ------- Locations: Packing Zone \- PACK-1 \- PACK-2 Our transfer has: * Picking type destination location: Packing Zone * Move destination location: Packing Zone/PACK-1 * Move line destination location: Packing Zone/PACK-1 Current behavior: * If we scan "PACK-1", the location is accepted directly * If we scan "PACK-2", the app asks for a confirmation If the user confirms PACK-2, we'll have inconsistencies with such result: * Move destination location: Packing Zone/PACK-1 * Move line destination location: Packing Zone/PACK-2 Also, the next move in the chain expects to receive goods in "Packing Zone/PACK-1", so we break the chain. Expected behavior after this commit: * If we scan "PACK-1", the location is accepted directly * If we scan "PACK-2", the location is rejected Considerations -------------- This issue was detected in the location content transfer, but it may happen in the other scenarios, so all of them should be fixed. Except for "Single Pack Transfer" which was already handling this in a different way, by updating the move's destination location. I don't think it is correct, as I doubt the next move in the chain will be correct then. But I don't want to break a use case that could be used, so not changing it for now. In Zone Picking, I simplified a bit the checks on the location: by checking *first* if we are in the picking type (and in the move desination now), we can return early and avoid repeating the condition on the next check. --- shopfloor/models/stock_location.py | 11 +++- shopfloor/services/cluster_picking.py | 5 ++ .../services/location_content_transfer.py | 8 +++ shopfloor/services/single_pack_transfer.py | 2 + shopfloor/services/zone_picking.py | 66 +++++++++++-------- .../tests/test_cluster_picking_unload.py | 51 ++++++++++++++ ...on_content_transfer_set_destination_all.py | 18 +++++ ...ransfer_set_destination_package_or_line.py | 44 +++++++++++++ shopfloor/tests/test_zone_picking_base.py | 22 +++++++ .../test_zone_picking_set_line_destination.py | 28 ++++++++ ...est_zone_picking_unload_set_destination.py | 30 +++++++++ 11 files changed, 253 insertions(+), 32 deletions(-) diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py index db4672b573..eb8f6909ac 100644 --- a/shopfloor/models/stock_location.py +++ b/shopfloor/models/stock_location.py @@ -20,12 +20,17 @@ class StockLocation(models.Model): comodel_name="stock.move.line", compute="_compute_reserved_move_lines", ) - def is_sublocation_of(self, others): - """Return True if self is a sublocation of at least one other""" + def is_sublocation_of(self, others, func=any): + """Return True if self is a sublocation of others (or equal) + + By default, it return True if any other is a parent or equal. + ``all`` can be passed to ``func`` to require all the other locations + to be parent or equal to be True. + """ self.ensure_one() # Efficient way to verify that the current location is # below one of the other location without using SQL. - return any(self.parent_path.startswith(other.parent_path) for other in others) + return func(self.parent_path.startswith(other.parent_path) for other in others) def _get_reserved_move_lines(self): return self.env["stock.move.line"].search( diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index a1867cb7db..9d45a70b31 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -941,6 +941,8 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): ) if not scanned_location.is_sublocation_of( picking_type.default_location_dest_id + ) or not scanned_location.is_sublocation_of( + lines.mapped("move_id.location_dest_id"), func=all ): return self._response_for_unload_all( batch, message=self.msg_store.dest_location_not_allowed() @@ -1092,8 +1094,11 @@ def _unload_scan_destination_lines( return self._response_for_unload_set_destination( batch, package, message=self.msg_store.no_location_found() ) + if not scanned_location.is_sublocation_of( picking_type.default_location_dest_id + ) or not scanned_location.is_sublocation_of( + lines.mapped("move_id.location_dest_id"), func=all ): return self._response_for_unload_set_destination( batch, package, message=self.msg_store.dest_location_not_allowed() diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 0222626737..31887876a1 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -366,6 +366,8 @@ def set_destination_all(self, location_id, barcode, confirmation=False): if not scanned_location.is_sublocation_of( self.picking_types.mapped("default_location_dest_id") + ) or not scanned_location.is_sublocation_of( + move_lines.mapped("move_id.location_dest_id"), func=all ): return self._response_for_scan_destination_all( pickings, message=self.msg_store.dest_location_not_allowed() @@ -550,6 +552,10 @@ def set_destination_package( ) if not scanned_location.is_sublocation_of( package_level.picking_id.picking_type_id.default_location_dest_id + ) or not scanned_location.is_sublocation_of( + # beware, package_level.move_id is not always set + package_level.move_line_ids.move_id.location_dest_id, + func=all, ): return self._response_for_scan_destination( location, @@ -617,6 +623,8 @@ def set_destination_line( ) if not scanned_location.is_sublocation_of( move_line.picking_id.picking_type_id.default_location_dest_id + ) or not scanned_location.is_sublocation_of( + move_line.move_id.location_dest_id, func=all ): return self._response_for_scan_destination( location, move_line, message=self.msg_store.dest_location_not_allowed() diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 308a420b94..f437449b05 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -175,6 +175,8 @@ def validate(self, package_level_id, location_barcode, confirmation=False): if confirmation: # If the destination of the move would be incoherent # (move line outside of it), we change the moves' destination + # TODO in other scenarios, we forbid this. Check if we want + # to forbid it as well. if not scanned_location.is_sublocation_of(move.location_dest_id): move.location_dest_id = scanned_location.id else: diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 3098e1b9c5..ad277ca0da 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -115,7 +115,7 @@ def _response_for_set_line_destination( data = self._data_for_move_line(zone_location, picking_type, move_line) data["confirmation_required"] = confirmation_required return self._response( - next_state="set_line_destination", data=data, message=message, + next_state="set_line_destination", data=data, message=message ) def _response_for_zero_check( @@ -148,7 +148,7 @@ def _response_for_unload_all( message = self.msg_store.need_confirmation() data = self._data_for_move_lines(zone_location, picking_type, move_lines) data["confirmation_required"] = confirmation_required - return self._response(next_state="unload_all", data=data, message=message,) + return self._response(next_state="unload_all", data=data, message=message) def _response_for_unload_single( self, zone_location, picking_type, move_line, message=None, popup=None @@ -176,7 +176,7 @@ def _response_for_unload_set_destination( data = self._data_for_move_line(zone_location, picking_type, move_line) data["confirmation_required"] = confirmation_required return self._response( - next_state="unload_set_destination", data=data, message=message, + next_state="unload_set_destination", data=data, message=message ) def _data_for_select_picking_type(self, zone_location, picking_types): @@ -455,7 +455,7 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit if not move_line: response = self.list_move_lines(zone_location.id, picking_type.id) return self._response( - base_response=response, message=self.msg_store.package_not_found(), + base_response=response, message=self.msg_store.package_not_found() ) product = search.product_from_scan(barcode) if product: @@ -465,7 +465,7 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit if not move_line: response = self.list_move_lines(zone_location.id, picking_type.id) return self._response( - base_response=response, message=self.msg_store.product_not_found(), + base_response=response, message=self.msg_store.product_not_found() ) lot = search.lot_from_scan(barcode) if lot: @@ -473,13 +473,13 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit if not move_line: response = self.list_move_lines(zone_location.id, picking_type.id) return self._response( - base_response=response, message=self.msg_store.lot_not_found(), + base_response=response, message=self.msg_store.lot_not_found() ) # barcode not found, get back on 'select_line' screen if not move_line: response = self.list_move_lines(zone_location.id, picking_type.id) return self._response( - base_response=response, message=self.msg_store.barcode_not_found(), + base_response=response, message=self.msg_store.barcode_not_found() ) return self._response_for_set_line_destination( zone_location, picking_type, move_line @@ -490,36 +490,40 @@ def _set_destination_location( ): location_changed = False response = None + + # A valid location is a sub-location of the original destination, or a + # any sub-location of the picking type's default destination location + # if `confirmation is True # Ask confirmation to the user if the scanned location is not in the # expected ones but is valid (in picking type's default destination) - if not location.is_sublocation_of(move_line.location_dest_id) and ( - not confirmation - and location.is_sublocation_of(picking_type.default_location_dest_id) + if not location.is_sublocation_of( + picking_type.default_location_dest_id + ) or not location.is_sublocation_of( + move_line.move_id.location_dest_id, func=all ): response = self._response_for_set_line_destination( zone_location, picking_type, move_line, - message=self.msg_store.confirm_location_changed( - move_line.location_dest_id, location - ), - confirmation_required=True, + message=self.msg_store.dest_location_not_allowed(), ) return (location_changed, response) - # A valid location is a sub-location of the original destination, or a - # sub-location of the picking type's default destination location if - # `confirmation is True - if not location.is_sublocation_of(move_line.location_dest_id) and ( - confirmation - and not location.is_sublocation_of(picking_type.default_location_dest_id) + + if ( + not location.is_sublocation_of(move_line.location_dest_id) + and not confirmation ): response = self._response_for_set_line_destination( zone_location, picking_type, move_line, - message=self.msg_store.dest_location_not_allowed(), + message=self.msg_store.confirm_location_changed( + move_line.location_dest_id, location + ), + confirmation_required=True, ) return (location_changed, response) + # If no destination package if not move_line.result_package_id: response = self._response_for_set_line_destination( @@ -744,7 +748,7 @@ def set_destination( # Process the next line response = self.list_move_lines(zone_location.id, picking_type.id) - return self._response(base_response=response, message=message,) + return self._response(base_response=response, message=message) def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero): """Confirm or not if the source location of a move has zero qty @@ -984,7 +988,7 @@ def prepare_unload(self, zone_location_id, picking_type_id): return self._response_for_unload_single( zone_location, picking_type, first(move_lines) ) - move_lines = self._find_location_move_lines(zone_location, picking_type,) + move_lines = self._find_location_move_lines(zone_location, picking_type) return self._response_for_select_line(zone_location, picking_type, move_lines) def _set_destination_all_response( @@ -992,12 +996,12 @@ def _set_destination_all_response( ): if buffer_lines: return self._response_for_unload_all( - zone_location, picking_type, buffer_lines, message=message, + zone_location, picking_type, buffer_lines, message=message ) move_lines = self._find_location_move_lines(zone_location, picking_type) if move_lines: return self._response_for_select_line( - zone_location, picking_type, move_lines, message=message, + zone_location, picking_type, move_lines, message=message ) return self._response_for_start(message=message) @@ -1073,7 +1077,7 @@ def set_destination_all( else: message = self.msg_store.no_location_found() return self._set_destination_all_response( - zone_location, picking_type, buffer_lines, message=message, + zone_location, picking_type, buffer_lines, message=message ) def _write_destination_on_lines(self, lines, location): @@ -1104,7 +1108,7 @@ def unload_split(self, zone_location_id, picking_type_id): # more than one remaining move line in the buffer if len(buffer_lines) > 1: return self._response_for_unload_single( - zone_location, picking_type, first(buffer_lines), + zone_location, picking_type, first(buffer_lines) ) # only one move line to process in the buffer elif len(buffer_lines) == 1: @@ -1238,7 +1242,11 @@ def unload_set_destination( search = self.actions_for("search") location = search.location_from_scan(barcode) if location: - if not location.is_sublocation_of(picking_type.default_location_dest_id): + if not location.is_sublocation_of( + picking_type.default_location_dest_id + ) or not location.is_sublocation_of( + buffer_lines.move_id.location_dest_id, func=all + ): return self._response_for_unload_set_destination( zone_location, picking_type, @@ -1269,7 +1277,7 @@ def unload_set_destination( buffer_lines = self._find_buffer_move_lines(zone_location, picking_type) if buffer_lines: return self._response_for_unload_single( - zone_location, picking_type, first(buffer_lines), + zone_location, picking_type, first(buffer_lines) ) move_lines = self._find_location_move_lines(zone_location, picking_type) if move_lines: diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index 6528eafe12..1df4f0e293 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -285,6 +285,33 @@ def test_set_destination_all_error_location_invalid(self): message={"message_type": "error", "body": "You cannot place it here"}, ) + def test_set_destination_all_error_location_move_invalid(self): + """Endpoint called with a barcode for an invalid location + + It is invalid when the location is not the destination location or + sublocation of move line's move + """ + move_lines = self.batch.mapped("picking_ids.move_line_ids") + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines.write({"location_dest_id": self.packing_a_location.id}) + move_lines[0].move_id.location_dest_id = self.packing_a_location + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_b_location.barcode, + }, + ) + location = move_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location) + self.assert_response( + response, + next_state="unload_all", + data=data, + message=self.service.msg_store.dest_location_not_allowed(), + ) + def test_set_destination_all_need_confirmation(self): """Endpoint called with a barcode for another (valid) location""" move_lines = self.batch.mapped("picking_ids.move_line_ids") @@ -651,6 +678,30 @@ def test_unload_scan_destination_error_location_invalid(self): message={"message_type": "error", "body": "You cannot place it here"}, ) + def test_unload_scan_destination_error_location_move_invalid(self): + """Endpoint called with a barcode for an invalid location + + It is invalid when the location is not the destination location or + sublocation of the move line's move + """ + self.bin1_lines[0].move_id.location_dest_id = self.packing_a_location + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": self.packing_b_location.barcode, + }, + ) + location = self.bin1_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) + self.assert_response( + response, + next_state="unload_set_destination", + data=data, + message=self.service.msg_store.dest_location_not_allowed(), + ) + def test_unload_scan_destination_need_confirmation(self): """Endpoint called with a barcode for another (valid) location""" response = self.service.dispatch( diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_all.py b/shopfloor/tests/test_location_content_transfer_set_destination_all.py index fb2c21b876..fc33a4d742 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_all.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py @@ -149,6 +149,24 @@ def test_set_destination_all_dest_location_invalid(self): message=self.service.msg_store.dest_location_not_allowed(), ) + def test_set_destination_all_dest_location_move_invalid(self): + """The scanned destination location is not in the move's dest location""" + # if we have at least one move which does not match the scanned location + # we forbid the action + self.pickings.move_lines[0].location_dest_id = self.shelf1 + response = self.service.dispatch( + "set_destination_all", + params={ + "location_id": self.content_loc.id, + "barcode": self.shelf2.barcode, + }, + ) + self.assert_response_scan_destination_all( + response, + self.pickings, + message=self.service.msg_store.dest_location_not_allowed(), + ) + def test_go_to_single(self): """User used to 'split by lines' button to process line per line""" response = self.service.dispatch( diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py index f8fd7db29b..92e58bc17a 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py @@ -111,6 +111,28 @@ def test_set_destination_package_dest_location_nok(self): message=self.service.msg_store.dest_location_not_allowed(), ) + def test_set_destination_package_dest_location_move_nok(self): + """Scanned destination location not valid (different as move)""" + package_level = self.picking1.package_level_ids[0] + # if the move related to the package level has a destination + # location not a parent or equal to the scanned location, + # refuse the action + move = package_level.move_line_ids.move_id + move.location_dest_id = self.shelf1 + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.shelf2.barcode, + }, + ) + self.assert_response_scan_destination( + response, + package_level, + message=self.service.msg_store.dest_location_not_allowed(), + ) + def test_set_destination_package_dest_location_to_confirm(self): """Scanned destination location valid, but need a confirmation.""" package_level = self.picking1.package_level_ids[0] @@ -220,6 +242,28 @@ def test_set_destination_line_dest_location_nok(self): message=self.service.msg_store.dest_location_not_allowed(), ) + def test_set_destination_line_dest_location_move_nok(self): + """Scanned destination location not valid (different as move)""" + move_line = self.picking2.move_line_ids[0] + # if the move related to the move line has a destination + # location not a parent or equal to the scanned location, + # refuse the action + move_line.move_id.location_dest_id = self.shelf1 + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.product_uom_qty, + "barcode": self.shelf2.barcode, + }, + ) + self.assert_response_scan_destination( + response, + move_line, + message=self.service.msg_store.dest_location_not_allowed(), + ) + def test_set_destination_line_dest_location_to_confirm(self): """Scanned destination location valid, but need a confirmation.""" move_line = self.picking2.move_line_ids[0] diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 097e7e0e15..05c019c4bc 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -71,6 +71,28 @@ def setUpClassBaseData(cls, *args, **kwargs): } ) ) + cls.packing_sublocation_a = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Packing Sublocation A", + "location_id": cls.packing_location.id, + "barcode": "PACKING_SUBLOCATION_A", + } + ) + ) + cls.packing_sublocation_b = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Packing Sublocation B", + "location_id": cls.packing_location.id, + "barcode": "PACKING_SUBLOCATION_B", + } + ) + ) cls.product_e = ( cls.env["product.product"] .sudo() diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py index da2f0e110f..f163958781 100644 --- a/shopfloor/tests/test_zone_picking_set_line_destination.py +++ b/shopfloor/tests/test_zone_picking_set_line_destination.py @@ -131,6 +131,34 @@ def test_set_destination_location_confirm(self): message=self.service.msg_store.confirm_pack_moved(), ) + def test_set_destination_location_move_invalid_location(self): + # Confirm the destination with a wrong destination, outside of move's + # move line (should not happen) + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + move_line.location_dest_id = self.packing_sublocation_a + move_line.move_id.location_dest_id = self.packing_sublocation_a + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.packing_sublocation_b.barcode, + "quantity": move_line.product_uom_qty, + "confirmation": True, + }, + ) + # Check response + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.dest_location_not_allowed(), + ) + def test_set_destination_location_no_other_move_line_full_qty(self): """Scanned barcode is the destination location. diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py index 2024d16524..be32d2295a 100644 --- a/shopfloor/tests/test_zone_picking_unload_set_destination.py +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -115,6 +115,36 @@ def test_unload_set_destination_location_not_allowed(self): message=self.service.msg_store.dest_location_not_allowed(), ) + def test_unload_set_destination_location_move_not_allowed(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + move_line[0].move_id.location_dest_id = self.packing_sublocation_a + # set the destination package + self.service._set_destination_package( + zone_location, + picking_type, + move_line, + move_line.product_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "package_id": self.free_package.id, + "barcode": self.packing_sublocation_b.barcode, + }, + ) + self.assert_response_unload_set_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.dest_location_not_allowed(), + ) + def test_unload_set_destination_confirm_location(self): zone_location = self.zone_location picking_type = self.picking1.picking_type_id From c8317fccd8abcc4b1440e105f9660d3419643648 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 31 Aug 2020 16:11:47 +0200 Subject: [PATCH 350/940] bknd: cluster-picking split move line split code --- shopfloor/models/stock_move_line.py | 33 +++++++++++++++++++++++++++ shopfloor/services/cluster_picking.py | 25 ++------------------ 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py index f92dedea7a..388cad6a1a 100644 --- a/shopfloor/models/stock_move_line.py +++ b/shopfloor/models/stock_move_line.py @@ -1,5 +1,6 @@ from odoo import _, fields, models from odoo.exceptions import UserError +from odoo.tools.float_utils import float_compare class StockMoveLine(models.Model): @@ -107,3 +108,35 @@ def _split_pickings_from_source_location(self): new_moves._action_assign() pickings = new_picking return pickings + + def _check_qty_to_be_done(self, qty_done, split_partial=True): + """Check qty to be done for current move line. Split it if needed. + + :param qty_done: qty expected to be done + :param split_partial: split if qty is less than expected + otherwise rely on a backorder. + """ + # store a new line if we have split our line (not enough qty) + new_line = self.env["stock.move.line"] + rounding = self.product_uom_id.rounding + compare = float_compare( + qty_done, self.product_uom_qty, precision_rounding=rounding + ) + qty_lesser = compare == -1 + qty_greater = compare == 1 + if qty_greater: + return (new_line, "greater") + elif qty_lesser: + if not split_partial: + return (new_line, "lesser") + # split the move line which will be processed later (maybe the user + # has to pick some goods from another place because the location + # contained less items than expected) + remaining = self.product_uom_qty - qty_done + new_line = self.copy({"product_uom_qty": remaining, "qty_done": 0}) + # if we didn't bypass reservation update, the quant reservation + # would be reduced as much as the deduced quantity, which is wrong + # as we only moved the quantity to a new move line + self.with_context(bypass_reservation_update=True).product_uom_qty = qty_done + return (new_line, "lesser") + return (new_line, "full") diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 9d45a70b31..aad0438fa7 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -1,6 +1,5 @@ from odoo import _, fields from odoo.osv import expression -from odoo.tools.float_utils import float_compare from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component @@ -568,32 +567,12 @@ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantit batch, message=self.msg_store.operation_not_found() ) - # store a new line if we have split our line (not enough qty) - new_line = self.env["stock.move.line"] - - rounding = move_line.product_uom_id.rounding - compare = float_compare( - quantity, move_line.product_uom_qty, precision_rounding=rounding - ) - qty_lesser = compare == -1 - qty_greater = compare == 1 - if qty_greater: + new_line, qty_check = move_line._check_qty_to_be_done(quantity) + if qty_check == "greater": return self._response_for_scan_destination( move_line, message=self.msg_store.unable_to_pick_more(move_line.product_uom_qty), ) - elif qty_lesser: - # split the move line which will be processed later (maybe the user - # has to pick some goods from another place because the location - # contained less items than expected) - remaining = move_line.product_uom_qty - quantity - new_line = move_line.copy({"product_uom_qty": remaining, "qty_done": 0}) - # if we didn't bypass reservation update, the quant reservation - # would be reduced as much as the deduced quantity, which is wrong - # as we only moved the quantity to a new move line - move_line.with_context( - bypass_reservation_update=True - ).product_uom_qty = quantity search = self.actions_for("search") bin_package = search.package_from_scan(barcode) From 19c4583352ae018d13fa56123771f393f6b8cd80 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 31 Aug 2020 16:45:40 +0200 Subject: [PATCH 351/940] bknd: checkout edit qty split if less qty done When editing the qty done on checkout/packing scenario the if the qty is not fully done, the line is split so we can process the remaining qty into another package. --- shopfloor/models/stock_move_line.py | 6 ++-- shopfloor/services/checkout.py | 25 ++++++++----- .../test_checkout_select_package_base.py | 6 ++-- shopfloor/tests/test_checkout_set_qty.py | 36 +++++++++++++++++-- 4 files changed, 58 insertions(+), 15 deletions(-) diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py index 388cad6a1a..fbc051f8ee 100644 --- a/shopfloor/models/stock_move_line.py +++ b/shopfloor/models/stock_move_line.py @@ -109,7 +109,7 @@ def _split_pickings_from_source_location(self): pickings = new_picking return pickings - def _check_qty_to_be_done(self, qty_done, split_partial=True): + def _check_qty_to_be_done(self, qty_done, split_partial=True, **split_default_vals): """Check qty to be done for current move line. Split it if needed. :param qty_done: qty expected to be done @@ -133,7 +133,9 @@ def _check_qty_to_be_done(self, qty_done, split_partial=True): # has to pick some goods from another place because the location # contained less items than expected) remaining = self.product_uom_qty - qty_done - new_line = self.copy({"product_uom_qty": remaining, "qty_done": 0}) + vals = {"product_uom_qty": remaining, "qty_done": 0} + vals.update(split_default_vals) + new_line = self.copy(vals) # if we didn't bypass reservation update, the quant reservation # would be reduced as much as the deduced quantity, which is wrong # as we only moved the quantity to a new move line diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 91ba2565b8..7988ae4e60 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -496,22 +496,29 @@ def _change_line_qty( message = self.msg_store.record_not_found() for move_line in move_lines: qty_done = quantity_func(move_line) - if qty_done > move_line.product_uom_qty: - qty_done = move_line.product_uom_qty - message = { - "body": _( - "Not allowed to pack more than the quantity, " - "the value has been changed to the maximum." - ), - "message_type": "warning", - } if qty_done < 0: message = { "body": _("Negative quantity not allowed."), "message_type": "error", } else: + new_line = self.env["stock.move.line"] + if qty_done > 0: + new_line, qty_check = move_line._check_qty_to_be_done( + qty_done, split_partial=True, result_package_id=False, + ) + if qty_check == "greater": + qty_done = move_line.product_uom_qty + message = { + "body": _( + "Not allowed to pack more than the quantity, " + "the value has been changed to the maximum." + ), + "message_type": "warning", + } move_line.qty_done = qty_done + if new_line: + selected_line_ids.append(new_line.id) return self._response_for_select_package( picking, self.env["stock.move.line"].browse(selected_line_ids).exists(), diff --git a/shopfloor/tests/test_checkout_select_package_base.py b/shopfloor/tests/test_checkout_select_package_base.py index 93c7670485..845c6d8df0 100644 --- a/shopfloor/tests/test_checkout_select_package_base.py +++ b/shopfloor/tests/test_checkout_select_package_base.py @@ -8,7 +8,7 @@ def _assert_selected_response( next_state="select_package", data={ "selected_move_lines": [ - self._move_line_data(ml) for ml in selected_lines + self._move_line_data(ml) for ml in selected_lines.sorted() ], "picking": self._picking_summary_data(picking), "packing_info": picking.shopfloor_packing_info if packing_info else "", @@ -26,7 +26,9 @@ def _assert_selected_qties( ): picking = selected_lines.mapped("picking_id") deselected_lines = picking.move_line_ids - selected_lines - self.assertEqual(selected_lines.ids, [l.id for l in lines_quantities]) + self.assertEqual( + sorted(selected_lines.ids), sorted([l.id for l in lines_quantities]) + ) for line, quantity in lines_quantities.items(): self.assertEqual(line.qty_done, quantity) for line in deselected_lines: diff --git a/shopfloor/tests/test_checkout_set_qty.py b/shopfloor/tests/test_checkout_set_qty.py index 5440e473d4..99229c7e0d 100644 --- a/shopfloor/tests/test_checkout_set_qty.py +++ b/shopfloor/tests/test_checkout_set_qty.py @@ -127,7 +127,8 @@ def test_set_custom_qty_ok(self): selected_lines = self.moves_pack1.move_line_ids line_to_change = selected_lines[0] line_keep_qty = selected_lines[1] - new_qty = 5 + # Process full qty + new_qty = line_to_change.product_uom_qty # we want to check that when we give the package id, we get # all its move lines response = self.service.dispatch( @@ -136,7 +137,7 @@ def test_set_custom_qty_ok(self): "picking_id": self.picking.id, "selected_line_ids": selected_lines.ids, "move_line_id": line_to_change.id, - "qty_done": 5, + "qty_done": new_qty, }, ) self.assertEqual(line_to_change.qty_done, new_qty) @@ -218,3 +219,34 @@ def test_set_custom_qty_negative(self): "message_type": "error", }, ) + + def test_set_custom_qty_partial(self): + selected_lines = self.moves_pack1.move_line_ids + line_to_change = selected_lines[0] + line_keep_qty = selected_lines[1] + # split 1 qty + new_qty = line_to_change.product_uom_qty - 1 + response = self.service.dispatch( + "set_custom_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": line_to_change.id, + "qty_done": new_qty, + }, + ) + self.assertEqual(line_to_change.qty_done, new_qty) + self.assertEqual(line_keep_qty.qty_done, line_keep_qty.product_uom_qty) + new_line = [ + x for x in self.moves_pack1.move_line_ids if x not in selected_lines + ][0] + self.assertEqual(new_line.product_uom_qty, 1.0) + self._assert_selected_qties( + response, + self.moves_pack1.move_line_ids, + { + line_to_change: new_qty, + line_keep_qty: line_keep_qty.product_uom_qty, + new_line: 0.0, + }, + ) From bd279b06c84e855f205c92ba7d69f20c48f82074 Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 1 Sep 2020 12:33:52 +0200 Subject: [PATCH 352/940] shopfloor: fix send_confirmation_email handling from pickings * Confirmation email (and delivery labels) have to be generated when moves are validated (already done) and also when the validation is done from the transfer itself. * ensure that the 'action_done' method of the picking is called when the last move is validated (this will trigger the send_confirmation_email and other methods) --- shopfloor/models/stock_move.py | 8 ++--- shopfloor/models/stock_picking.py | 9 ------ ...ransfer_set_destination_package_or_line.py | 30 +++++++------------ shopfloor/tests/test_single_pack_transfer.py | 24 +++++---------- .../test_zone_picking_set_line_destination.py | 24 +++++---------- .../tests/test_zone_picking_unload_all.py | 6 ++-- ...est_zone_picking_unload_set_destination.py | 12 +++----- 7 files changed, 35 insertions(+), 78 deletions(-) diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py index c641389dcb..1ba009e8cb 100644 --- a/shopfloor/models/stock_move.py +++ b/shopfloor/models/stock_move.py @@ -26,14 +26,12 @@ def split_other_move_lines(self, move_lines, intersection=False): return False def _action_done(self, cancel_backorder=False): - # Overloaded to send the email when the last move of a picking is validated. - # The method 'stock.picking._send_confirmation_email' is called only from - # the 'stock.picking.action_done()' method but never when moves are - # validated partially through the current method. + # Overloaded to ensure that the 'action_done' method of the picking + # is called when the last move of a picking is validated. moves = super()._action_done(cancel_backorder) if not self.env.context.get("_action_done_from_picking"): pickings = moves.picking_id for picking in pickings: if picking.state == "done": - picking._send_confirmation_email() + picking.action_done() return moves diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py index 90e10fd131..7f1714e374 100644 --- a/shopfloor/models/stock_picking.py +++ b/shopfloor/models/stock_picking.py @@ -46,12 +46,3 @@ def _create_backorder(self): def action_done(self): self = self.with_context(_action_done_from_picking=True) return super().action_done() - - def _send_confirmation_email(self): - # Avoid sending the confirmation email twice (one when the - # 'picking.action_done()' is called, and one when the last move of this - # picking is validated through 'move._action_done()') - # We send the confirmation email - if self.env.context.get("_action_done_from_picking"): - return - super()._send_confirmation_email() diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py index 92e58bc17a..5f6815eb1f 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py @@ -154,9 +154,7 @@ def test_set_destination_package_dest_location_to_confirm(self): def test_set_destination_package_dest_location_ok(self): """Scanned destination location valid, moves set to done.""" package_level = self.picking1.package_level_ids[0] - with mock.patch.object( - type(self.picking1), "_send_confirmation_email" - ) as send_confirmation_email: + with mock.patch.object(type(self.picking1), "action_done") as action_done: response = self.service.dispatch( "set_destination_package", params={ @@ -165,7 +163,7 @@ def test_set_destination_package_dest_location_ok(self): "barcode": self.dest_location.barcode, }, ) - send_confirmation_email.assert_called_once() + action_done.assert_called_once() move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( response, @@ -351,9 +349,7 @@ def test_set_destination_line_partial_qty(self): self.assertEqual(move_line_c.move_id.state, "done") # Scan remaining qty (4/10) remaining_move_line_c = move_product_c_splitted.move_line_ids - with mock.patch.object( - type(self.picking2), "_send_confirmation_email" - ) as send_confirmation_email: + with mock.patch.object(type(self.picking2), "action_done") as action_done: response = self.service.dispatch( "set_destination_line", params={ @@ -363,7 +359,7 @@ def test_set_destination_line_partial_qty(self): "barcode": self.dest_location.barcode, }, ) - send_confirmation_email.assert_not_called() + action_done.assert_not_called() # Check move line data self.assertEqual(remaining_move_line_c.move_id.product_uom_qty, 4) self.assertEqual(remaining_move_line_c.product_uom_qty, 0) @@ -383,9 +379,7 @@ def test_set_destination_line_partial_qty(self): move_line_d = self.picking2.move_line_ids.filtered( lambda m: m.product_id == self.product_d ) - with mock.patch.object( - type(self.picking2), "_send_confirmation_email" - ) as send_confirmation_email: + with mock.patch.object(type(self.picking2), "action_done") as action_done: response = self.service.dispatch( "set_destination_line", params={ @@ -400,7 +394,7 @@ def test_set_destination_line_partial_qty(self): self.assertEqual(move_line_d.qty_done, 10) self.assertEqual(move_line_d.state, "done") self.assertEqual(self.picking2.state, "done") - send_confirmation_email.assert_called_once() + action_done.assert_called_once() class LocationContentTransferSetDestinationXSpecialCase( @@ -564,9 +558,7 @@ def test_set_destination_line_split_move(self): remaining_move_lines = self.picking.move_line_ids_without_package.filtered( lambda ml: ml.state == "assigned" ) - with mock.patch.object( - type(self.picking), "_send_confirmation_email" - ) as send_confirmation_email: + with mock.patch.object(type(self.picking), "action_done") as action_done: for ml in remaining_move_lines: self.service.dispatch( "set_destination_line", @@ -578,11 +570,9 @@ def test_set_destination_line_split_move(self): }, ) self.assertEqual(self.picking.state, "assigned") - send_confirmation_email.assert_not_called() + action_done.assert_not_called() package_level = self.picking.package_level_ids[0] - with mock.patch.object( - type(self.picking), "_send_confirmation_email" - ) as send_confirmation_email: + with mock.patch.object(type(self.picking), "action_done") as action_done: self.service.dispatch( "set_destination_package", params={ @@ -592,4 +582,4 @@ def test_set_destination_line_split_move(self): }, ) self.assertEqual(self.picking.state, "done") - send_confirmation_email.assert_called_once() + action_done.assert_called_once() diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index 3397279830..72cdd8ba5d 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -399,9 +399,7 @@ def test_validate(self): # now, call the service to proceed with validation of the # movement - with mock.patch.object( - type(self.picking), "_send_confirmation_email" - ) as send_confirmation_email: + with mock.patch.object(type(self.picking), "action_done") as action_done: response = self.service.dispatch( "validate", params={ @@ -409,7 +407,7 @@ def test_validate(self): "location_barcode": self.shelf2.barcode, }, ) - send_confirmation_email.assert_called_once() + action_done.assert_called_once() self.assert_response( response, @@ -471,9 +469,7 @@ def test_validate_completion_info(self): # now, call the service to proceed with validation of the # movement - with mock.patch.object( - type(self.picking), "_send_confirmation_email" - ) as send_confirmation_email: + with mock.patch.object(type(self.picking), "action_done") as action_done: response = self.service.dispatch( "validate", params={ @@ -481,7 +477,7 @@ def test_validate_completion_info(self): "location_barcode": self.shelf2.barcode, }, ) - send_confirmation_email.assert_called_once() + action_done.assert_called_once() self.assert_response( response, @@ -605,9 +601,7 @@ def test_validate_location_to_confirm(self): # expected destination is 'shelf2', we'll scan shelf1 which must # ask a confirmation to the user (it's still in the same picking type) - with mock.patch.object( - type(self.picking), "_send_confirmation_email" - ) as send_confirmation_email: + with mock.patch.object(type(self.picking), "action_done") as action_done: response = self.service.dispatch( "validate", params={ @@ -615,7 +609,7 @@ def test_validate_location_to_confirm(self): "location_barcode": self.shelf1.barcode, }, ) - send_confirmation_email.assert_not_called() + action_done.assert_not_called() message = self.service.actions_for("message").confirm_location_changed( self.shelf2, self.shelf1 @@ -655,9 +649,7 @@ def test_validate_location_with_confirm(self): # expected destination is 'shelf1', we'll scan shelf2 which must # ask a confirmation to the user (it's still in the same picking type) - with mock.patch.object( - type(self.picking), "_send_confirmation_email" - ) as send_confirmation_email: + with mock.patch.object(type(self.picking), "action_done") as action_done: response = self.service.dispatch( "validate", params={ @@ -667,7 +659,7 @@ def test_validate_location_with_confirm(self): "confirmation": True, }, ) - send_confirmation_email.assert_called_once() + action_done.assert_called_once() self.assert_response( response, diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py index f163958781..9986f78489 100644 --- a/shopfloor/tests/test_zone_picking_set_line_destination.py +++ b/shopfloor/tests/test_zone_picking_set_line_destination.py @@ -180,9 +180,7 @@ def test_set_destination_location_no_other_move_line_full_qty(self): self.assertEqual(len(moves_before), 1) self.assertEqual(len(moves_before.move_line_ids), 1) move_line = moves_before.move_line_ids - with mock.patch.object( - type(self.picking1), "_send_confirmation_email" - ) as send_confirmation_email: + with mock.patch.object(type(self.picking1), "action_done") as action_done: response = self.service.dispatch( "set_destination", params={ @@ -194,7 +192,7 @@ def test_set_destination_location_no_other_move_line_full_qty(self): "confirmation": False, }, ) - send_confirmation_email.assert_called_once() + action_done.assert_called_once() # Check picking data moves_after = self.picking1.move_lines self.assertEqual(moves_before, moves_after) @@ -234,9 +232,7 @@ def test_set_destination_location_no_other_move_line_partial_qty(self): move_line = moves_before.move_line_ids # we need a destination package if we want to scan a destination location move_line.result_package_id = self.free_package - with mock.patch.object( - type(self.picking3), "_send_confirmation_email" - ) as send_confirmation_email: + with mock.patch.object(type(self.picking3), "action_done") as action_done: response = self.service.dispatch( "set_destination", params={ @@ -248,7 +244,7 @@ def test_set_destination_location_no_other_move_line_partial_qty(self): "confirmation": False, }, ) - send_confirmation_email.assert_not_called() + action_done.assert_not_called() self.assert_response_set_line_destination( response, zone_location, @@ -285,9 +281,7 @@ def test_set_destination_location_several_move_line_full_qty(self): # we need a destination package if we want to scan a destination location move_line.result_package_id = self.free_package other_move_line = moves_before.move_line_ids[1] - with mock.patch.object( - type(self.picking4), "_send_confirmation_email" - ) as send_confirmation_email: + with mock.patch.object(type(self.picking4), "action_done") as action_done: response = self.service.dispatch( "set_destination", params={ @@ -299,7 +293,7 @@ def test_set_destination_location_several_move_line_full_qty(self): "confirmation": False, }, ) - send_confirmation_email.assert_not_called() + action_done.assert_not_called() # Check picking data (move has been split in two, 6 done and 4 remaining) moves_after = self.picking4.move_lines self.assertEqual(len(moves_after), 2) @@ -348,9 +342,7 @@ def test_set_destination_location_several_move_line_partial_qty(self): move_line = moves_before.move_line_ids[0] # we need a destination package if we want to scan a destination location move_line.result_package_id = self.free_package - with mock.patch.object( - type(self.picking4), "_send_confirmation_email" - ) as send_confirmation_email: + with mock.patch.object(type(self.picking4), "action_done") as action_done: response = self.service.dispatch( "set_destination", params={ @@ -362,7 +354,7 @@ def test_set_destination_location_several_move_line_partial_qty(self): "confirmation": False, }, ) - send_confirmation_email.assert_not_called() + action_done.assert_not_called() self.assert_response_set_line_destination( response, zone_location, diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py index 7a37d01241..8b29c74687 100644 --- a/shopfloor/tests/test_zone_picking_unload_all.py +++ b/shopfloor/tests/test_zone_picking_unload_all.py @@ -197,9 +197,7 @@ def test_set_destination_all_ok(self): another_package, ) # set destination location for all lines in the buffer - with mock.patch.object( - type(self.picking5), "_send_confirmation_email" - ) as send_confirmation_email: + with mock.patch.object(type(self.picking5), "action_done") as action_done: response = self.service.dispatch( "set_destination_all", params={ @@ -208,7 +206,7 @@ def test_set_destination_all_ok(self): "barcode": self.packing_location.barcode, }, ) - send_confirmation_email.assert_called_once() + action_done.assert_called_once() # check data self.assertEqual(self.picking5.state, "done") # buffer should be empty diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py index be32d2295a..b1f01a6de8 100644 --- a/shopfloor/tests/test_zone_picking_unload_set_destination.py +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -223,9 +223,7 @@ def test_unload_set_destination_ok_buffer_empty(self): move_line.product_uom_qty, self.free_package, ) - with mock.patch.object( - type(self.picking1), "_send_confirmation_email" - ) as send_confirmation_email: + with mock.patch.object(type(self.picking1), "action_done") as action_done: response = self.service.dispatch( "unload_set_destination", params={ @@ -236,7 +234,7 @@ def test_unload_set_destination_ok_buffer_empty(self): "confirmation": True, }, ) - send_confirmation_email.assert_called_once() + action_done.assert_called_once() # check data self.assertEqual(move_line.location_dest_id, packing_sublocation) self.assertEqual(move_line.move_id.state, "done") @@ -268,9 +266,7 @@ def test_unload_set_destination_ok_buffer_not_empty(self): package_dest, ) # process 1/2 buffer line - with mock.patch.object( - type(self.picking5), "_send_confirmation_email" - ) as send_confirmation_email: + with mock.patch.object(type(self.picking5), "action_done") as action_done: response = self.service.dispatch( "unload_set_destination", params={ @@ -280,7 +276,7 @@ def test_unload_set_destination_ok_buffer_not_empty(self): "barcode": self.packing_location.barcode, }, ) - send_confirmation_email.assert_not_called() + action_done.assert_not_called() # check data move_line = self.picking5.move_line_ids.filtered( lambda l: l.result_package_id == self.free_package From c43ff0e241fe78dee3f23bd4b2b5b5a798c137da Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 7 Sep 2020 15:39:48 +0200 Subject: [PATCH 353/940] bknd: fix vendor code must be always a string --- shopfloor/actions/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 0ad4f88d10..5603890e76 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -225,7 +225,7 @@ def _product_supplier_code(self, rec, field): supplier_info = fields.first( rec.seller_ids.filtered(lambda x: x.product_id == rec) ) - return supplier_info.product_code if supplier_info else "" + return supplier_info.product_code or "" def picking_batch(self, record, with_pickings=False, **kw): parser = self._picking_batch_parser From 5628658f794fe215fb73998e20d224fdccb0163b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 8 Sep 2020 10:45:00 +0200 Subject: [PATCH 354/940] location transfer: ensure move state is recomputed (#66) When we split a move, the state of the remaining part may remain in "waiting" if we do not call "move._recompute_state()". In the order of events: * the move is split, the remaining part becomes a "waiting" move * the existing remaining move lines are directly assigned to the "waiting move" * when _action_assign() is called, as the move is "waiting", it checks if it needs reservation, but as it already has all the move lines, it does not change the move to "assigned" By calling "move._recompute_state()", if the reservation is already full, the state becomes "assigned" (and the move is ignored by "_action_assign()"). --- shopfloor/models/stock_move.py | 1 + ...ransfer_set_destination_package_or_line.py | 158 ++++++++++++++++-- 2 files changed, 149 insertions(+), 10 deletions(-) diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py index 1ba009e8cb..acf7371576 100644 --- a/shopfloor/models/stock_move.py +++ b/shopfloor/models/stock_move.py @@ -21,6 +21,7 @@ def split_other_move_lines(self, move_lines, intersection=False): backorder_move_id = self._split(qty_to_split) backorder_move = self.browse(backorder_move_id) backorder_move.move_line_ids = other_move_lines + backorder_move._recompute_state() backorder_move._action_assign() return backorder_move return False diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py index 5f6815eb1f..0543243704 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py @@ -311,6 +311,7 @@ def test_set_destination_line_partial_qty(self): move_line_c = self.picking2.move_line_ids.filtered( lambda m: m.product_id == self.product_c ) + move = move_line_c.move_id self.assertEqual(move_line_c.product_uom_qty, 10) self.assertEqual(move_line_c.qty_done, 10) # Scan partial qty (6/10) @@ -328,15 +329,12 @@ def test_set_destination_line_partial_qty(self): self.assertEqual(move_line_c.product_uom_qty, 0) self.assertEqual(move_line_c.qty_done, 6) self.assertEqual(move_line_c.state, "done") - # Check the new move created to handle the remaining qty - move_product_c_splitted = self.picking2.move_lines.filtered( - lambda m: m.product_id == self.product_c and m.state == "assigned" - ) - self.assertEqual(move_product_c_splitted.state, "assigned") - self.assertEqual(move_product_c_splitted.product_id, self.product_c) - self.assertEqual(move_product_c_splitted.product_uom_qty, 4) - self.assertEqual(move_product_c_splitted.move_line_ids.product_uom_qty, 4) - self.assertEqual(move_product_c_splitted.move_line_ids.qty_done, 4) + # the move is split with the remaining + self.assertEqual(move.state, "assigned") + self.assertEqual(move.product_id, self.product_c) + self.assertEqual(move.product_uom_qty, 4) + self.assertEqual(move.move_line_ids.product_uom_qty, 4) + self.assertEqual(move.move_line_ids.qty_done, 4) # Check the response move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( @@ -348,7 +346,7 @@ def test_set_destination_line_partial_qty(self): ) self.assertEqual(move_line_c.move_id.state, "done") # Scan remaining qty (4/10) - remaining_move_line_c = move_product_c_splitted.move_line_ids + remaining_move_line_c = move.move_line_ids with mock.patch.object(type(self.picking2), "action_done") as action_done: response = self.service.dispatch( "set_destination_line", @@ -583,3 +581,143 @@ def test_set_destination_line_split_move(self): ) self.assertEqual(self.picking.state, "done") action_done.assert_called_once() + + +class LocationContentTransferSetDestinationChainSpecialCase( + LocationContentTransferCommonCase +): + """Tests for endpoint used from scan_destination (special cases with + chained pickings) + + * /set_destination_package + * /set_destination_line + + """ + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + # Test split of partial qty when the moves have "move_orig_ids". + # We create a chain of pickings to ensure the proper state is computed + # for the split move. + cls.picking_a = picking_a = cls._create_picking(lines=[(cls.product_c, 10)]) + cls.picking_b = picking_b = cls._create_picking(lines=[(cls.product_c, 10)]) + # connect a and b in a chain of moves + for move_a in picking_a.move_lines: + for move_b in picking_b.move_lines: + if move_a.product_id == move_b.product_id: + move_a.move_dest_ids = move_b + move_b.procure_method = "make_to_order" + + cls.pickings = picking_a | picking_b + cls._fill_stock_for_moves(picking_a.move_lines, location=cls.content_loc) + cls.pickings.action_assign() + + cls.dest_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Sub Shelf 1", + "barcode": "subshelf1", + "location_id": cls.shelf1.id, + } + ) + ) + + def test_set_destination_line_partial_qty_with_move_orig_ids(self): + """Scanned destination location with partial qty, but related moves + has to be split and the move has origin moves (with origin moves) + """ + picking_a = self.picking_a + picking_b = self.picking_b + picking_a.move_line_ids.qty_done = 10 + picking_a.action_done() + self.assertEqual(picking_a.state, "done") + self.assertEqual(picking_b.state, "assigned") + self._simulate_pickings_selected(picking_b) + + move_line_c = picking_b.move_line_ids.filtered( + lambda m: m.product_id == self.product_c + ) + move = move_line_c.move_id + + self.assertEqual(move_line_c.product_uom_qty, 10) + self.assertEqual(move_line_c.qty_done, 10) + # Scan partial qty (6/10) + self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line_c.id, + "quantity": move_line_c.product_uom_qty - 4, # Scan 6 qty + "barcode": self.dest_location.barcode, + }, + ) + # Check move line data + self.assertEqual(move_line_c.move_id.product_uom_qty, 6) + self.assertEqual(move_line_c.product_uom_qty, 0) + self.assertEqual(move_line_c.qty_done, 6) + self.assertEqual(move_line_c.state, "done") + # the move has been split + self.assertNotEqual(move_line_c.move_id, move) + + # Check the move handling the remaining qty + self.assertEqual(move.state, "assigned") + move_line = move.move_line_ids + self.assertEqual(move_line.move_id.product_uom_qty, 4) + self.assertEqual(move_line.product_uom_qty, 4) + self.assertEqual(move_line.qty_done, 4) + + def test_set_destination_package_partial_qty_with_move_orig_ids(self): + """Scanned destination location with partial qty, but related moves + has to be split and the move has origin moves + (with package and origin moves) + """ + picking_a = self.picking_a + picking_b = self.picking_b + + # we put 6 in a new package and 4 in another new package + package1 = self.env["stock.quant.package"].create({}) + package2 = self.env["stock.quant.package"].create({}) + line1 = picking_a.move_line_ids + line2 = line1.copy({"product_uom_qty": 4, "qty_done": 4}) + line1.with_context(bypass_reservation_update=True).product_uom_qty = 6 + line1.qty_done = 6 + line1.result_package_id = package1 + line2.result_package_id = package2 + picking_a.action_done() + self.assertEqual(picking_a.state, "done") + self.assertEqual(picking_b.state, "assigned") + # we have 1 move line per package + self.assertEqual(len(picking_b.move_line_ids), 2) + self._simulate_pickings_selected(picking_b) + + move_line = picking_b.move_line_ids.filtered(lambda m: m.package_id == package1) + move = move_line.move_id + + self.assertEqual(move_line.product_uom_qty, 6.0) + self.assertEqual(move_line.qty_done, 6.0) + # Scan partial qty (6/10) + self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": 6.0, # Scan 6 qty + "barcode": self.dest_location.barcode, + }, + ) + # Check move line data + self.assertEqual(move_line.move_id.product_uom_qty, 6) + self.assertEqual(move_line.product_uom_qty, 0) + self.assertEqual(move_line.qty_done, 6) + self.assertEqual(move_line.state, "done") + + # Check the move handling the remaining qty + remaining_move = picking_b.move_lines - move + self.assertEqual(remaining_move.state, "assigned") + remaining_move_line = remaining_move.move_line_ids + self.assertEqual(remaining_move_line.move_id.product_uom_qty, 4) + self.assertEqual(remaining_move_line.product_uom_qty, 4) + self.assertEqual(remaining_move_line.qty_done, 4) From 039853eab1315d0370a84b64fdce585e4b2ed96c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 8 Sep 2020 13:29:22 +0200 Subject: [PATCH 355/940] loc content transfer: fix line sorter When we get an empty recordset for either move lines or pkg levels the comparison fails because x.location_dest_id.complete_name will return False instead of a string. --- shopfloor/actions/location_content_transfer_sorter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py index 154dedb281..b404b318c3 100644 --- a/shopfloor/actions/location_content_transfer_sorter.py +++ b/shopfloor/actions/location_content_transfer_sorter.py @@ -46,8 +46,8 @@ def _sort_key(content): ) def sort(self): - content = [line for line in self.move_lines()] + [ - level for level in self.package_levels() + content = [line for line in self.move_lines() if line] + [ + level for level in self.package_levels() if level ] self._content = sorted(content, key=self._sort_key) From 24883693032ab30bc631595d3b3aa0686c03b997 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 9 Sep 2020 07:13:33 +0200 Subject: [PATCH 356/940] backend: Prevent comparison between False and empty string --- shopfloor/actions/location_content_transfer_sorter.py | 2 +- shopfloor/services/cluster_picking.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py index b404b318c3..1878e45fe8 100644 --- a/shopfloor/actions/location_content_transfer_sorter.py +++ b/shopfloor/actions/location_content_transfer_sorter.py @@ -35,7 +35,7 @@ def _sort_key(content): # postponed content after other contents int(content.shopfloor_postponed), # sort by shopfloor picking sequence - content.location_dest_id.shopfloor_picking_sequence, + content.location_dest_id.shopfloor_picking_sequence or "", # sort by similar destination content.location_dest_id.complete_name, # lines before packages (if we have raw products and packages, raw diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index aad0438fa7..d9f5f464d1 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -320,7 +320,7 @@ def _pick_next_line(self, batch, message=None, force_line=None): def _sort_key_lines(line): return ( line.shopfloor_postponed, - line.location_id.shopfloor_picking_sequence, + line.location_id.shopfloor_picking_sequence or "", line.location_id.name, -int(line.move_id.priority or 1), line.move_id.sequence, From 0486af4c70fef5e23efba082a6d5a149c3dacdec Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 11 Sep 2020 08:31:17 +0200 Subject: [PATCH 357/940] bknd: delivery unify picking status check --- shopfloor/actions/message.py | 12 +- shopfloor/services/delivery.py | 138 ++++++------------ shopfloor/services/service.py | 15 ++ shopfloor/tests/test_delivery_scan_deliver.py | 4 +- 4 files changed, 73 insertions(+), 96 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index b713be668c..51dedf614c 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -72,7 +72,7 @@ def operation_not_found(self): def stock_picking_not_found(self): return { "message_type": "error", - "body": _("This transfer does not exist anymore."), + "body": _("This transfer does not exist or is not available anymore."), } def package_not_found(self): @@ -355,12 +355,18 @@ def location_content_transfer_complete(self, location_src, location_dest): ), } + def transfer_done_success(self, picking): + return { + "message_type": "success", + "body": _("Transfer {} done").format(picking.name), + } + def transfer_confirm_done(self): return { "message_type": "warning", "body": _( - "Not all lines have been processed, do you want to " - "confirm partial operation ?" + "Not all lines have been processed with full quantity. " + "Do you confirm partial operation?" ), } diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 18e9681be0..69679cfa67 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -72,26 +72,6 @@ def _response_for_confirm_done(self, picking, message=None): message=message, ) - def _check_picking_status(self, picking): - """Check if `picking` can be processed. - - If the picking is already done, canceled or didn't belong to the - expected picking type, a response is returned. - - Transitions: - * deliver: always return here with updated data - """ - if picking.state == "done": - return self._response_for_deliver(message=self.msg_store.already_done()) - if picking.state not in ("assigned", "partially_available"): - return self._response_for_deliver( - message=self.msg_store.stock_picking_not_available(picking) - ) - if picking.picking_type_id not in self.picking_types: - return self._response_for_deliver( - message=self.msg_store.cannot_move_something_in_picking_type() - ) - def scan_deliver(self, barcode, picking_id=None): """Scan a stock picking or a package/product/lot @@ -107,7 +87,7 @@ def scan_deliver(self, barcode, picking_id=None): than one package, a package barcode is requested, and if the product is tracked by lot/serial, a lot is asked. - If the barcode is a lot, the mbarcode ove lines for this lot are set to + If the barcode is a lot, the lines for this lot are set to done. However, if the lot is in more than one package, a package barcode is requested. @@ -127,32 +107,33 @@ def scan_deliver(self, barcode, picking_id=None): """ search = self.actions_for("search") picking = search.picking_from_scan(barcode) + barcode_valid = bool(picking) if picking: - response = self._check_picking_status(picking) - if response: - return response - return self._response_for_deliver(picking=picking) + message = self._check_picking_status(picking) + if message: + return self._response_for_deliver(message=message) - # We should have only a picking_id because the client was working - # on it already, so no need to validate the picking type if picking_id: - picking = self.env["stock.picking"].browse(picking_id).exists() + picking = self.env["stock.picking"].browse(picking_id) - package = search.package_from_scan(barcode) - if package: - return self._deliver_package(picking, package) + # Validate picking anyway + if not barcode_valid: + package = search.package_from_scan(barcode) + if package: + return self._deliver_package(picking, package) - product = search.product_from_scan(barcode) - if product: - return self._deliver_product(picking, product) + if not barcode_valid: + product = search.product_from_scan(barcode) + if product: + return self._deliver_product(picking, product) - lot = search.lot_from_scan(barcode) - if lot: - return self._deliver_lot(picking, lot) + if not barcode_valid: + lot = search.lot_from_scan(barcode) + if lot: + return self._deliver_lot(picking, lot) - return self._response_for_deliver( - picking=picking, message=self.msg_store.barcode_not_found() - ) + message = self.msg_store.barcode_not_found() if not barcode_valid else None + return self._response_for_deliver(picking=picking, message=message) def _set_lines_done(self, lines): """Set done quantities on `lines`. @@ -311,7 +292,7 @@ def _action_picking_done(self, picking): return True return False - def list_stock_picking(self): + def list_stock_picking(self, message=None): """Return the list of stock pickings for the picking types It returns only stock picking available or partially available. @@ -320,7 +301,7 @@ def list_stock_picking(self): * manual_selection: next state to show the list of stock pickings """ pickings = self.env["stock.picking"].search(self._pickings_domain(), order="id") - return self._response_for_manual_selection(pickings) + return self._response_for_manual_selection(pickings, message=message) def _pickings_domain(self): return [ @@ -337,13 +318,13 @@ def select(self, picking_id): * manual_selection: the selected stock picking is no longer valid * deliver: with information about the stock.picking """ - picking = self.env["stock.picking"].browse(picking_id).exists() + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self.list_stock_picking(message=message) if picking: return self._response_for_deliver(picking) - response = self.list_stock_picking() - return self._response( - response, message=self.msg_store.stock_picking_not_found() - ) + return self.list_stock_picking(message=self.msg_store.stock_picking_not_found()) def set_qty_done_pack(self, picking_id, package_id): """Set a package to "Done" @@ -354,15 +335,10 @@ def set_qty_done_pack(self, picking_id, package_id): Transitions: * deliver: always return here with updated data """ - picking = self.env["stock.picking"].browse(picking_id).exists() - if picking: - response = self._check_picking_status(picking) - if response: - return response - else: - return self._response_for_deliver( - message=self.msg_store.stock_picking_not_found() - ) + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_deliver(message=message) package = self.env["stock.quant.package"].browse(package_id).exists() if package: response = self._deliver_package(picking, package) @@ -384,15 +360,10 @@ def set_qty_done_line(self, picking_id, move_line_id): Transitions: * deliver: always return here with updated data """ - picking = self.env["stock.picking"].browse(picking_id).exists() - if picking: - response = self._check_picking_status(picking) - if response: - return response - else: - return self._response_for_deliver( - message=self.msg_store.stock_picking_not_found() - ) + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_deliver(message=message) line = self.env["stock.move.line"].browse(move_line_id).exists() if line: if line.package_id: @@ -415,15 +386,10 @@ def reset_qty_done_pack(self, picking_id, package_id): Transitions: * deliver: always return here with updated data """ - picking = self.env["stock.picking"].browse(picking_id).exists() - if picking: - response = self._check_picking_status(picking) - if response: - return response - else: - return self._response_for_deliver( - message=self.msg_store.stock_picking_not_found() - ) + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_deliver(message=message) package = self.env["stock.quant.package"].browse(package_id).exists() if package: lines = self.env["stock.move.line"].search( @@ -451,15 +417,10 @@ def reset_qty_done_line(self, picking_id, move_line_id): Transitions: * deliver: always return here with updated data """ - picking = self.env["stock.picking"].browse(picking_id).exists() - if picking: - response = self._check_picking_status(picking) - if response: - return response - else: - return self._response_for_deliver( - message=self.msg_store.stock_picking_not_found() - ) + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_deliver(message=message) line = self.env["stock.move.line"].browse(move_line_id).exists() if line: if line.picking_id != picking: @@ -486,14 +447,9 @@ def done(self, picking_id, confirm=False): * confirm_done: when not all lines of the stock.picking are done """ picking = self.env["stock.picking"].browse(picking_id).exists() - if picking: - response = self._check_picking_status(picking) - if response: - return response - else: - return self._response_for_deliver( - message=self.msg_store.stock_picking_not_found() - ) + message = self._check_picking_status(picking) + if message: + return self._response_for_deliver(message=message) if self._action_picking_done(picking): return self._response_for_deliver( message=self.msg_store.transfer_complete(picking) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 35dbe6fcd5..c4f212d47a 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -357,3 +357,18 @@ def picking_types(self): ) ) return picking_types + + def _check_picking_status(self, picking): + """Check if `picking` can be processed. + + If the picking is already done, canceled or didn't belong to the + expected picking type, a message is returned. + """ + if not picking.exists(): + return self.msg_store.stock_picking_not_found() + if picking.state == "done": + return self.msg_store.already_done() + if picking.state not in ("assigned", "partially_available"): + return self.msg_store.stock_picking_not_available(picking) + if picking.picking_type_id not in self.picking_types: + return self.msg_store.cannot_move_something_in_picking_type() diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py index 264510337b..97524254bf 100644 --- a/shopfloor/tests/test_delivery_scan_deliver.py +++ b/shopfloor/tests/test_delivery_scan_deliver.py @@ -78,7 +78,7 @@ def test_scan_deliver_error_barcode_not_found(self): "scan_deliver", params={"barcode": "NO VALID BARCODE", "picking_id": None} ) self.assert_response_deliver( - response, message={"message_type": "error", "body": "Barcode not found"} + response, message=self.service.msg_store.barcode_not_found(), ) def test_scan_deliver_error_barcode_not_found_keep_picking(self): @@ -91,7 +91,7 @@ def test_scan_deliver_error_barcode_not_found_keep_picking(self): # if the client was working on a picking (it sends picking_id, then # send refreshed data) picking=self.picking, - message={"message_type": "error", "body": "Barcode not found"}, + message=self.service.msg_store.barcode_not_found(), ) def _test_scan_set_done_ok(self, move_lines, barcode): From 8f6f2b1e09b0588eb586917627b8aa88b4279d6b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 11 Sep 2020 08:32:23 +0200 Subject: [PATCH 358/940] bknd: checkout unify picking status check --- shopfloor/services/checkout.py | 118 +++++++++-------------- shopfloor/tests/test_checkout_done.py | 16 +-- shopfloor/tests/test_checkout_select.py | 8 +- shopfloor/tests/test_checkout_summary.py | 57 +++++++++-- 4 files changed, 106 insertions(+), 93 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 7988ae4e60..eb8f129649 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -277,7 +277,10 @@ def select(self, picking_id): * select_line: the "normal" case, when the user has to put in pack/move lines """ - picking = self.env["stock.picking"].browse(picking_id).exists() + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_manual_selection(message=message) return self._select_picking(picking, "manual_selection") def _select_lines(self, lines): @@ -313,10 +316,9 @@ def scan_line(self, picking_id, barcode): screen to change the qty done and destination pack if needed """ picking = self.env["stock.picking"].browse(picking_id) - if not picking.exists(): - return self._response_for_select_document( - message=self.msg_store.stock_picking_not_found() - ) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) search = self.actions_for("search") @@ -464,10 +466,9 @@ def select_line(self, picking_id, package_id=None, move_line_id=None): assert package_id or move_line_id picking = self.env["stock.picking"].browse(picking_id) - if not picking.exists(): - return self._response_for_select_document( - message=self.msg_store.stock_picking_not_found() - ) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) selection_lines = self._lines_to_pack(picking) if not selection_lines: @@ -484,10 +485,9 @@ def _change_line_qty( self, picking_id, selected_line_ids, move_line_ids, quantity_func ): picking = self.env["stock.picking"].browse(picking_id) - if not picking.exists(): - return self._response_for_select_document( - message=self.msg_store.stock_picking_not_found() - ) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) move_lines = self.env["stock.move.line"].browse(move_line_ids).exists() @@ -689,10 +689,9 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): to close the stock picking """ picking = self.env["stock.picking"].browse(picking_id) - if not picking.exists(): - return self._response_for_select_document( - message=self.msg_store.stock_picking_not_found() - ) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) search = self.actions_for("search") selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() @@ -741,10 +740,9 @@ def new_package(self, picking_id, selected_line_ids): * select_line: goes back to selection of lines to work on next lines """ picking = self.env["stock.picking"].browse(picking_id) - if not picking.exists(): - return self._response_for_select_document( - message=self.msg_store.stock_picking_not_found() - ) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() return self._create_and_assign_new_packaging(picking, selected_lines) @@ -759,10 +757,9 @@ def no_package(self, picking_id, selected_line_ids): * select_line: goes back to selection of lines to work on next lines """ picking = self.env["stock.picking"].browse(picking_id) - if not picking.exists(): - return self._response_for_select_document( - message=self.msg_store.stock_picking_not_found() - ) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() selected_lines.write( {"shopfloor_checkout_done": True, "result_package_id": False} @@ -786,11 +783,9 @@ def list_dest_package(self, picking_id, selected_line_ids): * select_package: when no package is available """ picking = self.env["stock.picking"].browse(picking_id) - if not picking.exists(): - return self._response_for_select_document( - message=self.msg_store.stock_picking_not_found() - ) - + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) lines = self.env["stock.move.line"].browse(selected_line_ids).exists() return self._response_for_select_dest_package(picking, lines) @@ -826,10 +821,9 @@ def scan_dest_package(self, picking_id, selected_line_ids, barcode): * summary: all lines are put in packages """ picking = self.env["stock.picking"].browse(picking_id) - if not picking.exists(): - return self._response_for_select_document( - message=self.msg_store.stock_picking_not_found() - ) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) lines = self.env["stock.move.line"].browse(selected_line_ids).exists() search = self.actions_for("search") package = search.package_from_scan(barcode) @@ -848,10 +842,9 @@ def set_dest_package(self, picking_id, selected_line_ids, package_id): * summary: all lines are put in packages """ picking = self.env["stock.picking"].browse(picking_id) - if not picking.exists(): - return self._response_for_select_document( - message=self.msg_store.stock_picking_not_found() - ) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) lines = self.env["stock.move.line"].browse(selected_line_ids).exists() package = self.env["stock.quant.package"].browse(package_id).exists() return self._set_dest_package_from_selection(picking, lines, package) @@ -863,10 +856,9 @@ def summary(self, picking_id): * summary """ picking = self.env["stock.picking"].browse(picking_id) - if not picking.exists(): - return self._response_for_select_document( - message=self.msg_store.stock_picking_not_found() - ) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) return self._response_for_summary(picking) def _get_allowed_packaging(self): @@ -883,11 +875,9 @@ def list_packaging(self, picking_id, package_id): * summary: if the package_id no longer exists """ picking = self.env["stock.picking"].browse(picking_id) - if not picking.exists(): - return self._response_for_select_document( - message=self.msg_store.stock_picking_not_found() - ) - + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) package = self.env["stock.quant.package"].browse(package_id).exists() packaging_list = self._get_allowed_packaging() return self._response_for_change_packaging(picking, package, packaging_list) @@ -900,10 +890,9 @@ def set_packaging(self, picking_id, package_id, packaging_id): * summary """ picking = self.env["stock.picking"].browse(picking_id) - if not picking.exists(): - return self._response_for_select_document( - message=self.msg_store.stock_picking_not_found() - ) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) package = self.env["stock.quant.package"].browse(package_id).exists() packaging = self.env["product.packaging"].browse(packaging_id).exists() @@ -937,10 +926,9 @@ def cancel_line(self, picking_id, package_id=None, line_id=None): * summary """ picking = self.env["stock.picking"].browse(picking_id) - if not picking.exists(): - return self._response_for_select_document( - message=self.msg_store.stock_picking_not_found() - ) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) package = self.env["stock.quant.package"].browse(package_id).exists() line = self.env["stock.move.line"].browse(line_id).exists() @@ -982,23 +970,16 @@ def done(self, picking_id, confirmation=False): * confirm_done: confirm a partial """ picking = self.env["stock.picking"].browse(picking_id) - if not picking.exists(): - return self._response_for_select_document( - message=self.msg_store.stock_picking_not_found() - ) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) lines = picking.move_line_ids if not confirmation: if not all(line.qty_done == line.product_uom_qty for line in lines): return self._response_for_summary( picking, need_confirm=True, - message={ - "message_type": "warning", - "body": _( - "Not all lines have been processed with full quantity. " - "Do you confirm partial operation?" - ), - }, + message=self.msg_store.transfer_confirm_done(), ) elif not all(line.shopfloor_checkout_done for line in lines): return self._response_for_summary( @@ -1011,10 +992,7 @@ def done(self, picking_id, confirmation=False): ) picking.action_done() return self._response_for_select_document( - message={ - "message_type": "success", - "body": _("Transfer {} done").format(picking.name), - } + message=self.msg_store.transfer_done_success(picking) ) diff --git a/shopfloor/tests/test_checkout_done.py b/shopfloor/tests/test_checkout_done.py index bba6f7af58..c5ff9e9240 100644 --- a/shopfloor/tests/test_checkout_done.py +++ b/shopfloor/tests/test_checkout_done.py @@ -46,11 +46,7 @@ def test_done_partial(self): response, next_state="confirm_done", data={"picking": self._stock_picking_data(self.picking, done=True)}, - message={ - "message_type": "warning", - "body": "Not all lines have been processed with full quantity. " - "Do you confirm partial operation?", - }, + message=self.service.msg_store.transfer_confirm_done(), ) def test_done_partial_confirm(self): @@ -65,10 +61,7 @@ def test_done_partial_confirm(self): self.assert_response( response, next_state="select_document", - message={ - "message_type": "success", - "body": "Transfer {} done".format(self.picking.name), - }, + message=self.service.msg_store.transfer_done_success(self.picking), ) @@ -124,8 +117,5 @@ def test_done_partial_confirm(self): self.assert_response( response, next_state="select_document", - message={ - "message_type": "success", - "body": "Transfer {} done".format(self.picking.name), - }, + message=self.service.msg_store.transfer_done_success(self.picking), ) diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py index 442ab0ae14..3458b76e06 100644 --- a/shopfloor/tests/test_checkout_select.py +++ b/shopfloor/tests/test_checkout_select.py @@ -53,11 +53,15 @@ def _test_error(self, picking, msg): def test_select_error_not_found(self): picking = self._create_picking() picking.sudo().unlink() - self._test_error(picking, "This transfer does not exist anymore.") + self._test_error( + picking, self.service.msg_store.stock_picking_not_found()["body"] + ) def test_select_error_not_available(self): picking = self._create_picking() - self._test_error(picking, "Transfer {} is not available.".format(picking.name)) + self._test_error( + picking, self.service.msg_store.stock_picking_not_available(picking)["body"] + ) def test_select_error_not_allowed(self): picking = self._create_picking(picking_type=self.wh.pick_type_id) diff --git a/shopfloor/tests/test_checkout_summary.py b/shopfloor/tests/test_checkout_summary.py index 2d765930a8..b7d71cc271 100644 --- a/shopfloor/tests/test_checkout_summary.py +++ b/shopfloor/tests/test_checkout_summary.py @@ -2,22 +2,63 @@ class CheckoutSummaryCase(CheckoutCommonCase): - def test_summary_ok(self): - picking = self._create_picking( + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = cls._create_picking( lines=[ - (self.product_a, 10), - (self.product_b, 10), - (self.product_c, 10), - (self.product_d, 10), + (cls.product_a, 10), + (cls.product_b, 10), + (cls.product_c, 10), + (cls.product_d, 10), ] ) - response = self.service.dispatch("summary", params={"picking_id": picking.id}) + def test_summary_picking_not_ready(self): + response = self.service.dispatch( + "summary", params={"picking_id": self.picking.id} + ) + self.assert_response( + response, + next_state="select_document", + data={}, + message=self.service.msg_store.stock_picking_not_available(self.picking), + ) + + def test_summary_not_fully_processed(self): + self._fill_stock_for_moves(self.picking.move_lines, in_package=True) + self.picking.action_assign() + # satisfy only few lines + for ml in self.picking.move_line_ids[:2]: + ml.qty_done = ml.product_uom_qty + ml.shopfloor_checkout_done = True + response = self.service.dispatch( + "summary", params={"picking_id": self.picking.id} + ) + self.assert_response( + response, + next_state="summary", + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": False, + }, + ) + + def test_summary_fully_processed(self): + self._fill_stock_for_moves(self.picking.move_lines, in_package=True) + self.picking.action_assign() + # satisfy only all lines + for ml in self.picking.move_line_ids: + ml.qty_done = ml.product_uom_qty + ml.shopfloor_checkout_done = True + response = self.service.dispatch( + "summary", params={"picking_id": self.picking.id} + ) self.assert_response( response, next_state="summary", data={ - "picking": self._stock_picking_data(picking, done=True), + "picking": self._stock_picking_data(self.picking, done=True), "all_processed": True, }, ) From 27c420c2f218dea4d85a9da28a062f535470116a Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 11 Sep 2020 10:34:19 +0200 Subject: [PATCH 359/940] bknd: fix delivery picking state check In general we can process only pickings that are ready (state = 'assigned'). This is because even if the lines are partially available and the picking is of type 'immediate transfer' it will be available (in ready state) only when all the lines are available. --- shopfloor/services/delivery.py | 34 ++++++++++++++++--- shopfloor/services/service.py | 2 +- .../tests/test_delivery_list_stock_picking.py | 23 +++++++++++-- shopfloor/tests/test_delivery_select.py | 7 ++-- 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 69679cfa67..f3263e2563 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -156,11 +156,17 @@ def _reset_lines(self, lines): line.qty_done = 0 def _deliver_package(self, picking, package): - lines = package.move_line_ids - lines = lines.filtered( + lines = package.move_line_ids.filtered( lambda l: l.state in ("assigned", "partially_available") - and l.picking_id.picking_type_id in self.picking_types ) + # State of the picking might change while we reach this point: check again! + message = self._check_picking_status(lines.mapped("picking_id")) + if message: + message = "\n".join([ + _("Package {} belongs to a picking without a valid state.").format(package.name), + message, + ]) + return self._response_for_deliver(message=message) if not lines: return self._response_for_deliver( picking=picking, @@ -213,6 +219,15 @@ def _deliver_product(self, picking, product): picking, message=self.msg_store.product_not_found_in_pickings() ) + # State of the picking might change while we reach this point: check again! + message = self._check_picking_status(lines.mapped("picking_id")) + if message: + message = "\n".join([ + _("Product {} belongs to a picking without a valid state.").format(product.name), + message, + ]) + return self._response_for_deliver(message=message) + new_picking = fields.first(lines.mapped("picking_id")) # When products are as units outside of packages, we can select them for # packing, but if they are in a package, we want the user to scan the packages. @@ -249,6 +264,15 @@ def _deliver_lot(self, picking, lot): picking, message=self.msg_store.lot_not_found_in_pickings() ) + # State of the picking might change while we reach this point: check again! + message = self._check_picking_status(lines.mapped("picking_id")) + if message: + message = "\n".join([ + _("Lot {} belongs to a picking without a valid state.").format(lot.name), + message, + ]) + return self._response_for_deliver(message=message) + new_picking = fields.first(lines.mapped("picking_id")) # When lots are as units outside of packages, we can select them for @@ -306,7 +330,7 @@ def list_stock_picking(self, message=None): def _pickings_domain(self): return [ ("picking_type_id", "in", self.picking_types.ids), - ("state", "not in", ["cancel", "done", "waiting", "draft"]), + ("state", "=", "assigned"), ] def select(self, picking_id): @@ -446,7 +470,7 @@ def done(self, picking_id, confirm=False): * deliver: error during action * confirm_done: when not all lines of the stock.picking are done """ - picking = self.env["stock.picking"].browse(picking_id).exists() + picking = self.env["stock.picking"].browse(picking_id) message = self._check_picking_status(picking) if message: return self._response_for_deliver(message=message) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index c4f212d47a..0a77c66666 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -368,7 +368,7 @@ def _check_picking_status(self, picking): return self.msg_store.stock_picking_not_found() if picking.state == "done": return self.msg_store.already_done() - if picking.state not in ("assigned", "partially_available"): + if picking.state != "assigned": # the picking must be ready return self.msg_store.stock_picking_not_available(picking) if picking.picking_type_id not in self.picking_types: return self.msg_store.cannot_move_something_in_picking_type() diff --git a/shopfloor/tests/test_delivery_list_stock_picking.py b/shopfloor/tests/test_delivery_list_stock_picking.py index 545a4db435..056557d407 100644 --- a/shopfloor/tests/test_delivery_list_stock_picking.py +++ b/shopfloor/tests/test_delivery_list_stock_picking.py @@ -17,9 +17,28 @@ def setUpClassBaseData(cls): lines=[(cls.product_a, 10), (cls.product_b, 10)] ) + def test_list_stock_picking_ko(self): + """No picking is ready, no picking to list.""" + response = self.service.dispatch("list_stock_picking", params={}) + self.assert_response_manual_selection( + response, pickings=[], + ) + def test_list_stock_picking_ok(self): - pickings = self.picking1 | self.picking2 + """Picking ready to list.""" + # prepare 1st picking + self._fill_stock_for_moves(self.picking1.move_lines) + self.picking1.action_assign() + response = self.service.dispatch("list_stock_picking", params={}) + # picking1 only available + self.assert_response_manual_selection( + response, pickings=self.picking1, + ) + # prepare 2nd picking + self._fill_stock_for_moves(self.picking2.move_lines) + self.picking2.action_assign() response = self.service.dispatch("list_stock_picking", params={}) + # all pickings available self.assert_response_manual_selection( - response, pickings=pickings, + response, pickings=self.picking1 + self.picking2, ) diff --git a/shopfloor/tests/test_delivery_select.py b/shopfloor/tests/test_delivery_select.py index 14b214ef82..ce545658b4 100644 --- a/shopfloor/tests/test_delivery_select.py +++ b/shopfloor/tests/test_delivery_select.py @@ -16,6 +16,10 @@ def setUpClassBaseData(cls): cls.picking2 = cls._create_picking( lines=[(cls.product_a, 10), (cls.product_b, 10)] ) + cls._fill_stock_for_moves(cls.picking1.move_lines) + cls._fill_stock_for_moves(cls.picking2.move_lines) + cls.pickings = cls.picking1 | cls.picking2 + cls.pickings.action_assign() def test_select_ok(self): response = self.service.dispatch( @@ -24,10 +28,9 @@ def test_select_ok(self): self.assert_response_deliver(response, picking=self.picking1) def test_select_not_found(self): - pickings = self.picking1 | self.picking2 response = self.service.dispatch("select", params={"picking_id": -1}) self.assert_response_manual_selection( response, - pickings=pickings, + pickings=self.pickings, message=self.service.msg_store.stock_picking_not_found(), ) From aa3804eff282c265a35bd84860f8f7c0c3ed1f03 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 11 Sep 2020 11:17:32 +0200 Subject: [PATCH 360/940] bknd: delivery check picking status on deliver by pkg|lot|prod When the user scans the barcode of a product/package/lot the picking state might have changed already. This change ensures that before processing the item the picking state is valid. --- shopfloor/services/delivery.py | 38 ++++++++++++++++++++++------------ shopfloor/services/service.py | 21 ++++++++++--------- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index f3263e2563..debbea4126 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -1,4 +1,4 @@ -from odoo import fields +from odoo import _, fields from odoo.osv import expression from odoo.tools.float_utils import float_is_zero @@ -162,10 +162,14 @@ def _deliver_package(self, picking, package): # State of the picking might change while we reach this point: check again! message = self._check_picking_status(lines.mapped("picking_id")) if message: - message = "\n".join([ - _("Package {} belongs to a picking without a valid state.").format(package.name), - message, - ]) + message = "\n".join( + [ + _("Package {} belongs to a picking without a valid state.").format( + package.name + ), + message, + ] + ) return self._response_for_deliver(message=message) if not lines: return self._response_for_deliver( @@ -222,10 +226,14 @@ def _deliver_product(self, picking, product): # State of the picking might change while we reach this point: check again! message = self._check_picking_status(lines.mapped("picking_id")) if message: - message = "\n".join([ - _("Product {} belongs to a picking without a valid state.").format(product.name), - message, - ]) + message = "\n".join( + [ + _("Product {} belongs to a picking without a valid state.").format( + product.name + ), + message, + ] + ) return self._response_for_deliver(message=message) new_picking = fields.first(lines.mapped("picking_id")) @@ -267,10 +275,14 @@ def _deliver_lot(self, picking, lot): # State of the picking might change while we reach this point: check again! message = self._check_picking_status(lines.mapped("picking_id")) if message: - message = "\n".join([ - _("Lot {} belongs to a picking without a valid state.").format(lot.name), - message, - ]) + message = "\n".join( + [ + _("Lot {} belongs to a picking without a valid state.").format( + lot.name + ), + message, + ] + ) return self._response_for_deliver(message=message) new_picking = fields.first(lines.mapped("picking_id")) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 0a77c66666..9cc6c7a8d4 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -358,17 +358,18 @@ def picking_types(self): ) return picking_types - def _check_picking_status(self, picking): - """Check if `picking` can be processed. + def _check_picking_status(self, pickings): + """Check if given pickings can be processed. If the picking is already done, canceled or didn't belong to the expected picking type, a message is returned. """ - if not picking.exists(): - return self.msg_store.stock_picking_not_found() - if picking.state == "done": - return self.msg_store.already_done() - if picking.state != "assigned": # the picking must be ready - return self.msg_store.stock_picking_not_available(picking) - if picking.picking_type_id not in self.picking_types: - return self.msg_store.cannot_move_something_in_picking_type() + for picking in pickings: + if not picking.exists(): + return self.msg_store.stock_picking_not_found() + if picking.state == "done": + return self.msg_store.already_done() + if picking.state != "assigned": # the picking must be ready + return self.msg_store.stock_picking_not_available(picking) + if picking.picking_type_id not in self.picking_types: + return self.msg_store.cannot_move_something_in_picking_type() From 6d8391fa4ba011f11a76d5d7204baf24f2a32301 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 11 Sep 2020 11:18:37 +0200 Subject: [PATCH 361/940] bknd: loc content transfer fix recover pickings domain --- shopfloor/services/location_content_transfer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 31887876a1..a2dfcdd88b 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -147,7 +147,7 @@ def _router_single_or_all_destination(self, pickings, message=None): def _domain_recover_pickings(self): return [ ("user_id", "=", self.env.uid), - ("state", "in", ("assigned", "partially_available")), + ("state", "=", "assigned"), ("picking_type_id", "in", self.picking_types.ids), ] From 5c79ba25e5e9aaf12611450670f38f8d1d90424d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 14 Sep 2020 10:23:45 +0200 Subject: [PATCH 362/940] Fix zone_picking move line domain on scan_location Include only move lines from operation types assigned to current menu item. --- shopfloor/services/zone_picking.py | 2 ++ shopfloor/tests/test_zone_picking_start.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index ad277ca0da..e67a521597 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -244,6 +244,8 @@ def _find_location_move_lines_domain( ] if picking_type: domain += [("picking_id.picking_type_id", "=", picking_type.id)] + else: + domain += [("picking_id.picking_type_id", "in", self.picking_types.ids)] if package: domain += [("package_id", "=", package.id)] if product: diff --git a/shopfloor/tests/test_zone_picking_start.py b/shopfloor/tests/test_zone_picking_start.py index b339bbd893..c8a6fc7933 100644 --- a/shopfloor/tests/test_zone_picking_start.py +++ b/shopfloor/tests/test_zone_picking_start.py @@ -8,6 +8,28 @@ class ZonePickingStartCase(ZonePickingCommonCase): """ + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + # create a picking w/ a different picking type + # which should be excluded from picking types list + bad_picking_type = cls.picking_type.sudo().copy( + { + "name": "Bad type", + "sequence_code": "WH/BAD", + "default_location_src_id": cls.zone_location.id, + "shopfloor_menu_ids": False, + } + ) + cls.extra_picking = extra_picking = cls._create_picking( + lines=[(cls.product_b, 10)], picking_type=bad_picking_type, + ) + cls._fill_stock_for_moves( + extra_picking.move_lines, in_package=True, location=cls.zone_sublocation1 + ) + cls._update_qty_in_location(cls.zone_sublocation1, cls.product_b, 10) + extra_picking.action_assign() + def test_scan_location_wrong_barcode(self): """Scanned location invalid, no location found.""" response = self.service.dispatch( From 566cb66ed9b5b2ff1f0ad52131a99f610aaae157 Mon Sep 17 00:00:00 2001 From: sebalix Date: Wed, 16 Sep 2020 10:55:41 +0200 Subject: [PATCH 363/940] Fix location content transfer tests Tests were broken since c99bc5e0e51fb77907b520e621d193f072ef3bc5 --- shopfloor/tests/test_location_content_transfer_mix.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shopfloor/tests/test_location_content_transfer_mix.py b/shopfloor/tests/test_location_content_transfer_mix.py index b4c6252dba..2b440f5202 100644 --- a/shopfloor/tests/test_location_content_transfer_mix.py +++ b/shopfloor/tests/test_location_content_transfer_mix.py @@ -32,6 +32,8 @@ def setUpClassVars(cls, *args, **kwargs): cls.wh.sudo().delivery_steps = "pick_pack_ship" cls.pack_location = cls.wh.wh_pack_stock_loc_id cls.ship_location = cls.wh.wh_output_stock_loc_id + # Allows zone picking to process PICK picking type + cls.zp_menu.sudo().picking_type_ids += cls.wh.pick_type_id # Allows location content transfer to process PACK picking type cls.menu.sudo().picking_type_ids = cls.wh.pack_type_id cls.wh.pack_type_id.sudo().default_location_dest_id = cls.env.ref( From 60afc35609f947f648e3fb315d28f6ba2e0bb93b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 2 Sep 2020 13:32:58 +0200 Subject: [PATCH 364/940] Implement change.package.lot as a Component instead of Mixin --- shopfloor/actions/__init__.py | 1 + shopfloor/actions/base_action.py | 4 ++++ .../change_package_lot.py} | 24 +++++++++++-------- shopfloor/services/cluster_picking.py | 8 +++---- shopfloor/services/zone_picking.py | 14 +++++------ 5 files changed, 30 insertions(+), 21 deletions(-) rename shopfloor/{services/change_pack_lot_mixin.py => actions/change_package_lot.py} (91%) diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index 42f45d7eee..7aa11fa809 100644 --- a/shopfloor/actions/__init__.py +++ b/shopfloor/actions/__init__.py @@ -17,6 +17,7 @@ """ from . import base_action +from . import change_package_lot from . import data from . import data_detail from . import completion_info diff --git a/shopfloor/actions/base_action.py b/shopfloor/actions/base_action.py index 76eac66eba..b094c2c812 100644 --- a/shopfloor/actions/base_action.py +++ b/shopfloor/actions/base_action.py @@ -10,3 +10,7 @@ class ShopFloorProcessAction(AbstractComponent): def actions_for(self, usage): return self.component(usage=usage) + + @property + def msg_store(self): + return self.actions_for("message") diff --git a/shopfloor/services/change_pack_lot_mixin.py b/shopfloor/actions/change_package_lot.py similarity index 91% rename from shopfloor/services/change_pack_lot_mixin.py rename to shopfloor/actions/change_package_lot.py index e1d9b078d2..5d96a56ea2 100644 --- a/shopfloor/services/change_pack_lot_mixin.py +++ b/shopfloor/actions/change_package_lot.py @@ -1,14 +1,20 @@ from odoo import _ +from odoo.addons.component.core import Component -# TODO use a component instead (in actions) -# delegation > inheritance ;) -class ChangePackLotMixin: - def _change_lot(self, move_line, lot, response_ok_func, response_error_func): + +class ChangePackageLot(Component): + """Provide methods for changing a package or a lot on a move line""" + + _name = "shopfloor.change.package.lot.action" + _inherit = "shopfloor.process.action" + _usage = "change.package.lot" + + def change_lot(self, move_line, lot, response_ok_func, response_error_func): """Change the lot on the move line. - :param response_ok_func: callable used tu return ok response - :param response_error_func: callable used tu return error response + :param response_ok_func: callable used to return ok response + :param response_error_func: callable used to return error response """ # If the lot is part of a package, what we really want # is not to change the lot, but change the package (which will @@ -32,7 +38,7 @@ def _change_lot(self, move_line, lot, response_ok_func, response_error_func): ) if len(lot_package_quants) == 1: package = lot_package_quants.package_id - return self._change_pack_lot_change_package( + return self.change_package( move_line, package, response_ok_func, response_error_func ) elif len(lot_package_quants) > 1: @@ -90,9 +96,7 @@ def _package_identical_move_lines_qty(self, package, move_lines): return grouped_quants == grouped_lines - def _change_pack_lot_change_package( - self, move_line, package, response_ok_func, response_error_func - ): + def change_package(self, move_line, package, response_ok_func, response_error_func): inventory = self.actions_for("inventory") package_level = move_line.package_level_id diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index d9f5f464d1..3ba0e10066 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -4,11 +4,10 @@ from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component -from .change_pack_lot_mixin import ChangePackLotMixin from .service import to_float -class ClusterPicking(Component, ChangePackLotMixin): +class ClusterPicking(Component): """ Methods for the Cluster Picking Process @@ -863,9 +862,10 @@ def change_pack_lot(self, picking_batch_id, move_line_id, barcode): search = self.actions_for("search") response_ok_func = self._response_for_scan_destination response_error_func = self._response_for_change_pack_lot + change_package_lot = self.actions_for("change.package.lot") lot = search.lot_from_scan(barcode) if lot: - response = self._change_lot( + response = change_package_lot.change_lot( move_line, lot, response_ok_func, response_error_func ) if response: @@ -873,7 +873,7 @@ def change_pack_lot(self, picking_batch_id, move_line_id, barcode): package = search.package_from_scan(barcode) if package: - return self._change_pack_lot_change_package( + return change_package_lot.change_package( move_line, package, response_ok_func, response_error_func ) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index e67a521597..b9e5d2f67e 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -6,11 +6,10 @@ from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component -from .change_pack_lot_mixin import ChangePackLotMixin from .service import to_float -class ZonePicking(Component, ChangePackLotMixin): +class ZonePicking(Component): """ Methods for the Zone Picking Process @@ -905,9 +904,9 @@ def change_pack_lot(self, zone_location_id, picking_type_id, move_line_id, barco if not move_line.exists(): return self._response_for_start(message=self.msg_store.record_not_found()) search = self.actions_for("search") - # pre-configured callable used to generate the response as 'ChangePackLotMixin' - # is not aware of the needed response type and related parameters for - # zone picking scenario + # pre-configured callable used to generate the response as the + # change.package.lot component is not aware of the needed response type + # and related parameters for zone picking scenario response_ok_func = functools.partial( self._response_for_set_line_destination, zone_location, picking_type ) @@ -915,16 +914,17 @@ def change_pack_lot(self, zone_location_id, picking_type_id, move_line_id, barco self._response_for_change_pack_lot, zone_location, picking_type ) response = None + change_package_lot = self.actions_for("change.package.lot") # handle lot lot = search.lot_from_scan(barcode) if lot: - response = self._change_lot( + response = change_package_lot.change_lot( move_line, lot, response_ok_func, response_error_func ) # handle package package = search.package_from_scan(barcode) if package: - response = self._change_pack_lot_change_package( + return change_package_lot.change_package( move_line, package, response_ok_func, response_error_func ) # if the response is not an error, we check the move_line status From 1719e636f5d38aaaa791aecd7b770b1ad3c08033 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 17 Sep 2020 07:42:55 +0200 Subject: [PATCH 365/940] checkout: Fix flaky test for list_packaging CheckoutListSetPackagingCase.test_list_packaging_ok is randomly failing, because it relies on the order of the records which indeterminate. As shopfloor depends on the module "product_packaging_type", which changes the default ordering of "product.packaging" to: _order = "product_id, type_sequence" The "sequence" on the packaging is useless. Create packaging types so the order is stable. --- .../tests/test_checkout_change_packaging.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py index 5f79c6ec40..f9488e6714 100644 --- a/shopfloor/tests/test_checkout_change_packaging.py +++ b/shopfloor/tests/test_checkout_change_packaging.py @@ -5,12 +5,17 @@ class CheckoutListSetPackagingCase(CheckoutCommonCase): @classmethod def setUpClassBaseData(cls): super().setUpClassBaseData() + pallet_type = ( + cls.env["product.packaging.type"] + .sudo() + .create({"name": "Pallet", "code": "P", "sequence": 3}) + ) cls.packaging_pallet = ( cls.env["product.packaging"] .sudo() .create( { - "sequence": 3, + "packaging_type_id": pallet_type.id, "name": "Pallet", "barcode": "PPP", "height": 100, @@ -19,12 +24,17 @@ def setUpClassBaseData(cls): } ) ) + box_type = ( + cls.env["product.packaging.type"] + .sudo() + .create({"name": "Box", "code": "B", "sequence": 2}) + ) cls.packaging_box = ( cls.env["product.packaging"] .sudo() .create( { - "sequence": 2, + "packaging_type_id": box_type.id, "name": "Box", "barcode": "BBB", "height": 20, @@ -33,12 +43,17 @@ def setUpClassBaseData(cls): } ) ) + inner_box_type = ( + cls.env["product.packaging.type"] + .sudo() + .create({"name": "Inner Box", "code": "I", "sequence": 1}) + ) cls.packaging_inner_box = ( cls.env["product.packaging"] .sudo() .create( { - "sequence": 1, + "packaging_type_id": inner_box_type.id, "name": "Inner Box", "barcode": "III", "height": 10, From 609f5e3ad88c90d7eab555e4100871663ebb43b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 17 Sep 2020 13:13:08 +0200 Subject: [PATCH 366/940] new method to validate moves in a separate transfer (#72) Calling _action_done() directly on the stock.move while leaving the stock.picking "assigned" with the other moves already required various workarounds as it is normally not an expected flow in Odoo. This method is to be used in services instead of calling _action_done() on the moves. If a move to set to done contains move lines that should not be validated, the line to keep should be extracted beforehand with `move_id.split_other_move_lines`. --- shopfloor/models/stock_move.py | 44 ++++++++++- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_stock_split.py | 112 +++++++++++++++++++++++----- 3 files changed, 136 insertions(+), 21 deletions(-) diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py index acf7371576..da24e18d02 100644 --- a/shopfloor/models/stock_move.py +++ b/shopfloor/models/stock_move.py @@ -1,4 +1,4 @@ -from odoo import models +from odoo import _, models class StockMove(models.Model): @@ -36,3 +36,45 @@ def _action_done(self, cancel_backorder=False): if picking.state == "done": picking.action_done() return moves + + def extract_and_action_done(self): + """Extract the moves in a separate transfer and validate them. + + You can combine this method with `split_other_move_lines` method + to first extract some move lines in a separate move, then validate it + with this method. + """ + moves = self.filtered(lambda m: m.state == "assigned") + if not moves: + return False + for picking in moves.picking_id: + moves_todo = picking.move_lines & moves + if moves_todo == picking.move_lines: + # No need to create a new transfer if we are processing all moves + new_picking = picking + else: + new_picking = picking.copy( + { + "name": "/", + "move_lines": [], + "move_line_ids": [], + "backorder_id": picking.id, + } + ) + new_picking.message_post( + body=_( + "Created from backorder " + "%s." + ) + % (picking.id, picking.name) + ) + moves_todo.write({"picking_id": new_picking.id}) + moves_todo.package_level_id.write({"picking_id": new_picking.id}) + moves_todo.move_line_ids.write({"picking_id": new_picking.id}) + moves_todo.move_line_ids.package_level_id.write( + {"picking_id": new_picking.id} + ) + new_picking.action_assign() + assert new_picking.state == "assigned" + new_picking.action_done() + return True diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index bfa6c153a4..ca80513434 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -51,3 +51,4 @@ from . import test_zone_picking_unload_all from . import test_zone_picking_unload_set_destination from . import test_misc +from . import test_stock_split diff --git a/shopfloor/tests/test_stock_split.py b/shopfloor/tests/test_stock_split.py index 9a3ac8e852..b39e22878f 100644 --- a/shopfloor/tests/test_stock_split.py +++ b/shopfloor/tests/test_stock_split.py @@ -14,7 +14,7 @@ def setUpClass(cls): cls.pack_location = cls.warehouse.wh_pack_stock_loc_id cls.ship_location = cls.warehouse.wh_output_stock_loc_id cls.stock_location = cls.env.ref("stock.stock_location_stock") - # Create a product + # Create products cls.product_a = ( cls.env["product.product"] .sudo() @@ -39,6 +39,30 @@ def setUpClass(cls): } ) ) + cls.product_b = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product B", + "type": "product", + "default_code": "B", + "barcode": "B", + "weight": 2, + } + ) + ) + cls.product_b_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box", + "product_id": cls.product_b.id, + "barcode": "ProductBBox", + } + ) + ) # Put product_a quantities in different packages to get several move lines cls.package_1 = cls.env["stock.quant.package"].create({"name": "PACKAGE_1"}) cls.package_2 = cls.env["stock.quant.package"].create({"name": "PACKAGE_2"}) @@ -53,6 +77,8 @@ def setUpClass(cls): cls._update_qty_in_location( cls.stock_location, cls.product_a, 5, package=cls.package_3 ) + # Put product_b quantities in stock + cls._update_qty_in_location(cls.stock_location, cls.product_b, 10) # Create the pick/pack/ship transfer cls.ship_move_a = cls.env["stock.move"].create( { @@ -68,12 +94,28 @@ def setUpClass(cls): "state": "draft", } ) - cls.ship_move_a._assign_picking() - cls.ship_move_a._action_confirm(merge=False) - cls.pack_move = cls.ship_move_a.move_orig_ids[0] - cls.pick_move = cls.pack_move.move_orig_ids[0] - cls.picking = cls.pick_move.picking_id - cls.packing = cls.pack_move.picking_id + cls.ship_move_b = cls.env["stock.move"].create( + { + "name": cls.product_b.display_name, + "product_id": cls.product_b.id, + "product_uom_qty": 4, + "product_uom": cls.product_b.uom_id.id, + "location_id": cls.ship_location.id, + "location_dest_id": cls.customer_location.id, + "warehouse_id": cls.warehouse.id, + "picking_type_id": cls.warehouse.out_type_id.id, + "procure_method": "make_to_order", + "state": "draft", + } + ) + (cls.ship_move_a | cls.ship_move_b)._assign_picking() + (cls.ship_move_a | cls.ship_move_b)._action_confirm(merge=False) + cls.pack_move_a = cls.ship_move_a.move_orig_ids[0] + cls.pick_move_a = cls.pack_move_a.move_orig_ids[0] + cls.pack_move_b = cls.ship_move_b.move_orig_ids[0] + cls.pick_move_b = cls.pack_move_b.move_orig_ids[0] + cls.picking = cls.pick_move_a.picking_id + cls.packing = cls.pack_move_a.picking_id cls.picking.action_assign() @classmethod @@ -90,27 +132,27 @@ def _update_qty_in_location( ) def test_split_pickings_from_source_location(self): - dest_location = self.pick_move.location_dest_id.sudo().copy( + dest_location = self.pick_move_a.location_dest_id.sudo().copy( { - "name": self.pick_move.location_dest_id.name + "_2", - "barcode": self.pick_move.location_dest_id.barcode + "_2", - "location_id": self.pick_move.location_dest_id.id, + "name": self.pick_move_a.location_dest_id.name + "_2", + "barcode": self.pick_move_a.location_dest_id.barcode + "_2", + "location_id": self.pick_move_a.location_dest_id.id, } ) # Pick goods from stock and move some of them to a different destination - self.assertEqual(self.pick_move.state, "assigned") - for i, move_line in enumerate(self.pick_move.move_line_ids): + self.assertEqual(self.pick_move_a.state, "assigned") + for i, move_line in enumerate(self.pick_move_a.move_line_ids): move_line.qty_done = move_line.product_uom_qty if i % 2: move_line.location_dest_id = dest_location - self.pick_move.with_context(_sf_no_backorder=True)._action_done() - self.assertEqual(self.pick_move.state, "done") + self.pick_move_a.with_context(_sf_no_backorder=True)._action_done() + self.assertEqual(self.pick_move_a.state, "done") # Pack step, we want to split move lines from common source location - self.assertEqual(self.pack_move.state, "assigned") - move_lines_to_process = self.pack_move.move_line_ids.filtered( + self.assertEqual(self.pack_move_a.state, "assigned") + move_lines_to_process = self.pack_move_a.move_line_ids.filtered( lambda ml: ml.location_id == dest_location ) - self.assertEqual(len(self.pack_move.move_line_ids), 3) + self.assertEqual(len(self.pack_move_a.move_line_ids), 3) self.assertEqual(len(self.packing.package_level_ids), 3) self.assertEqual(len(move_lines_to_process), 1) new_packing = move_lines_to_process._split_pickings_from_source_location() @@ -120,9 +162,39 @@ def test_split_pickings_from_source_location(self): self.assertTrue(new_packing != self.packing) self.assertEqual(new_packing.backorder_id, self.packing) self.assertEqual( - self.pick_move.move_dest_ids.picking_id, self.packing | new_packing + self.pick_move_a.move_dest_ids.picking_id, self.packing | new_packing ) self.assertEqual(move_lines_to_process.state, "assigned") self.assertEqual( - set(self.pack_move.move_line_ids.mapped("state")), {"assigned"} + set(self.pack_move_a.move_line_ids.mapped("state")), {"assigned"} ) + + def test_extract_and_action_done_one_move(self): + self.assertFalse(self.picking.backorder_ids) + self.assertEqual(self.picking.state, "assigned") + for move_line in self.pick_move_b.move_line_ids: + move_line.qty_done = move_line.product_uom_qty + self.pick_move_b.extract_and_action_done() + new_picking = self.picking.backorder_ids + self.assertTrue(new_picking) + # Check move lines repartition + self.assertNotIn(self.pick_move_b, self.picking.move_lines) + self.assertEqual(new_picking.move_lines, self.pick_move_b) + # Check states + self.assertEqual(self.picking.state, "assigned") + self.assertEqual(self.pick_move_b.state, "done") + self.assertEqual(new_picking.state, "done") + + def test_extract_and_action_done_several_moves(self): + self.assertFalse(self.picking.backorder_ids) + self.assertEqual(self.picking.state, "assigned") + for move_line in self.picking.move_line_ids: + move_line.qty_done = move_line.product_uom_qty + self.picking.move_lines.extract_and_action_done() + # No backorder as all moves of the picking have been validated + new_picking = self.picking.backorder_ids + self.assertFalse(new_picking) + # Check move lines repartition + self.assertEqual(len(self.picking.move_lines), 2) + # Check states + self.assertEqual(self.picking.state, "done") From a510f2f23191c3b2c5842257b86f688e4c925a30 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 9 Sep 2020 12:18:43 +0200 Subject: [PATCH 367/940] pack transfer: check of scanned dest. location vs move's destination complete commit fcab62b which added this check for other scenarios In the single pack transfer, prevent to set a destination on the move line which is not equal or below the move's destination. --- shopfloor/services/single_pack_transfer.py | 11 +-- shopfloor/tests/test_single_pack_transfer.py | 77 ++++++++++++++++++-- 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index f437449b05..d6212e46be 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -134,7 +134,7 @@ def _is_dest_location_valid(self, move, scanned_location): """Forbid a dest location to be used""" return scanned_location.is_sublocation_of( move.picking_id.picking_type_id.default_location_dest_id - ) + ) and scanned_location.is_sublocation_of(move.location_dest_id) def _is_dest_location_to_confirm(self, move, scanned_location): """Destination that could be used but need confirmation""" @@ -172,14 +172,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False): ) if self._is_dest_location_to_confirm(move, scanned_location): - if confirmation: - # If the destination of the move would be incoherent - # (move line outside of it), we change the moves' destination - # TODO in other scenarios, we forbid this. Check if we want - # to forbid it as well. - if not scanned_location.is_sublocation_of(move.location_dest_id): - move.location_dest_id = scanned_location.id - else: + if not confirmation: return self._response_for_scan_location( package_level, confirmation_required=True, diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index 72cdd8ba5d..170321fee0 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -73,8 +73,8 @@ def _response_package_level_data(self, package_level): return { "id": package_level.id, "name": package_level.package_id.name, - "location_src": self.data.location(self.shelf1), - "location_dest": self.data.location(self.shelf2), + "location_src": self.data.location(package_level.location_id), + "location_dest": self.data.location(package_level.location_dest_id), "picking": self.data.picking(self.picking), "product": self.data.product(self.product_a), } @@ -547,7 +547,7 @@ def test_validate_location_not_found(self): ) def test_validate_location_forbidden(self): - """Test a call on /validate on a forbidden location + """Test a call on /validate on a forbidden location (not child of type) The pre-conditions: @@ -557,7 +557,7 @@ def test_validate_location_forbidden(self): * No change in odoo, Transition with a message - Note: a forbidden location is when a location is not a child + Note: the location is forbidden when a location is not a child of the destination location of the picking type used for the process """ # setup the picking as we need, like if the move line @@ -580,6 +580,47 @@ def test_validate_location_forbidden(self): message={"message_type": "error", "body": "You cannot place it here"}, ) + def test_validate_location_forbidden_move_invalid(self): + """Test a call on /validate on a forbidden location (not child of move) + + The pre-conditions: + + * /start has been called + + Expected result: + + * No change in odoo, Transition with a message + + Note: the location is forbidden when a location is not a child + of the destination location of the move + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started() + + move = package_level.move_line_ids.move_id + # take the parent of the expected dest.: not allowed + location = move.location_dest_id.location_id + # allow this location to be used in the picking type, otherwise, + # we check the wrong condition + self.picking_type.sudo().default_location_dest_id = location + + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + # this location is outside of the expected destination + "location_barcode": location.barcode, + }, + ) + + self.assert_response( + response, + next_state="scan_location", + data=self.ANY, + message={"message_type": "error", "body": "You cannot place it here"}, + ) + def test_validate_location_to_confirm(self): """Test a call on /validate on a location to confirm @@ -599,20 +640,44 @@ def test_validate_location_to_confirm(self): # was already started by the first step (start operation) package_level = self._simulate_started() + sub_shelf1 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "subshelf1", + "barcode": "subshelf1", + "location_id": self.shelf2.id, + } + ) + ) + sub_shelf2 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "subshelf2", + "barcode": "subshelf2", + "location_id": self.shelf2.id, + } + ) + ) + # expected destination is 'shelf2', we'll scan shelf1 which must # ask a confirmation to the user (it's still in the same picking type) + package_level.location_dest_id = sub_shelf1 with mock.patch.object(type(self.picking), "action_done") as action_done: response = self.service.dispatch( "validate", params={ "package_level_id": package_level.id, - "location_barcode": self.shelf1.barcode, + "location_barcode": sub_shelf2.barcode, }, ) action_done.assert_not_called() message = self.service.actions_for("message").confirm_location_changed( - self.shelf2, self.shelf1 + sub_shelf1, sub_shelf2 ) self.assert_response( response, From 69be16754cbf2d1fdeba668886497de0080c6495 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 17 Sep 2020 13:28:38 +0200 Subject: [PATCH 368/940] single pack transfer: extract move in new picking and use action_done Calling _action_done() directly on the stock.move while leaving the stock.picking "assigned" with the other moves already required various workarounds as it is normally not an expected flow in Odoo. A previous commit added a method "extract_and_action_done" on the moves, which extract the move(s) to set to done in its own stock.picking and call action_done() on it. We ensure it works as intented by the stock module. The remaining code for the workarounds (_sf_no_backorder, ...) will be removed in a subsequent commit. --- shopfloor/services/single_pack_transfer.py | 2 +- shopfloor/tests/test_single_pack_transfer.py | 71 +++++++++----------- 2 files changed, 32 insertions(+), 41 deletions(-) diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index d6212e46be..46f5526225 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -202,7 +202,7 @@ def _set_destination_and_done(self, move, scanned_location): # when writing the destination on the package level, it writes # on the move lines move.move_line_ids.package_level_id.location_dest_id = scanned_location - move._action_done() + move.extract_and_action_done() def cancel(self, package_level_id): package_level = self.env["stock.package_level"].browse(package_level_id) diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index 170321fee0..298ab080c2 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -1,5 +1,3 @@ -from unittest import mock - from odoo.tests.common import Form from .common import CommonCase @@ -399,15 +397,13 @@ def test_validate(self): # now, call the service to proceed with validation of the # movement - with mock.patch.object(type(self.picking), "action_done") as action_done: - response = self.service.dispatch( - "validate", - params={ - "package_level_id": package_level.id, - "location_barcode": self.shelf2.barcode, - }, - ) - action_done.assert_called_once() + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": self.shelf2.barcode, + }, + ) self.assert_response( response, @@ -469,15 +465,14 @@ def test_validate_completion_info(self): # now, call the service to proceed with validation of the # movement - with mock.patch.object(type(self.picking), "action_done") as action_done: - response = self.service.dispatch( - "validate", - params={ - "package_level_id": package_level.id, - "location_barcode": self.shelf2.barcode, - }, - ) - action_done.assert_called_once() + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": self.shelf2.barcode, + }, + ) + self.assertEqual(package_level.picking_id.state, "done") self.assert_response( response, @@ -666,15 +661,13 @@ def test_validate_location_to_confirm(self): # expected destination is 'shelf2', we'll scan shelf1 which must # ask a confirmation to the user (it's still in the same picking type) package_level.location_dest_id = sub_shelf1 - with mock.patch.object(type(self.picking), "action_done") as action_done: - response = self.service.dispatch( - "validate", - params={ - "package_level_id": package_level.id, - "location_barcode": sub_shelf2.barcode, - }, - ) - action_done.assert_not_called() + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": sub_shelf2.barcode, + }, + ) message = self.service.actions_for("message").confirm_location_changed( sub_shelf1, sub_shelf2 @@ -714,17 +707,15 @@ def test_validate_location_with_confirm(self): # expected destination is 'shelf1', we'll scan shelf2 which must # ask a confirmation to the user (it's still in the same picking type) - with mock.patch.object(type(self.picking), "action_done") as action_done: - response = self.service.dispatch( - "validate", - params={ - "package_level_id": package_level.id, - "location_barcode": self.shelf2.barcode, - # acknowledge the change of destination - "confirmation": True, - }, - ) - action_done.assert_called_once() + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": self.shelf2.barcode, + # acknowledge the change of destination + "confirmation": True, + }, + ) self.assert_response( response, From 611db7b553a74c1c5e2350ee72c6f63630568c1b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 17 Sep 2020 12:58:17 +0200 Subject: [PATCH 369/940] zone picking: extract move in new picking and use action_done Calling _action_done() directly on the stock.move while leaving the stock.picking "assigned" with the other moves already required various workarounds as it is normally not an expected flow in Odoo. A previous commit added a method "extract_and_action_done" on the moves, which extract the move(s) to set to done in its own stock.picking and call action_done() on it. We ensure it works as intented by the stock module. The remaining code for the workarounds (_sf_no_backorder, ...) will be removed in a subsequent commit. --- shopfloor/services/zone_picking.py | 7 +- .../test_zone_picking_set_line_destination.py | 120 +++++++++--------- .../tests/test_zone_picking_unload_all.py | 20 ++- ...est_zone_picking_unload_set_destination.py | 66 +++++----- 4 files changed, 104 insertions(+), 109 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index b9e5d2f67e..d51ae01c0e 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -540,11 +540,10 @@ def _set_destination_location( move_line.qty_done = quantity # if the move has other move lines, it is split to have only this move line move_line.move_id.split_other_move_lines(move_line) - # set to done (without backorder) - move_line.move_id.with_context(_sf_no_backorder=True)._action_done() # try to re-assign any split move (in case of partial qty) if "confirmed" in move_line.picking_id.move_lines.mapped("state"): move_line.picking_id.action_assign() + move_line.move_id.extract_and_action_done() location_changed = True # Zero check zero_check = picking_type.shopfloor_zero_check @@ -1073,7 +1072,7 @@ def set_destination_all( self._write_destination_on_lines(buffer_lines, location) # set lines to done + refresh buffer lines (should be empty) moves = buffer_lines.mapped("move_id") - moves.with_context(_sf_no_backorder=True)._action_done() + moves.extract_and_action_done() message = self.msg_store.buffer_complete() buffer_lines = self._find_buffer_move_lines(zone_location, picking_type) else: @@ -1275,7 +1274,7 @@ def unload_set_destination( self._write_destination_on_lines(buffer_lines, location) # set lines to done + refresh buffer lines (should be empty) moves = buffer_lines.mapped("move_id") - moves.with_context(_sf_no_backorder=True)._action_done() + moves.extract_and_action_done() buffer_lines = self._find_buffer_move_lines(zone_location, picking_type) if buffer_lines: return self._response_for_unload_single( diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py index 9986f78489..33fc91473c 100644 --- a/shopfloor/tests/test_zone_picking_set_line_destination.py +++ b/shopfloor/tests/test_zone_picking_set_line_destination.py @@ -1,5 +1,3 @@ -from unittest import mock - from .test_zone_picking_base import ZonePickingCommonCase @@ -180,19 +178,18 @@ def test_set_destination_location_no_other_move_line_full_qty(self): self.assertEqual(len(moves_before), 1) self.assertEqual(len(moves_before.move_line_ids), 1) move_line = moves_before.move_line_ids - with mock.patch.object(type(self.picking1), "action_done") as action_done: - response = self.service.dispatch( - "set_destination", - params={ - "zone_location_id": zone_location.id, - "picking_type_id": picking_type.id, - "move_line_id": move_line.id, - "barcode": self.packing_location.barcode, - "quantity": move_line.product_uom_qty, - "confirmation": False, - }, - ) - action_done.assert_called_once() + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": move_line.product_uom_qty, + "confirmation": False, + }, + ) + self.assertEqual(move_line.state, "done") # Check picking data moves_after = self.picking1.move_lines self.assertEqual(moves_before, moves_after) @@ -232,19 +229,17 @@ def test_set_destination_location_no_other_move_line_partial_qty(self): move_line = moves_before.move_line_ids # we need a destination package if we want to scan a destination location move_line.result_package_id = self.free_package - with mock.patch.object(type(self.picking3), "action_done") as action_done: - response = self.service.dispatch( - "set_destination", - params={ - "zone_location_id": zone_location.id, - "picking_type_id": picking_type.id, - "move_line_id": move_line.id, - "barcode": barcode, - "quantity": 6, - "confirmation": False, - }, - ) - action_done.assert_not_called() + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": barcode, + "quantity": 6, + "confirmation": False, + }, + ) self.assert_response_set_line_destination( response, zone_location, @@ -281,28 +276,31 @@ def test_set_destination_location_several_move_line_full_qty(self): # we need a destination package if we want to scan a destination location move_line.result_package_id = self.free_package other_move_line = moves_before.move_line_ids[1] - with mock.patch.object(type(self.picking4), "action_done") as action_done: - response = self.service.dispatch( - "set_destination", - params={ - "zone_location_id": zone_location.id, - "picking_type_id": picking_type.id, - "move_line_id": move_line.id, - "barcode": self.packing_location.barcode, - "quantity": move_line.product_uom_qty, # 6 qty - "confirmation": False, - }, - ) - action_done.assert_not_called() + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": move_line.product_uom_qty, # 6 qty + "confirmation": False, + }, + ) + self.assertEqual(move_line.state, "done") # Check picking data (move has been split in two, 6 done and 4 remaining) - moves_after = self.picking4.move_lines - self.assertEqual(len(moves_after), 2) - self.assertEqual(moves_after[0].product_uom_qty, 6) - self.assertEqual(moves_after[0].state, "done") - self.assertEqual(moves_after[0].move_line_ids.product_uom_qty, 0) - self.assertEqual(moves_after[1].product_uom_qty, 4) - self.assertEqual(moves_after[1].state, "assigned") - self.assertEqual(moves_after[1].move_line_ids.product_uom_qty, 4) + + done_picking = self.picking4.backorder_ids + self.assertEqual(done_picking.state, "done") + self.assertEqual(self.picking4.state, "assigned") + move_after = self.picking4.move_lines + self.assertEqual(len(move_after), 1) + self.assertEqual(move_line.move_id.product_uom_qty, 6) + self.assertEqual(move_line.move_id.state, "done") + self.assertEqual(move_line.move_id.move_line_ids.product_uom_qty, 0) + self.assertEqual(move_after.product_uom_qty, 4) + self.assertEqual(move_after.state, "assigned") + self.assertEqual(move_after.move_line_ids.product_uom_qty, 4) self.assertEqual(move_line.qty_done, 6) self.assertNotEqual(move_line.move_id, other_move_line.move_id) # Check response @@ -342,19 +340,17 @@ def test_set_destination_location_several_move_line_partial_qty(self): move_line = moves_before.move_line_ids[0] # we need a destination package if we want to scan a destination location move_line.result_package_id = self.free_package - with mock.patch.object(type(self.picking4), "action_done") as action_done: - response = self.service.dispatch( - "set_destination", - params={ - "zone_location_id": zone_location.id, - "picking_type_id": picking_type.id, - "move_line_id": move_line.id, - "barcode": barcode, - "quantity": 4, # 4/6 qty - "confirmation": False, - }, - ) - action_done.assert_not_called() + response = self.service.dispatch( + "set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "move_line_id": move_line.id, + "barcode": barcode, + "quantity": 4, # 4/6 qty + "confirmation": False, + }, + ) self.assert_response_set_line_destination( response, zone_location, diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py index 8b29c74687..05c6f8e16e 100644 --- a/shopfloor/tests/test_zone_picking_unload_all.py +++ b/shopfloor/tests/test_zone_picking_unload_all.py @@ -1,5 +1,3 @@ -from unittest import mock - from .test_zone_picking_base import ZonePickingCommonCase @@ -197,16 +195,14 @@ def test_set_destination_all_ok(self): another_package, ) # set destination location for all lines in the buffer - with mock.patch.object(type(self.picking5), "action_done") as action_done: - response = self.service.dispatch( - "set_destination_all", - params={ - "zone_location_id": zone_location.id, - "picking_type_id": picking_type.id, - "barcode": self.packing_location.barcode, - }, - ) - action_done.assert_called_once() + response = self.service.dispatch( + "set_destination_all", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": self.packing_location.barcode, + }, + ) # check data self.assertEqual(self.picking5.state, "done") # buffer should be empty diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py index b1f01a6de8..a27e4356af 100644 --- a/shopfloor/tests/test_zone_picking_unload_set_destination.py +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -1,5 +1,3 @@ -from unittest import mock - from .test_zone_picking_base import ZonePickingCommonCase @@ -223,18 +221,16 @@ def test_unload_set_destination_ok_buffer_empty(self): move_line.product_uom_qty, self.free_package, ) - with mock.patch.object(type(self.picking1), "action_done") as action_done: - response = self.service.dispatch( - "unload_set_destination", - params={ - "zone_location_id": zone_location.id, - "picking_type_id": picking_type.id, - "package_id": self.free_package.id, - "barcode": packing_sublocation.barcode, - "confirmation": True, - }, - ) - action_done.assert_called_once() + response = self.service.dispatch( + "unload_set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "package_id": self.free_package.id, + "barcode": packing_sublocation.barcode, + "confirmation": True, + }, + ) # check data self.assertEqual(move_line.location_dest_id, packing_sublocation) self.assertEqual(move_line.move_id.state, "done") @@ -255,8 +251,9 @@ def test_unload_set_destination_ok_buffer_not_empty(self): self.another_package = self.env["stock.quant.package"].create( {"name": "ANOTHER_PACKAGE"} ) + move_lines = self.picking5.move_line_ids for move_line, package_dest in zip( - self.picking5.move_line_ids, self.free_package | self.another_package + move_lines, self.free_package | self.another_package ): self.service._set_destination_package( zone_location, @@ -265,24 +262,31 @@ def test_unload_set_destination_ok_buffer_not_empty(self): move_line.product_uom_qty, package_dest, ) - # process 1/2 buffer line - with mock.patch.object(type(self.picking5), "action_done") as action_done: - response = self.service.dispatch( - "unload_set_destination", - params={ - "zone_location_id": zone_location.id, - "picking_type_id": picking_type.id, - "package_id": self.free_package.id, - "barcode": self.packing_location.barcode, - }, - ) - action_done.assert_not_called() - # check data - move_line = self.picking5.move_line_ids.filtered( + free_package_line = move_lines.filtered( lambda l: l.result_package_id == self.free_package ) - self.assertEqual(move_line.location_dest_id, self.packing_location) - self.assertEqual(move_line.move_id.state, "done") + another_package_line = move_lines - free_package_line + + # process 1/2 buffer line + response = self.service.dispatch( + "unload_set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "package_id": self.free_package.id, + "barcode": self.packing_location.barcode, + }, + ) + # check data + done_picking = self.picking5.backorder_ids + self.assertEqual(done_picking.state, "done") + self.assertEqual(done_picking.move_line_ids, free_package_line) + + self.assertEqual(free_package_line.location_dest_id, self.packing_location) + self.assertEqual(free_package_line.move_id.state, "done") + + self.assertEqual(self.picking5.move_line_ids, another_package_line) + # check response buffer_line = self.service._find_buffer_move_lines(zone_location, picking_type) completion_info = self.service.actions_for("completion.info") From 2b8d4afb8de61ef360171bd7c5db504f1b159239 Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 17 Sep 2020 09:37:28 +0200 Subject: [PATCH 370/940] location transfer: extract move in new picking and use action_done --- .../services/location_content_transfer.py | 20 +- ...ransfer_set_destination_package_or_line.py | 191 ++++++++++-------- 2 files changed, 110 insertions(+), 101 deletions(-) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index a2dfcdd88b..4d28698ff6 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -525,13 +525,6 @@ def set_destination_package( After the destination is set, the move is set to done. - Beware, when _action_done() is called on the move, the normal behavior - of Odoo would be to create a backorder transfer. We don't want this or - we would have a backorder per move. The context key - ``_sf_no_backorder`` disables the creation of backorders, it must be set - on all moves, but the last one of a transfer (so in case something was not - available, a backorder is created). - Transitions: * scan_destination: invalid destination or could not * start_single: continue with the next package level / line @@ -576,7 +569,7 @@ def set_destination_package( # split the move to process only the lines related to the package. package_move.split_other_move_lines(package_move_lines) self._write_destination_on_lines(package_level.move_line_ids, scanned_location) - package_moves.with_context(_sf_no_backorder=True)._action_done() + package_moves.extract_and_action_done() move_lines = self._find_transfer_move_lines(location) message = self.msg_store.location_content_transfer_item_complete( scanned_location @@ -596,13 +589,6 @@ def set_destination_line( After the destination and quantity are set, the move is set to done. - Beware, when _action_done() is called on the move, the normal behavior - of Odoo would be to create a backorder transfer. We don't want this or - we would have a backorder per move. The context key - ``_sf_no_backorder`` disables the creation of backorders, it must be set - on all moves, but the last one of a transfer (so in case something was not - available, a backorder is created). - Transitions: * scan_destination: invalid destination or could not * start_single: continue with the next package level / line @@ -647,13 +633,13 @@ def set_destination_line( new_move = self.env["stock.move"].browse(new_move_id) new_move.move_line_ids = move_line # Ensure that the remaining qty to process is reserved as before - current_move._recompute_state() + (new_move | current_move)._recompute_state() (new_move | current_move)._action_assign() for remaining_move_line in current_move.move_line_ids: remaining_move_line.qty_done = remaining_move_line.product_uom_qty move_line.move_id.split_other_move_lines(move_line) self._write_destination_on_lines(move_line, scanned_location) - move_line.move_id.with_context(_sf_no_backorder=True)._action_done() + move_line.move_id.extract_and_action_done() move_lines = self._find_transfer_move_lines(location) message = self.msg_store.location_content_transfer_item_complete( scanned_location diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py index 0543243704..2e5b9c27aa 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py @@ -1,4 +1,5 @@ -from unittest import mock +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from .test_location_content_transfer_base import LocationContentTransferCommonCase @@ -153,17 +154,21 @@ def test_set_destination_package_dest_location_to_confirm(self): def test_set_destination_package_dest_location_ok(self): """Scanned destination location valid, moves set to done.""" - package_level = self.picking1.package_level_ids[0] - with mock.patch.object(type(self.picking1), "action_done") as action_done: - response = self.service.dispatch( - "set_destination_package", - params={ - "location_id": self.content_loc.id, - "package_level_id": package_level.id, - "barcode": self.dest_location.barcode, - }, - ) - action_done.assert_called_once() + original_picking = self.picking1 + package_level = original_picking.package_level_ids[0] + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.dest_location.barcode, + }, + ) + # Check the data (the whole transfer has been validated here w/o backorder) + self.assertFalse(original_picking.backorder_ids) + self.assertEqual(original_picking.state, "done") + self.assertEqual(package_level.state, "done") + # Check the response move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( response, @@ -283,7 +288,8 @@ def test_set_destination_line_dest_location_to_confirm(self): def test_set_destination_line_dest_location_ok(self): """Scanned destination location valid, moves set to done.""" - move_line = self.picking2.move_line_ids[0] + original_picking = self.picking2 + move_line = original_picking.move_line_ids[0] response = self.service.dispatch( "set_destination_line", params={ @@ -293,6 +299,15 @@ def test_set_destination_line_dest_location_ok(self): "barcode": self.dest_location.barcode, }, ) + # Check the resulting data + # We got a new picking as the original one had two moves (and we + # validated only one) + new_picking = move_line.picking_id + self.assertTrue(new_picking != original_picking) + self.assertEqual(move_line.move_id.state, "done") + self.assertEqual(move_line.picking_id.state, "done") + self.assertEqual(original_picking.state, "assigned") + # Check the response move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( response, @@ -301,14 +316,13 @@ def test_set_destination_line_dest_location_ok(self): self.dest_location ), ) - self.assertEqual(move_line.move_id.state, "done") - self.assertEqual(move_line.picking_id.state, "assigned") def test_set_destination_line_partial_qty(self): """Scanned destination location with partial qty, but related moves has to be splitted. """ - move_line_c = self.picking2.move_line_ids.filtered( + original_picking = self.picking2 + move_line_c = original_picking.move_line_ids.filtered( lambda m: m.product_id == self.product_c ) move = move_line_c.move_id @@ -324,12 +338,16 @@ def test_set_destination_line_partial_qty(self): "barcode": self.dest_location.barcode, }, ) + done_picking = original_picking.backorder_ids # Check move line data self.assertEqual(move_line_c.move_id.product_uom_qty, 6) self.assertEqual(move_line_c.product_uom_qty, 0) self.assertEqual(move_line_c.qty_done, 6) self.assertEqual(move_line_c.state, "done") + self.assertEqual(original_picking.backorder_ids, done_picking) + self.assertEqual(done_picking.state, "done") # the move is split with the remaining + self.assertEqual(original_picking.state, "assigned") self.assertEqual(move.state, "assigned") self.assertEqual(move.product_id, self.product_c) self.assertEqual(move.product_uom_qty, 4) @@ -347,24 +365,27 @@ def test_set_destination_line_partial_qty(self): self.assertEqual(move_line_c.move_id.state, "done") # Scan remaining qty (4/10) remaining_move_line_c = move.move_line_ids - with mock.patch.object(type(self.picking2), "action_done") as action_done: - response = self.service.dispatch( - "set_destination_line", - params={ - "location_id": self.content_loc.id, - "move_line_id": remaining_move_line_c.id, - "quantity": remaining_move_line_c.product_uom_qty, - "barcode": self.dest_location.barcode, - }, - ) - action_done.assert_not_called() + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": remaining_move_line_c.id, + "quantity": remaining_move_line_c.product_uom_qty, + "barcode": self.dest_location.barcode, + }, + ) + done_picking2 = remaining_move_line_c.picking_id # Check move line data self.assertEqual(remaining_move_line_c.move_id.product_uom_qty, 4) self.assertEqual(remaining_move_line_c.product_uom_qty, 0) self.assertEqual(remaining_move_line_c.qty_done, 4) self.assertEqual(remaining_move_line_c.state, "done") - # All move lines related to product_c are now done - moves_product_c = self.picking2.move_lines.filtered( + self.assertTrue(done_picking2 != original_picking) + self.assertEqual(done_picking2.state, "done") + # All move lines related to product_c are now done and extracted from + # the initial transfer + all_pickings = original_picking | done_picking | done_picking2 + moves_product_c = all_pickings.move_lines.filtered( lambda m: m.product_id == self.product_c ) moves_product_c_done = all(move.state == "done" for move in moves_product_c) @@ -372,27 +393,25 @@ def test_set_destination_line_partial_qty(self): moves_product_c_qty_done = sum([move.quantity_done for move in moves_product_c]) self.assertEqual(moves_product_c_qty_done, 10) # The picking is still not done as product_d hasn't been processed - self.assertEqual(self.picking2.state, "assigned") + self.assertEqual(original_picking.state, "assigned") # Let scan product_d quantity and check picking state - move_line_d = self.picking2.move_line_ids.filtered( + move_line_d = original_picking.move_line_ids.filtered( lambda m: m.product_id == self.product_d ) - with mock.patch.object(type(self.picking2), "action_done") as action_done: - response = self.service.dispatch( - "set_destination_line", - params={ - "location_id": self.content_loc.id, - "move_line_id": move_line_d.id, - "quantity": move_line_d.product_uom_qty, - "barcode": self.dest_location.barcode, - }, - ) - self.assertEqual(move_line_d.move_id.product_uom_qty, 10) - self.assertEqual(move_line_d.product_uom_qty, 0) - self.assertEqual(move_line_d.qty_done, 10) - self.assertEqual(move_line_d.state, "done") - self.assertEqual(self.picking2.state, "done") - action_done.assert_called_once() + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line_d.id, + "quantity": move_line_d.product_uom_qty, + "barcode": self.dest_location.barcode, + }, + ) + self.assertEqual(move_line_d.move_id.product_uom_qty, 10) + self.assertEqual(move_line_d.product_uom_qty, 0) + self.assertEqual(move_line_d.qty_done, 10) + self.assertEqual(move_line_d.state, "done") + self.assertEqual(original_picking.state, "done") class LocationContentTransferSetDestinationXSpecialCase( @@ -461,9 +480,10 @@ def test_set_destination_package_split_move(self): """Scanned destination location valid for a package, but related moves has to be splitted because it is linked to additional move lines. """ - self.assertEqual(len(self.picking.move_lines), 2) + original_picking = self.picking + self.assertEqual(len(original_picking.move_lines), 2) self.assertEqual(len(self.move_product_a.move_line_ids), 2) - package_level = self.picking.package_level_ids[0] + package_level = original_picking.package_level_ids[0] response = self.service.dispatch( "set_destination_package", params={ @@ -472,22 +492,24 @@ def test_set_destination_package_split_move(self): "barcode": self.dest_location.barcode, }, ) + done_picking = package_level.picking_id # Check the picking data + self.assertEqual(original_picking.backorder_ids, done_picking) self.assertEqual(package_level.location_dest_id, self.dest_location) for move_line in package_level.move_line_ids: self.assertEqual(move_line.location_dest_id, self.dest_location) - moves_product_a = self.picking.move_lines.filtered( + moves_product_a = original_picking.move_lines.filtered( lambda m: m.product_id == self.product_a ) - self.assertEqual(len(self.picking.move_lines), 3) - self.assertEqual(len(moves_product_a), 2) + self.assertEqual(len(original_picking.move_lines), 2) + self.assertEqual(len(moves_product_a), 1) for move in moves_product_a: self.assertEqual(len(move.move_line_ids), 1) - move_lines_wo_pkg = self.picking.move_line_ids_without_package + move_lines_wo_pkg = original_picking.move_line_ids_without_package move_lines_wo_pkg_states = set(move_lines_wo_pkg.mapped("state")) self.assertEqual(len(move_lines_wo_pkg_states), 1) self.assertEqual(move_lines_wo_pkg_states.pop(), "assigned") - self.assertEqual(self.picking.package_level_ids.state, "done") + self.assertEqual(done_picking.package_level_ids.state, "done") # Check the response move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( @@ -502,7 +524,8 @@ def test_set_destination_line_split_move(self): """Scanned destination location valid for a move line, but related moves has to be splitted because it is linked to additional move lines. """ - self.assertEqual(len(self.picking.move_lines), 2) + original_picking = self.picking + self.assertEqual(len(original_picking.move_lines), 2) self.assertEqual(len(self.move_product_b.move_line_ids), 2) move_line = self.move_product_b.move_line_ids.filtered( lambda ml: ml.product_uom_qty == 6 @@ -516,26 +539,28 @@ def test_set_destination_line_split_move(self): "barcode": self.dest_location.barcode, }, ) + done_picking = move_line.picking_id # Check the picking data - self.assertEqual(self.picking.state, "assigned") + self.assertEqual(original_picking.backorder_ids, done_picking) + self.assertEqual(done_picking.state, "done") + self.assertEqual(original_picking.state, "assigned") self.assertEqual(move_line.move_id.product_uom_qty, 6) self.assertEqual(move_line.product_uom_qty, 0) self.assertEqual(move_line.qty_done, 6) self.assertEqual(move_line.location_dest_id, self.dest_location) - moves_product_b = self.picking.move_lines.filtered( + self.assertEqual(len(original_picking.move_lines), 2) + moves_product_b = original_picking.move_lines.filtered( lambda m: m.product_id == self.product_b ) - self.assertEqual(len(self.picking.move_lines), 3) - self.assertEqual(len(moves_product_b), 2) + self.assertEqual(len(moves_product_b), 1) for move in moves_product_b: self.assertEqual(len(move.move_line_ids), 1) - move_lines_wo_pkg = self.picking.move_line_ids_without_package + move_lines_wo_pkg = original_picking.move_line_ids_without_package move_lines_wo_pkg_states = set(move_lines_wo_pkg.mapped("state")) - self.assertEqual(len(move_lines_wo_pkg_states), 2) - self.assertIn("assigned", move_lines_wo_pkg_states) - self.assertIn("done", move_lines_wo_pkg_states) + self.assertEqual(len(move_lines_wo_pkg_states), 1) + self.assertTrue(all(state == "assigned" for state in move_lines_wo_pkg_states)) self.assertEqual(move_line.state, "done") - remaining_move = self.picking.move_lines.filtered( + remaining_move = original_picking.move_lines.filtered( lambda m: move_line.move_id != m and m.product_id == self.product_b ) self.assertEqual(remaining_move.state, "assigned") @@ -553,34 +578,30 @@ def test_set_destination_line_split_move(self): ) # Process the other move lines (lines w/o package + package levels) # to check the picking state - remaining_move_lines = self.picking.move_line_ids_without_package.filtered( + remaining_move_lines = original_picking.move_line_ids_without_package.filtered( lambda ml: ml.state == "assigned" ) - with mock.patch.object(type(self.picking), "action_done") as action_done: - for ml in remaining_move_lines: - self.service.dispatch( - "set_destination_line", - params={ - "location_id": self.content_loc.id, - "move_line_id": ml.id, - "quantity": ml.product_uom_qty, - "barcode": self.dest_location.barcode, - }, - ) - self.assertEqual(self.picking.state, "assigned") - action_done.assert_not_called() - package_level = self.picking.package_level_ids[0] - with mock.patch.object(type(self.picking), "action_done") as action_done: + for ml in remaining_move_lines: self.service.dispatch( - "set_destination_package", + "set_destination_line", params={ "location_id": self.content_loc.id, - "package_level_id": package_level.id, + "move_line_id": ml.id, + "quantity": ml.product_uom_qty, "barcode": self.dest_location.barcode, }, ) - self.assertEqual(self.picking.state, "done") - action_done.assert_called_once() + self.assertEqual(original_picking.state, "assigned") + package_level = original_picking.package_level_ids[0] + self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.dest_location.barcode, + }, + ) + self.assertEqual(original_picking.state, "done") class LocationContentTransferSetDestinationChainSpecialCase( @@ -654,7 +675,9 @@ def test_set_destination_line_partial_qty_with_move_orig_ids(self): "barcode": self.dest_location.barcode, }, ) + done_picking = move_line_c.picking_id # Check move line data + self.assertEqual(picking_b.backorder_ids, done_picking) self.assertEqual(move_line_c.move_id.product_uom_qty, 6) self.assertEqual(move_line_c.product_uom_qty, 0) self.assertEqual(move_line_c.qty_done, 6) From f220bfa804cd63dc2238c11f7a10019e0f9991e0 Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 17 Sep 2020 17:21:45 +0200 Subject: [PATCH 371/940] backend: remove code related to _action_done usage Calling _action_done() directly on the stock.move while leaving the stock.picking "assigned" with the other moves already required various workarounds as it is normally not an expected flow in Odoo. We have detected new issues with package levels which alter "done" move lines when we call _action_assign() on the other moves again. Previous commits removed the calls to _action_done() on the moves and replaced them by a method that extract the move to set to done in a new stock.picking (StockMove.extract_and_action_done()). This commit removes the hacks which are no longer used. --- shopfloor/models/stock_move.py | 11 ----------- shopfloor/models/stock_picking.py | 10 ---------- shopfloor/tests/test_single_pack_transfer.py | 2 +- shopfloor/tests/test_stock_split.py | 2 +- 4 files changed, 2 insertions(+), 23 deletions(-) diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py index da24e18d02..f1690dd182 100644 --- a/shopfloor/models/stock_move.py +++ b/shopfloor/models/stock_move.py @@ -26,17 +26,6 @@ def split_other_move_lines(self, move_lines, intersection=False): return backorder_move return False - def _action_done(self, cancel_backorder=False): - # Overloaded to ensure that the 'action_done' method of the picking - # is called when the last move of a picking is validated. - moves = super()._action_done(cancel_backorder) - if not self.env.context.get("_action_done_from_picking"): - pickings = moves.picking_id - for picking in pickings: - if picking.state == "done": - picking.action_done() - return moves - def extract_and_action_done(self): """Extract the moves in a separate transfer and validate them. diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py index 7f1714e374..5f88110457 100644 --- a/shopfloor/models/stock_picking.py +++ b/shopfloor/models/stock_picking.py @@ -36,13 +36,3 @@ def _calc_weight(self): for move_line in self.mapped("move_line_ids"): weight += move_line.product_qty * move_line.product_id.weight return weight - - def _create_backorder(self): - if self.env.context.get("_sf_no_backorder"): - return self.browse() - else: - return super()._create_backorder() - - def action_done(self): - self = self.with_context(_action_done_from_picking=True) - return super().action_done() diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index 298ab080c2..041aa51b56 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -835,7 +835,7 @@ def test_cancel_already_done(self): picking = move.picking_id # someone cancel the work started by our operator - move._action_done() + move.extract_and_action_done() # now, call the service to cancel response = self.service.dispatch( diff --git a/shopfloor/tests/test_stock_split.py b/shopfloor/tests/test_stock_split.py index b39e22878f..a0554d6ea1 100644 --- a/shopfloor/tests/test_stock_split.py +++ b/shopfloor/tests/test_stock_split.py @@ -145,7 +145,7 @@ def test_split_pickings_from_source_location(self): move_line.qty_done = move_line.product_uom_qty if i % 2: move_line.location_dest_id = dest_location - self.pick_move_a.with_context(_sf_no_backorder=True)._action_done() + self.pick_move_a.extract_and_action_done() self.assertEqual(self.pick_move_a.state, "done") # Pack step, we want to split move lines from common source location self.assertEqual(self.pack_move_a.state, "assigned") From 64e4168a2c8bdf290eec0159b265e717ba712c61 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 21 Sep 2020 15:36:46 +0200 Subject: [PATCH 372/940] location content transfer: add completion info Each time a good has been moved (on `set_destination_all`, `set_destination_package` or `set_destination_line`), completion info is returned. --- .../services/location_content_transfer.py | 26 +++-- .../test_location_content_transfer_base.py | 9 +- ...on_content_transfer_set_destination_all.py | 42 ++++++++ ...ransfer_set_destination_package_or_line.py | 97 +++++++++++++++++++ 4 files changed, 164 insertions(+), 10 deletions(-) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 4d28698ff6..f9ebfc3d1d 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -47,9 +47,9 @@ class LocationContentTransfer(Component): _usage = "location_content_transfer" _description = __doc__ - def _response_for_start(self, message=None): + def _response_for_start(self, message=None, popup=None): """Transition to the 'start' state""" - return self._response(next_state="start", message=message) + return self._response(next_state="start", message=message, popup=popup) def _response_for_scan_destination_all( self, pickings, message=None, confirmation_required=False @@ -70,7 +70,7 @@ def _response_for_scan_destination_all( next_state="scan_destination_all", data=data, message=message ) - def _response_for_start_single(self, pickings, message=None): + def _response_for_start_single(self, pickings, message=None, popup=None): """Transition to the 'start_single' state The client screen shows details of the package level or move line to move. @@ -79,11 +79,12 @@ def _response_for_start_single(self, pickings, message=None): next_content = self._next_content(pickings) if not next_content: # TODO test (no more lines) - return self._response_for_start(message=message) + return self._response_for_start(message=message, popup=popup) return self._response( next_state="start_single", data=self._data_content_line_for_location(location, next_content), message=message, + popup=popup, ) def _response_for_scan_destination( @@ -384,10 +385,13 @@ def set_destination_all(self, location_id, barcode, confirmation=False): self._set_all_destination_lines_and_done(pickings, move_lines, scanned_location) + completion_info = self.actions_for("completion.info") + completion_info_popup = completion_info.popup(move_lines) return self._response_for_start( message=self.msg_store.location_content_transfer_complete( location, scanned_location - ) + ), + popup=completion_info_popup, ) def go_to_single(self, location_id): @@ -574,8 +578,12 @@ def set_destination_package( message = self.msg_store.location_content_transfer_item_complete( scanned_location ) + completion_info = self.actions_for("completion.info") + completion_info_popup = completion_info.popup(package_moves.move_line_ids) return self._response_for_start_single( - move_lines.mapped("picking_id"), message=message + move_lines.mapped("picking_id"), + message=message, + popup=completion_info_popup, ) def set_destination_line( @@ -644,8 +652,12 @@ def set_destination_line( message = self.msg_store.location_content_transfer_item_complete( scanned_location ) + completion_info = self.actions_for("completion.info") + completion_info_popup = completion_info.popup(move_line) return self._response_for_start_single( - move_lines.mapped("picking_id"), message=message + move_lines.mapped("picking_id"), + message=message, + popup=completion_info_popup, ) def postpone_package(self, location_id, package_level_id): diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py index 6c89326b34..40e24385c5 100644 --- a/shopfloor/tests/test_location_content_transfer_base.py +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -46,8 +46,8 @@ def _simulate_pickings_selected(cls, pickings): for line in pickings.mapped("move_line_ids"): line.qty_done = line.product_uom_qty - def assert_response_start(self, response, message=None): - self.assert_response(response, next_state="start", message=message) + def assert_response_start(self, response, message=None, popup=None): + self.assert_response(response, next_state="start", message=message, popup=popup) def _assert_response_scan_destination_all( self, state, response, pickings, message=None, confirmation_required=False @@ -81,7 +81,9 @@ def assert_response_scan_destination_all( confirmation_required=confirmation_required, ) - def assert_response_start_single(self, response, pickings, message=None): + def assert_response_start_single( + self, response, pickings, message=None, popup=None + ): sorter = self.service.actions_for("location_content_transfer.sorter") sorter.feed_pickings(pickings) location = pickings.mapped("location_id") @@ -90,6 +92,7 @@ def assert_response_start_single(self, response, pickings, message=None): next_state="start_single", data=self.service._data_content_line_for_location(location, next(sorter)), message=message, + popup=popup, ) def _assert_response_scan_destination( diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_all.py b/shopfloor/tests/test_location_content_transfer_set_destination_all.py index fc33a4d742..93cd3d80bc 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_all.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py @@ -78,6 +78,48 @@ def test_set_destination_all_dest_location_ok(self): ) self.assert_all_done(sub_shelf1) + def test_set_destination_all_dest_location_ok_with_completion_info(self): + """Scanned destination location valid, moves set to done accepted + and completion info is returned as the next transfer is ready. + """ + move = self.picking1.move_lines[0] + next_move = move.copy( + { + "location_id": move.location_dest_id.id, + "location_dest_id": self.customer_location.id, + "move_orig_ids": [(6, 0, move.ids)], + } + ) + next_move._action_confirm(merge=False) + next_move._assign_picking() + self.assertEqual(next_move.state, "waiting") + sub_shelf1 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Sub Shelf 1", + "barcode": "subshelf1", + "location_id": self.shelf1.id, + } + ) + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + response = self.service.dispatch( + "set_destination_all", + params={"location_id": self.content_loc.id, "barcode": sub_shelf1.barcode}, + ) + self.assertEqual(next_move.state, "assigned") + completion_info = self.service.actions_for("completion.info") + completion_info_popup = completion_info.popup(move_lines) + self.assert_response_start( + response, + message=self.service.msg_store.location_content_transfer_complete( + self.content_loc, sub_shelf1 + ), + popup=completion_info_popup, + ) + def test_set_destination_all_dest_location_not_found(self): """Barcode scanned for destination location is not found""" response = self.service.dispatch( diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py index 2e5b9c27aa..6212307b49 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py @@ -180,6 +180,53 @@ def test_set_destination_package_dest_location_ok(self): for move in package_level.move_line_ids.mapped("move_id"): self.assertEqual(move.state, "done") + def test_set_destination_package_dest_location_ok_with_completion_info(self): + """Scanned destination location valid, moves set to done + and completion info is returned as the next transfer is ready. + """ + original_picking = self.picking1 + package_level = original_picking.package_level_ids[0] + move = package_level.move_line_ids.move_id[0] + next_move = move.copy( + { + "picking_id": False, + "location_id": move.location_dest_id.id, + "location_dest_id": self.customer_location.id, + "move_orig_ids": [(6, 0, move.ids)], + } + ) + next_move._action_confirm(merge=False) + next_move._assign_picking() + self.assertEqual(next_move.state, "waiting") + self.assertTrue(next_move.picking_id) + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.dest_location.barcode, + }, + ) + # Check the data (the whole transfer has been validated here w/o backorder) + self.assertFalse(original_picking.backorder_ids) + self.assertEqual(original_picking.state, "done") + self.assertEqual(package_level.state, "done") + self.assertEqual(next_move.state, "assigned") + # Check the response + move_lines = self.service._find_transfer_move_lines(self.content_loc) + completion_info = self.service.actions_for("completion.info") + completion_info_popup = completion_info.popup(package_level.move_line_ids) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), + popup=completion_info_popup, + ) + for move in package_level.move_line_ids.mapped("move_id"): + self.assertEqual(move.state, "done") + def test_set_destination_line_wrong_parameters(self): """Wrong 'location' and 'move_line_id' parameters, redirect the user to the 'start' screen. @@ -317,6 +364,56 @@ def test_set_destination_line_dest_location_ok(self): ), ) + def test_set_destination_line_dest_location_ok_with_completion_info(self): + """Scanned destination location valid, moves set to done + and completion info is returned as the next transfer is ready. + """ + original_picking = self.picking2 + move_line = original_picking.move_line_ids[0] + move = move_line.move_id + next_move = move.copy( + { + "picking_id": False, + "location_id": move.location_dest_id.id, + "location_dest_id": self.customer_location.id, + "move_orig_ids": [(6, 0, move.ids)], + } + ) + next_move._action_confirm(merge=False) + next_move._assign_picking() + self.assertEqual(next_move.state, "waiting") + self.assertTrue(next_move.picking_id) + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.product_uom_qty, + "barcode": self.dest_location.barcode, + }, + ) + # Check the resulting data + # We got a new picking as the original one had two moves (and we + # validated only one) + new_picking = move_line.picking_id + self.assertTrue(new_picking != original_picking) + self.assertEqual(move_line.move_id.state, "done") + self.assertEqual(move_line.picking_id.state, "done") + self.assertEqual(original_picking.state, "assigned") + self.assertEqual(next_move.state, "assigned") + # Check the response + move_lines = self.service._find_transfer_move_lines(self.content_loc) + completion_info = self.service.actions_for("completion.info") + completion_info_popup = completion_info.popup(move_line) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), + popup=completion_info_popup, + ) + def test_set_destination_line_partial_qty(self): """Scanned destination location with partial qty, but related moves has to be splitted. From 4122e1155ff3b40317cd615b9f6c70aa3cbaf67b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 22 Sep 2020 08:42:41 +0200 Subject: [PATCH 373/940] backend: change "postponed" as "priority" (#80) Postponed becomes a priority value of 9999. Default priority is 10. We will use a lower value for priority, for instance, to force some move lines to be processed "right now" after we split a package level. --- shopfloor/__manifest__.py | 2 +- .../location_content_transfer_sorter.py | 2 +- .../migrations/13.0.1.1.0/pre-migration.py | 77 +++++++++++++++++++ shopfloor/models/__init__.py | 1 + shopfloor/models/priority_postpone_mixin.py | 38 +++++++++ shopfloor/models/stock_move_line.py | 9 +-- shopfloor/models/stock_package_level.py | 12 +-- shopfloor/services/cluster_picking.py | 2 +- shopfloor/views/stock_move_line.xml | 4 +- 9 files changed, 126 insertions(+), 21 deletions(-) create mode 100644 shopfloor/migrations/13.0.1.1.0/pre-migration.py create mode 100644 shopfloor/models/priority_postpone_mixin.py diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 4412ed12e6..b1d12b54fb 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "13.0.1.0.0", + "version": "13.0.1.1.0", "development_status": "Alpha", "category": "Inventory", "website": "https://odoo-community.org", diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py index 1878e45fe8..94d449aa68 100644 --- a/shopfloor/actions/location_content_transfer_sorter.py +++ b/shopfloor/actions/location_content_transfer_sorter.py @@ -33,7 +33,7 @@ def _sort_key(content): # level return ( # postponed content after other contents - int(content.shopfloor_postponed), + content.shopfloor_priority or 10, # sort by shopfloor picking sequence content.location_dest_id.shopfloor_picking_sequence or "", # sort by similar destination diff --git a/shopfloor/migrations/13.0.1.1.0/pre-migration.py b/shopfloor/migrations/13.0.1.1.0/pre-migration.py new file mode 100644 index 0000000000..8255f2c535 --- /dev/null +++ b/shopfloor/migrations/13.0.1.1.0/pre-migration.py @@ -0,0 +1,77 @@ +from psycopg2 import sql + +from odoo.tools import column_exists + + +def migrate(cr, version): + renames = ( + ("stock.move.line", "shopfloor_postponed", "shopfloor_priority"), + ("stock.package.level", "shopfloor_postponed", "shopfloor_priority"), + ) + for model, old_field, new_field in renames: + table = model.replace(".", "_") + if not column_exists(cr, table, old_field): + continue + # pylint: disable=sql-injection + cr.execute( + sql.SQL( + """ + ALTER TABLE {} + ALTER {} + TYPE INTEGER + USING CASE COALESCE({}, false) + WHEN false THEN 10 + ELSE 9999 + END; + """ + ).format( + sql.Identifier(table), + sql.Identifier(old_field), + sql.Identifier(old_field), + ) + ) + # pylint: disable=sql-injection + cr.execute( + sql.SQL( + """ + ALTER TABLE {} + RENAME {} + TO {}; + """ + ).format( + sql.Identifier(table), + sql.Identifier(old_field), + sql.Identifier(new_field), + ) + ) + cr.execute( + """ + UPDATE ir_model_fields + SET name = %s + WHERE name = %s + AND model = %s + """, + (new_field, old_field, model), + ) + cr.execute( + """ + UPDATE ir_model_data + SET name = %s + WHERE name = %s + AND model = %s + """, + ( + "field_{}__{}".format(table, new_field), + "field_{}__{}".format(table, old_field), + model, + ), + ) + cr.execute( + """ + UPDATE ir_translation + SET name = %s + WHERE name = %s + AND type = 'model' + """, + ("{},{}".format(model, new_field), "{},{}".format(model, old_field)), + ) diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index cb9e4bf257..30930739fd 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -1,3 +1,4 @@ +from . import priority_postpone_mixin from . import res_partner from . import shopfloor_menu from . import shopfloor_log diff --git a/shopfloor/models/priority_postpone_mixin.py b/shopfloor/models/priority_postpone_mixin.py new file mode 100644 index 0000000000..41fa877961 --- /dev/null +++ b/shopfloor/models/priority_postpone_mixin.py @@ -0,0 +1,38 @@ +from odoo import api, fields, models + + +class PriorityPostponeMixin(models.AbstractModel): + _name = "shopfloor.priority.postpone.mixin" + _description = "Adds shopfloor priority/postpone fields" + + # shopfloor_priority is set to this value when postponed + # consider it as the max value for priority + _SF_PRIORITY_POSTPONED = 9999 + _SF_PRIORITY_DEFAULT = 10 + + shopfloor_priority = fields.Integer( + default=lambda self: self._SF_PRIORITY_DEFAULT, + copy=False, + help="Technical field. Overrides operation priority in barcode scenario.", + ) + + shopfloor_postponed = fields.Boolean( + compute="_compute_shopfloor_postponed", + inverse="_inverse_shopfloor_postponed", + help="Technical field. " + "Indicates if the operation has been postponed in a barcode scenario.", + ) + + @api.depends("shopfloor_priority") + def _compute_shopfloor_postponed(self): + for record in self: + record.shopfloor_postponed = bool( + record.shopfloor_priority == self._SF_PRIORITY_POSTPONED + ) + + def _inverse_shopfloor_postponed(self): + for record in self: + if record.shopfloor_postponed: + record.shopfloor_priority = self._SF_PRIORITY_POSTPONED + else: + record.shopfloor_priority = self._SF_PRIORITY_DEFAULT diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py index fbc051f8ee..8028b5cff0 100644 --- a/shopfloor/models/stock_move_line.py +++ b/shopfloor/models/stock_move_line.py @@ -4,16 +4,11 @@ class StockMoveLine(models.Model): - _inherit = "stock.move.line" + _name = "stock.move.line" + _inherit = ["stock.move.line", "shopfloor.priority.postpone.mixin"] # TODO use a serialized field shopfloor_unloaded = fields.Boolean(default=False) - shopfloor_postponed = fields.Boolean( - default=False, - copy=False, - help="Technical field. " - "Indicates if a the move has been postponed in a barcode scenario.", - ) shopfloor_checkout_done = fields.Boolean(default=False) shopfloor_user_id = fields.Many2one(comodel_name="res.users", index=True) diff --git a/shopfloor/models/stock_package_level.py b/shopfloor/models/stock_package_level.py index 1aa691c6e9..24b1b8a531 100644 --- a/shopfloor/models/stock_package_level.py +++ b/shopfloor/models/stock_package_level.py @@ -1,15 +1,9 @@ -from odoo import fields, models +from odoo import models class StockPackageLevel(models.Model): - _inherit = "stock.package_level" - - shopfloor_postponed = fields.Boolean( - default=False, - copy=False, - help="Technical field. " - "Indicates if a the package level has been postponed in a barcode scenario.", - ) + _name = "stock.package_level" + _inherit = ["stock.package_level", "shopfloor.priority.postpone.mixin"] def replace_package(self, new_package): """Replace a package on an assigned package level and related records diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 3ba0e10066..0041c5af23 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -318,7 +318,7 @@ def _pick_next_line(self, batch, message=None, force_line=None): @staticmethod def _sort_key_lines(line): return ( - line.shopfloor_postponed, + line.shopfloor_priority or 10, line.location_id.shopfloor_picking_sequence or "", line.location_id.name, -int(line.move_id.priority or 1), diff --git a/shopfloor/views/stock_move_line.xml b/shopfloor/views/stock_move_line.xml index 67ee61d002..348a0fdb93 100644 --- a/shopfloor/views/stock_move_line.xml +++ b/shopfloor/views/stock_move_line.xml @@ -24,8 +24,8 @@ groups="base.group_no_one" /> From c93509bf05bc2c0f16f37bf0c46d992090d68f15 Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 17 Sep 2020 19:34:27 +0200 Subject: [PATCH 374/940] location transfer: new endpoint to remove a package level This allows the user to explode the content of a package and move its goods to different locations. Afterwards this means the user will process directly move lines instead of a package. --- .../location_content_transfer_sorter.py | 31 ++++++++++++- shopfloor/actions/message.py | 6 +++ .../services/location_content_transfer.py | 44 ++++++++++++++++++ .../test_location_content_transfer_single.py | 46 +++++++++++++++++++ 4 files changed, 125 insertions(+), 2 deletions(-) diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py index 94d449aa68..49b45430dc 100644 --- a/shopfloor/actions/location_content_transfer_sorter.py +++ b/shopfloor/actions/location_content_transfer_sorter.py @@ -16,15 +16,42 @@ def feed_pickings(self, pickings): self._pickings |= pickings def move_lines(self): - return self._pickings.move_line_ids.filtered( - # lines without package level only (raw products) + """Returns valid move lines. + + Valid move lines are: + - those not bound to a package level + - those bound to invalid package levels + + An invalid package level has one of its line not targetting the + expected package. + """ + # lines without package level only (raw products) + move_lines = self._pickings.move_line_ids.filtered( lambda line: not line.package_level_id and line.state not in ("cancel", "done") ) + # lines with invalid package levels + invalid_levels = self._pickings.package_level_ids.filtered( + lambda level: level.state not in ("cancel", "done") + and any( + line.result_package_id != level.package_id + for line in level.move_line_ids + ) + ) + return move_lines | invalid_levels.move_line_ids def package_levels(self): + """Returns valid package levels. + + A valid package level has all its related move lines targetting + the expected package. + """ return self._pickings.package_level_ids.filtered( lambda level: level.state not in ("cancel", "done") + and all( + line.result_package_id == level.package_id + for line in level.move_line_ids + ) ) @staticmethod diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 51dedf614c..00724daf90 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -459,3 +459,9 @@ def new_move_lines_not_assigned(self): "message_type": "error", "body": _("New move lines cannot be assigned: canceled."), } + + def package_open(self): + return { + "message_type": "info", + "body": _("Package has been opened. You can move partial quantities."), + } diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index f9ebfc3d1d..3e3e34cd9c 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -788,6 +788,41 @@ def stock_out_line(self, location_id, move_line_id): move_lines = self._find_transfer_move_lines(location) return self._response_for_start_single(move_lines.mapped("picking_id")) + def dismiss_package_level(self, location_id, package_level_id): + """Dismiss the package level. + + The result package of the related move lines is unset, then the package + level itself is removed from the picking. This allows to move parts + of the package to different locations. + + The user is then redirected to process the next line of the related picking. + + Transitions: + * start_single: continue with the next line + """ + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + package_level = self.env["stock.package_level"].browse(package_level_id) + if not package_level.exists(): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message=self.msg_store.record_not_found(), + ) + move_lines = package_level.move_line_ids + move_lines.write( + { + "result_package_id": False, + # ensure all the lines in the package are the next ones to be processed + "shopfloor_priority": 1, + } + ) + package_level.unlink() + return self._response_for_start_single( + move_lines.mapped("picking_id"), message=self.msg_store.package_open() + ) + class ShopfloorLocationContentTransferValidator(Component): """Validators for the Location Content Transfer endpoints""" @@ -867,6 +902,12 @@ def stock_out_line(self): "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, } + def dismiss_package_level(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + class ShopfloorLocationContentTransferValidatorResponse(Component): """Validators for the Location Content Transfer endpoints responses""" @@ -963,3 +1004,6 @@ def stock_out_package(self): def stock_out_line(self): return self._response_schema(next_states={"start", "start_single"}) + + def dismiss_package_level(self): + return self._response_schema(next_states={"start", "start_single"}) diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py index 599b6aabd7..cdbf6efc49 100644 --- a/shopfloor/tests/test_location_content_transfer_single.py +++ b/shopfloor/tests/test_location_content_transfer_single.py @@ -456,6 +456,52 @@ def test_stock_out_line_wrong_parameters(self): response, move_lines.mapped("picking_id"), ) + def test_dismiss_package_level_ok(self): + """Open a package level""" + package_level = self.picking1.move_line_ids.package_level_id + move_lines = package_level.move_line_ids + response = self.service.dispatch( + "dismiss_package_level", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + }, + ) + self.assertFalse(package_level.exists()) + self.assertFalse(move_lines.result_package_id) + self.assertFalse(move_lines.package_level_id) + self.assertEqual(move_lines.mapped("shopfloor_priority"), [1, 1]) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.package_open(), + ) + + def test_dismiss_package_level_error_no_package_level(self): + """Open a package level, send unknown package level id""" + response = self.service.dispatch( + "dismiss_package_level", + params={"location_id": self.content_loc.id, "package_level_id": 0}, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.record_not_found(), + ) + + def test_dismiss_package_level_error_no_location(self): + """Open a package level, send unknown location id""" + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "dismiss_package_level", + params={"location_id": 0, "package_level_id": package_level.id}, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + class LocationContentTransferSingleSpecialCase(LocationContentTransferCommonCase): """Tests for endpoint used from state start_single (special cases) From 739e73673fc62ad04c960bc0a5240750aa0496e5 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 24 Sep 2020 15:13:50 +0200 Subject: [PATCH 375/940] Fix error when product has no default_code The default code is not required on products. When declared as non-nullable in the return schema, it makes the whole request fail with a 500 error. --- shopfloor/services/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index d1d424c3a8..4d8742930b 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -109,7 +109,7 @@ def product(self): "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, "display_name": {"type": "string", "nullable": False, "required": True}, - "default_code": {"type": "string", "nullable": False, "required": True}, + "default_code": {"type": "string", "nullable": True, "required": True}, "barcode": {"type": "string", "nullable": True, "required": False}, "supplier_code": {"type": "string", "nullable": True, "required": False}, "packaging": self._schema_list_of(self.packaging()), From 7ea5feae48b37279f2137fd358d58127a520f328 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 25 Sep 2020 07:56:00 +0200 Subject: [PATCH 376/940] backend: Rename method for better understanding "check" doesn't really imply a side-effect and is confusing. "split" describes better what it does (even if it doesn't always split, the end goal is the same). --- shopfloor/models/stock_move_line.py | 2 +- shopfloor/services/checkout.py | 2 +- shopfloor/services/cluster_picking.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py index 8028b5cff0..f089481203 100644 --- a/shopfloor/models/stock_move_line.py +++ b/shopfloor/models/stock_move_line.py @@ -104,7 +104,7 @@ def _split_pickings_from_source_location(self): pickings = new_picking return pickings - def _check_qty_to_be_done(self, qty_done, split_partial=True, **split_default_vals): + def _split_qty_to_be_done(self, qty_done, split_partial=True, **split_default_vals): """Check qty to be done for current move line. Split it if needed. :param qty_done: qty expected to be done diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index eb8f129649..a4753fb0de 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -504,7 +504,7 @@ def _change_line_qty( else: new_line = self.env["stock.move.line"] if qty_done > 0: - new_line, qty_check = move_line._check_qty_to_be_done( + new_line, qty_check = move_line._split_qty_to_be_done( qty_done, split_partial=True, result_package_id=False, ) if qty_check == "greater": diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 0041c5af23..660d0dc782 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -566,7 +566,7 @@ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantit batch, message=self.msg_store.operation_not_found() ) - new_line, qty_check = move_line._check_qty_to_be_done(quantity) + new_line, qty_check = move_line._split_qty_to_be_done(quantity) if qty_check == "greater": return self._response_for_scan_destination( move_line, From 971ca746f319a1f6f11f037de0bad0ca370a49d5 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 25 Sep 2020 08:43:26 +0200 Subject: [PATCH 377/940] Add missing imports of tests --- shopfloor/tests/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index ca80513434..d2b61ffdf3 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -18,6 +18,7 @@ from . import test_checkout_select from . import test_checkout_scan_line from . import test_checkout_select_line +from . import test_checkout_select_package_base from . import test_checkout_set_qty from . import test_checkout_scan_package_action from . import test_checkout_new_package @@ -28,7 +29,10 @@ from . import test_checkout_cancel_line from . import test_checkout_done from . import test_delivery_base +from . import test_delivery_done from . import test_delivery_scan_deliver +from . import test_delivery_reset_qty_done_line +from . import test_delivery_reset_qty_done_pack from . import test_delivery_set_qty_done_pack from . import test_delivery_set_qty_done_line from . import test_delivery_list_stock_picking @@ -51,4 +55,5 @@ from . import test_zone_picking_unload_all from . import test_zone_picking_unload_set_destination from . import test_misc +from . import test_scan_anything from . import test_stock_split From 502a3d825a8fb58a8f56cc311217259bca8d2537 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 25 Sep 2020 10:36:55 +0200 Subject: [PATCH 378/940] backend: fix reset of result_package_id on move._action_assign() This is actually an issue in the core stock module, which is fixed locally as it is much more likely to happen in shopfloor than any other places (and the consequences if it happens in shopfloor have no workaround, while you can still edit the move lines when it happens on the UI). Reported as https://github.com/odoo/odoo/issues/58491 The details about the issue are there. In short: calling _action_assign on a stock.move create/write package levels which may in turn replace the "result_package_id" of a move line, even if it was already set by a user. To prevent this, we skip the package level sync when: * any line related to the package has a result_package_id set * any line related to the package has a qty_done set Depending on the response of odoo to this issue, we may later remove this commit partially or completely. --- shopfloor/models/stock_picking.py | 19 +++++ shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_move_action_assign.py | 82 ++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 shopfloor/tests/test_move_action_assign.py diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py index 5f88110457..f42bed1277 100644 --- a/shopfloor/models/stock_picking.py +++ b/shopfloor/models/stock_picking.py @@ -36,3 +36,22 @@ def _calc_weight(self): for move_line in self.mapped("move_line_ids"): weight += move_line.product_qty * move_line.product_id.weight return weight + + def _check_move_lines_map_quant_package(self, package): + # see tests/test_move_action_assign.py for details + pack_move_lines = self.move_line_ids.filtered( + lambda ml: ml.package_id == package + ) + # if we set a qty_done on any line, it's picked, we don't want + # to change it in any case, so we ignore the package level + if any(pack_move_lines.mapped("qty_done")): + return False + # if we already changed the destination package, do not create + # a new package level + if any( + line.result_package_id != package + for line in pack_move_lines + if line.result_package_id + ): + return False + return super()._check_move_lines_map_quant_package(package) diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index d2b61ffdf3..571ed4a34f 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -55,5 +55,6 @@ from . import test_zone_picking_unload_all from . import test_zone_picking_unload_set_destination from . import test_misc +from . import test_move_action_assign from . import test_scan_anything from . import test_stock_split diff --git a/shopfloor/tests/test_move_action_assign.py b/shopfloor/tests/test_move_action_assign.py new file mode 100644 index 0000000000..7f387f7d6b --- /dev/null +++ b/shopfloor/tests/test_move_action_assign.py @@ -0,0 +1,82 @@ +from .common import CommonCase + + +class TestStockMoveActionAssign(CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.wh = cls.env.ref("stock.warehouse0") + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.wh.sudo().delivery_steps = "pick_pack_ship" + + def test_action_assign_package_level(self): + """calling _action_assign on move does not erase lines' "result_package_id" + + At the end of the method ``StockMove._action_assign()``, the method + ``StockPicking._check_entire_pack()`` is called. This method compares + the move lines with the quants of their source package, and if the entire + package is moved at once in the same transfer, a ``stock.package_level`` is + created. On creation of a ``stock.package_level``, the result package of + the move lines is directly updated with the entire package. + + This is good on the first assign of the move, but when we call assign for + the second time on a move, for instance because it was made partially available + and we want to assign the remaining, it can override the result package we + selected before. + + An override of ``StockPicking._check_move_lines_map_quant_package()`` ensures + that we ignore: + + * picked lines (qty_done > 0) + * lines with a different result package already + """ + package = self.env["stock.quant.package"].create({"name": "Src Pack"}) + dest_package1 = self.env["stock.quant.package"].create({"name": "Dest Pack1"}) + + picking = self._create_picking( + picking_type=self.wh.pick_type_id, lines=[(self.product_a, 50)] + ) + self._update_qty_in_location( + picking.location_id, self.product_a, 20, package=package + ) + picking.action_assign() + + self.assertEqual(picking.state, "assigned") + self.assertEqual(picking.package_level_ids.package_id, package) + + move = picking.move_lines + line = move.move_line_ids + + # we are no longer moving the entire package + line.qty_done = 20 + line.result_package_id = dest_package1 + + # create remaining quantity + new_package = self.env["stock.quant.package"].create({"name": "New Pack"}) + self._update_qty_in_location( + picking.location_id, self.product_a, 30, package=new_package + ) + + move._action_assign() + new_line = move.move_line_ids - line + + # At the end of _action_assign(), StockPicking._check_entire_pack() is + # called, which, by default, look the sum of the move lines qties, and + # if they match a package, it: + # + # * creates a package level + # * updates all the move lines result package with the package, + # including the 'done' lines + # + # These checks ensure that we prevent this to happen if we already set + # a result package. + self.assertRecordValues( + line + new_line, + [ + {"qty_done": 20, "result_package_id": dest_package1.id}, + {"qty_done": 0, "result_package_id": new_package.id}, + ], + ) From 5d010898a9e797cd63c02bf94e7ed9e483838e6d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 25 Sep 2020 07:59:38 +0200 Subject: [PATCH 379/940] backend: Fix issue on stock out endpoints When we create a stock issue, create an inventory with the quantity of units to keep. The quantity to keep is the quantity of products which have been picked (qty_done > 0). To do so, we unreserve the move lines in the location where qty_done == 0, so remain only the picked ones, and when creating the inventory, we sum the move lines quantities. As the state of a move line is a related to the move, we have to include "partially_available" ones. It is important to keep the domain on the state to exclude cancel and done moves, and it should be more efficient to filter on assigned/partially_available than to exclude all the others. A scenario where it was observed is: In a location, we have 2 packages: * Pack1: 2 units * Pack2: 100 units We create a move for 100 units, it creates 2 move lines: * 2 units from Pack1 * 98 units from Pack2 In the application, we scan Pack1, move only 1 unit to a destination Bin. The next line is shown for the remaining unit of Pack1, we use the "stock out" button. At this point, what we expect: * The inventory for Pack1 is created with 1 unit (to satisfy the first unit put in the destination Bin) Before the correction: * The inventory for Pack1 is created with 0 unit, because the move is "partially_available", so the move line for the picked unit is ignored. I researched for other occurrences where we do not search for "partially_available" on move lines and fixed them. --- shopfloor/actions/inventory.py | 2 +- shopfloor/services/cluster_picking.py | 4 +- .../tests/test_cluster_picking_stock_issue.py | 52 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/shopfloor/actions/inventory.py b/shopfloor/actions/inventory.py index f195c05d11..c0a32de3fe 100644 --- a/shopfloor/actions/inventory.py +++ b/shopfloor/actions/inventory.py @@ -124,7 +124,7 @@ def _stock_issue_get_related_move_lines(self, move, location, package, lot): ("product_id", "=", move.product_id.id), ("package_id", "=", package.id), ("lot_id", "=", lot.id), - ("state", "=", "assigned"), + ("state", "in", ("assigned", "partially_available")), ] return self.env["stock.move.line"].search(domain) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 660d0dc782..5edecae6f6 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -341,7 +341,7 @@ def _lines_to_pick(self, picking_batch): return self._lines_for_picking_batch( picking_batch, filter_func=lambda l: ( - l.state == "assigned" + l.state in ("assigned", "partially_available") # On 'StockPicking.action_assign()', result_package_id is set to # the same package as 'package_id'. Here, we need to exclude lines # that were already put into a bin, i.e. the destination package @@ -657,7 +657,7 @@ def _data_for_unload_single(self, batch, package): def _filter_for_unload(self, line): return ( - line.state == "assigned" + line.state in ("assigned", "partially_available") and line.qty_done > 0 and line.result_package_id and not line.shopfloor_unloaded diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py index 8d1eaa0a83..130319cea7 100644 --- a/shopfloor/tests/test_cluster_picking_stock_issue.py +++ b/shopfloor/tests/test_cluster_picking_stock_issue.py @@ -340,3 +340,55 @@ def test_stock_issue_reserve_elsewhere(self): ), self.shelf2, ) + + def test_stock_issue_similar_move_with_picked_line(self): + """Stock issue on the remaining of a line on partial move + + We have a move with 10 units. + 2 are reserved in a package. The remaining in another package. + We pick 1 of the first package and put it in a bin. + A new move line of 1 is created to pick in the first package: we + declare a stock out on it. + The first move line must be untouched, the second line for the remaining + should pick one more item in the other package. + """ + package1 = self.env["stock.quant.package"].create({"name": "PACKAGE_1"}) + package2 = self.env["stock.quant.package"].create({"name": "PACKAGE_2"}) + self._update_qty_in_location(self.shelf1, self.product_a, 2, package=package1) + self._update_qty_in_location(self.shelf1, self.product_a, 200, package=package2) + self.move1._action_assign() + self.move2._action_assign() + self.move3._action_assign() + self._simulate_batch_selected(self.batch, fill_stock=False) + self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"}) + + pick_line1, pick_line2 = self.move1.move_line_ids + new_line, __ = pick_line1._split_qty_to_be_done(1) + self._set_dest_package_and_done(pick_line1, self.dest_package) + + self.assertEqual(pick_line1.product_qty, 1.0) + self.assertEqual(new_line.product_qty, 1.0) + self.assertEqual(pick_line2.product_qty, 8.0) + # on the third move, the operator can't pick anymore in shelf1 + # because there is nothing inside, they declare a stock issue + self._stock_issue(new_line, next_line_func=lambda: pick_line2) + + self.assertRecordValues( + # check that the first move line of the move was not altered + pick_line1, + [ + { + "location_id": self.shelf1.id, + "qty_done": 1.0, + "result_package_id": self.dest_package.id, + } + ], + ) + # the line on which we declared stock out does not exists + self.assertFalse(new_line.exists()) + # the second line to pick has been raised to 9 instead of 8 + # initially, to compensate the stock out + self.assertEqual(pick_line2.product_qty, 9.0) + + # quant with stock out has been updated + self.assertEqual(package1.quant_ids.quantity, 1.0) From f982a3564d8215445721a3c47fa4060ea1a83817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 1 Oct 2020 12:22:27 +0200 Subject: [PATCH 380/940] shopfloor: zone_picking, show if location are about to be empty (#90) * shopfloor: zone_picking, show if location are about to be empty On 'select_line' screen, inform the user that if he process this move line he will empty the location. This could mean for him that he'll be able to retrieve a pallet at the same time without taking one in another place to prepare the goods. --- shopfloor/models/stock_location.py | 29 +++++++++----- shopfloor/services/zone_picking.py | 20 +++++++++- shopfloor/tests/test_zone_picking_base.py | 20 +++++----- .../tests/test_zone_picking_select_line.py | 39 +++++++++++++++++++ 4 files changed, 88 insertions(+), 20 deletions(-) diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py index eb8f6909ac..9641cec1a0 100644 --- a/shopfloor/models/stock_location.py +++ b/shopfloor/models/stock_location.py @@ -45,25 +45,36 @@ def _compute_reserved_move_lines(self): for rec in self: rec.update({"reserved_move_line_ids": rec._get_reserved_move_lines()}) - def planned_qty_in_location_is_empty(self): + def planned_qty_in_location_is_empty(self, move_lines=None): """Return if a location will be empty when move lines will be confirmed Used for the "zero check". We need to know if a location is empty, but since we set the move lines to "done" only at the end of the unload workflow, we have to look at the qty_done of the move lines from this location. + + With `move_lines` we can force the use of the given move lines for the check. + This allows to know that the location will be empty if we process only + these move lines. """ self.ensure_one() quants = self.env["stock.quant"].search( [("quantity", ">", 0), ("location_id", "=", self.id)] ) remaining = sum(quants.mapped("quantity")) - lines = self.env["stock.move.line"].search( - [ - ("state", "!=", "done"), - ("location_id", "=", self.id), - ("qty_done", ">", 0), - ] - ) - planned = remaining - sum(lines.mapped("qty_done")) + move_line_qty_field = "qty_done" + if move_lines: + move_lines = move_lines.filtered( + lambda m: m.state not in ("cancel", "done") + ) + move_line_qty_field = "product_uom_qty" + else: + move_lines = self.env["stock.move.line"].search( + [ + ("state", "not in", ("cancel", "done")), + ("location_id", "=", self.id), + ("qty_done", ">", 0), + ] + ) + planned = remaining - sum(move_lines.mapped(move_line_qty_field)) compare = float_compare(planned, 0, precision_rounding=0.01) return compare <= 0 diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index d51ae01c0e..d36e5ffd09 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -220,11 +220,17 @@ def _data_for_move_line(self, zone_location, picking_type, move_line): } def _data_for_move_lines(self, zone_location, picking_type, move_lines): - return { + data = { "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), "move_lines": self.data.move_lines(move_lines, with_picking=True), } + for data_move_line in data["move_lines"]: + move_line = self.env["stock.move.line"].browse(data_move_line["id"]) + data_move_line[ + "empty_location_src" + ] = move_line.location_id.planned_qty_in_location_is_empty(move_line) + return data def _data_for_location(self, zone_location, picking_type, location): return { @@ -1428,7 +1434,7 @@ def _states(self): return { "start": {}, "select_picking_type": self._schema_for_select_picking_type, - "select_line": self._schema_for_move_lines, + "select_line": self._schema_for_move_lines_empty_location, "set_line_destination": self._schema_for_move_line, "zero_check": self._schema_for_zero_check, "change_pack_lot": self._schema_for_move_line, @@ -1548,6 +1554,16 @@ def _schema_for_move_lines(self): } return schema + @property + def _schema_for_move_lines_empty_location(self): + schema = self._schema_for_move_lines + schema["move_lines"]["schema"]["schema"]["empty_location_src"] = { + "type": "boolean", + "nullable": False, + "required": True, + } + return schema + @property def _schema_for_zero_check(self): schema = { diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 05c019c4bc..f2cb932382 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -216,16 +216,18 @@ def _assert_response_select_line( message=None, popup=None, ): + data = { + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "move_lines": self.data.move_lines(move_lines, with_picking=True), + } + for data_move_line in data["move_lines"]: + move_line = self.env["stock.move.line"].browse(data_move_line["id"]) + data_move_line[ + "empty_location_src" + ] = move_line.location_id.planned_qty_in_location_is_empty(move_line) self.assert_response( - response, - next_state=state, - data={ - "zone_location": self.data.location(zone_location), - "picking_type": self.data.picking_type(picking_type), - "move_lines": self.data.move_lines(move_lines, with_picking=True), - }, - message=message, - popup=popup, + response, next_state=state, data=data, message=message, popup=popup, ) def assert_response_select_line( diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py index fc442b851e..ba02eac993 100644 --- a/shopfloor/tests/test_zone_picking_select_line.py +++ b/shopfloor/tests/test_zone_picking_select_line.py @@ -396,3 +396,42 @@ def test_prepare_unload_buffer_multi_line_same_destination(self): self.assert_response_unload_all( response, zone_location, picking_type, self.picking5.move_line_ids, ) + + def test_list_move_lines_empty_location(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "list_move_lines", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "order": "location", + }, + ) + move_lines = self.service._find_location_move_lines( + zone_location, picking_type, order="location" + ) + self.assert_response_select_line( + response, zone_location, picking_type, move_lines, + ) + data_move_lines = response["data"]["select_line"]["move_lines"] + # Check that the move line in "Zone sub-location 1" is about to empty + # its location if we process it + data_move_line = [ + m + for m in data_move_lines + if m["location_src"]["barcode"] == "ZONE_SUBLOCATION_1" + ][0] + self.assertTrue(data_move_line["empty_location_src"]) + # Same check with the internal method + move_line = self.env["stock.move.line"].browse(data_move_line["id"]) + location_src = move_line.location_id + move_line_will_empty_location = location_src.planned_qty_in_location_is_empty( + move_lines=move_line + ) + self.assertTrue(move_line_will_empty_location) + # But if we check the location without giving the move line as parameter, + # knowing that this move line hasn't its 'qty_done' field filled, + # the location won't be considered empty with such pending move line + move_line_will_empty_location = location_src.planned_qty_in_location_is_empty() + self.assertFalse(move_line_will_empty_location) From 687c23e9e5f0fabea79593cd93b70d43d8037a4b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 5 Oct 2020 11:04:31 +0200 Subject: [PATCH 381/940] shopfloor: zone_picking rename empty_location_src -> location_will_be_empty --- shopfloor/services/zone_picking.py | 9 +++++++-- shopfloor/tests/test_zone_picking_base.py | 2 +- shopfloor/tests/test_zone_picking_select_line.py | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index d36e5ffd09..602e387cfc 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -226,9 +226,14 @@ def _data_for_move_lines(self, zone_location, picking_type, move_lines): "move_lines": self.data.move_lines(move_lines, with_picking=True), } for data_move_line in data["move_lines"]: + # TODO: this could be expensive, think about a better way + # to retrieve if location will be empty. + # Maybe group lines by location and compute only once. move_line = self.env["stock.move.line"].browse(data_move_line["id"]) + # `location_will_be_empty` flag states if, by processing this move line + # and picking the product, the location will be emptied. data_move_line[ - "empty_location_src" + "location_will_be_empty" ] = move_line.location_id.planned_qty_in_location_is_empty(move_line) return data @@ -1557,7 +1562,7 @@ def _schema_for_move_lines(self): @property def _schema_for_move_lines_empty_location(self): schema = self._schema_for_move_lines - schema["move_lines"]["schema"]["schema"]["empty_location_src"] = { + schema["move_lines"]["schema"]["schema"]["location_will_be_empty"] = { "type": "boolean", "nullable": False, "required": True, diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index f2cb932382..948ec31161 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -224,7 +224,7 @@ def _assert_response_select_line( for data_move_line in data["move_lines"]: move_line = self.env["stock.move.line"].browse(data_move_line["id"]) data_move_line[ - "empty_location_src" + "location_will_be_empty" ] = move_line.location_id.planned_qty_in_location_is_empty(move_line) self.assert_response( response, next_state=state, data=data, message=message, popup=popup, diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py index ba02eac993..614cd6a3ff 100644 --- a/shopfloor/tests/test_zone_picking_select_line.py +++ b/shopfloor/tests/test_zone_picking_select_line.py @@ -422,7 +422,7 @@ def test_list_move_lines_empty_location(self): for m in data_move_lines if m["location_src"]["barcode"] == "ZONE_SUBLOCATION_1" ][0] - self.assertTrue(data_move_line["empty_location_src"]) + self.assertTrue(data_move_line["location_will_be_empty"]) # Same check with the internal method move_line = self.env["stock.move.line"].browse(data_move_line["id"]) location_src = move_line.location_id From 3842b4fc830553341a0ba70170097d1ada2ad049 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 7 Oct 2020 08:50:13 +0200 Subject: [PATCH 382/940] backend: add option on menu to allow unreserving moves --- shopfloor/models/shopfloor_menu.py | 38 ++++++++++++++++++++++++++++++ shopfloor/views/shopfloor_menu.xml | 15 ++++++++++++ 2 files changed, 53 insertions(+) diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 23b7f0bd9e..0a16a91ab5 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -11,6 +11,11 @@ class ShopfloorMenu(models.Model): "location_content_transfer", ) + _scenario_allowing_unreserve_other_moves = ( + "single_pack_transfer", + "location_content_transfer", + ) + name = fields.Char(translate=True) sequence = fields.Integer() profile_ids = fields.Many2many( @@ -32,6 +37,15 @@ class ShopfloorMenu(models.Model): " scanned and no move already exists. Any new move is created in the" " selected operation type, so it can be active only when one type is selected.", ) + unreserve_other_moves_is_possible = fields.Boolean( + compute="_compute_unreserve_other_moves_is_possible" + ) + allow_unreserve_other_moves = fields.Boolean( + string="Allow to process reserved quantities", + default=False, + help="If you tick this box, this scenario will allow operator to move" + " goods even if a reservation is made by a different operation type.", + ) active = fields.Boolean(default=True) def _selection_scenario(self): @@ -65,6 +79,30 @@ def _check_allow_move_create(self): _("Creation of moves is not allowed for menu {}.").format(menu.name) ) + @api.depends("scenario", "picking_type_ids") + def _compute_unreserve_other_moves_is_possible(self): + for menu in self: + menu.unreserve_other_moves_is_possible = ( + menu.scenario in self._scenario_allowing_unreserve_other_moves + ) + + @api.onchange("unreserve_other_moves_is_possible") + def onchange_unreserve_other_moves_is_possible(self): + self.allow_unreserve_other_moves = self.unreserve_other_moves_is_possible + + @api.constrains("scenario", "picking_type_ids", "allow_unreserve_other_moves") + def _check_allow_unreserve_other_moves(self): + for menu in self: + if ( + menu.allow_unreserve_other_moves + and not menu.unreserve_other_moves_is_possible + ): + raise exceptions.ValidationError( + _( + "Processing reserved quantities is" " not allowed for menu {}." + ).format(menu.name) + ) + # ATM the goal is to block using single_pack_transfer (SPT) # w/out moving the full pkg. # Is not optimal, but is mandatory as long as SPT does not work w/ moves diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index 149d4cea7b..7777787659 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -23,6 +23,11 @@ name="allow_move_create" attrs="{'invisible': [('move_create_is_possible', '=', False)]}" /> + + @@ -64,6 +69,16 @@ + + + + From 6436c9497587f8eb48f6490fe54d437b66147c1d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 7 Oct 2020 11:17:38 +0200 Subject: [PATCH 383/940] location transfer: implement unreserve of existing moves When we scan a location which already have reserved move lines for other picking types, and the option is active on the menu, unreserve the moves, create new moves for the location content transfer and re-assign the unreserved moves. If one of the move that would be unreserved has already been picked (qty_done > 0), an error is returned. I added a savepoint to rollback when the we had to unreserve moves and finally we can't reserve the location transfer moves for any reason. As a side effect, we don't need anymore to cancel them as they will be rollbacked. --- shopfloor/actions/message.py | 8 ++ shopfloor/models/stock_package_level.py | 26 +++++ .../services/location_content_transfer.py | 108 +++++++++++++++--- .../test_location_content_transfer_start.py | 89 ++++++++++++++- 4 files changed, 216 insertions(+), 15 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 00724daf90..e084794e86 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -355,6 +355,14 @@ def location_content_transfer_complete(self, location_src, location_dest): ), } + def picking_already_started_in_location(self, pickings): + return { + "message_type": "error", + "body": _( + "Picking has already been started in this location in transfer(s): {}" + ).format(", ".join(pickings.mapped("name"))), + } + def transfer_done_success(self, picking): return { "message_type": "success", diff --git a/shopfloor/models/stock_package_level.py b/shopfloor/models/stock_package_level.py index 24b1b8a531..a8569cdc3d 100644 --- a/shopfloor/models/stock_package_level.py +++ b/shopfloor/models/stock_package_level.py @@ -27,3 +27,29 @@ def replace_package(self, new_package): line.owner_id = quant.owner_id self.package_id = new_package + + def shallow_unlink(self): + """Unlink but keep the moves + A package level has a relation to "move_ids" only when the + package level was created first from the UI and it created + its move. + When we unlink a package level, it deletes the move it created. + But in some cases, we want to keep the move, e.g.: + * create a package level from the UI to move a package + * it generates a move for the matching product quantity + * we use a barcode scenario such as cluster or zone picking + * we use the "replace package" button + * when replacing the package, we have to delete the package level, + but we still have the same need in term of "I want X products", + so we have to keep the move + * another case is when we "dismiss" the package level in the location + content transfer scenario, we want to keep the "need" in moves, but + we are no longer moving the entire package level + """ + self.move_ids.package_level_id = False + self.unlink() + + def explode_package(self): + move_lines = self.move_line_ids + move_lines.result_package_id = False + self.shallow_unlink() diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 3e3e34cd9c..abc5dfeee2 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -1,4 +1,9 @@ +import uuid + +from psycopg2 import sql + from odoo import _ +from odoo.sql_db import clear_env, flush_env from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -183,6 +188,12 @@ def _find_location_move_lines_domain(self, location): ("shopfloor_user_id", "=", False), ] + def _find_location_all_move_lines_domain(self, location): + return [ + ("location_id", "=", location.id), + ("state", "in", ("assigned", "partially_available")), + ] + def _find_location_move_lines(self, location): """Find lines that potentially are to move in the location""" return self.env["stock.move.line"].search( @@ -213,6 +224,42 @@ def _create_moves_from_location(self, location): ) return self.env["stock.move"].create(move_vals_list) + def _unreserve_other_lines(self, location, move_lines): + """Unreserve move lines in location in another picking type + + Returns a tuple of ( + move lines that stays in the location to process, + moves to reserve again, + response to return to client in case of error + ) + """ + lines_other_picking_types = move_lines.filtered( + lambda line: line.picking_id.picking_type_id not in self.picking_types + ) + if not lines_other_picking_types: + return move_lines + unreserved_moves = move_lines.move_id + location_move_lines = self.env["stock.move.line"].search( + self._find_location_all_move_lines_domain(location) + ) + extra_move_lines = location_move_lines - move_lines + if extra_move_lines: + return ( + self.env["stock.move.line"].browse(), + self.env["stock.move"].browse(), + self._response_for_start( + message=self.msg_store.picking_already_started_in_location( + extra_move_lines.picking_id + ) + ), + ) + package_levels = move_lines.package_level_id + # if we leave the package level around, it will try to reserve + # the same package as before + package_levels.explode_package() + unreserved_moves._do_unreserve() + return (move_lines - lines_other_picking_types, unreserved_moves, None) + def scan_location(self, barcode): """Scan start location @@ -247,20 +294,37 @@ def scan_location(self, barcode): pickings = move_lines.picking_id picking_types = pickings.mapped("picking_type_id") - if len(picking_types) > 1: - return self._response_for_start( - message={ - "message_type": "error", - "body": _("This location content can't be moved at once."), - } - ) - if picking_types - self.picking_types: - return self._response_for_start( - message={ - "message_type": "error", - "body": _("This location content can't be moved using this menu."), - } + savepoint_name = uuid.uuid1().hex + flush_env(self.env.cr, clear=False) + # pylint: disable=sql-injection + self.env.cr.execute( + sql.SQL("SAVEPOINT {}").format(sql.Identifier(savepoint_name)) + ) + + unreserved_moves = self.env["stock.move"].browse() + if self.work.menu.allow_unreserve_other_moves: + move_lines, unreserved_moves, response = self._unreserve_other_lines( + location, move_lines ) + if response: + return response + else: + if len(picking_types) > 1: + return self._response_for_start( + message={ + "message_type": "error", + "body": _("This location content can't be moved at once."), + } + ) + if picking_types - self.picking_types: + return self._response_for_start( + message={ + "message_type": "error", + "body": _( + "This location content can't be moved using this menu." + ), + } + ) # Ensure we process move lines related to pickings having only one source # location among all their move lines. If there are different source # locations, we put the move lines we are interested in in a separate picking. @@ -291,7 +355,15 @@ def scan_location(self, barcode): new_moves._action_confirm(merge=False) new_moves._action_assign() if not all([x.state == "assigned" for x in new_moves]): - new_moves._action_cancel() + clear_env( + self.env.cr + ) # required to refresh cache data previous savepoint + # pylint: disable=sql-injection + self.env.cr.execute( + sql.SQL("ROLLBACK TO SAVEPOINT {}").format( + sql.Identifier(savepoint_name) + ) + ) return self._response_for_start( message=self.msg_store.new_move_lines_not_assigned() ) @@ -309,6 +381,14 @@ def scan_location(self, barcode): pickings.user_id = self.env.uid + unreserved_moves._action_assign() + + flush_env(self.env.cr, clear=False) + # pylint: disable=sql-injection + self.env.cr.execute( + sql.SQL("RELEASE SAVEPOINT {}").format(sql.Identifier(savepoint_name)) + ) + return self._router_single_or_all_destination(pickings) def _find_transfer_move_lines_domain(self, location): diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py index 66786e1023..62c4a6f694 100644 --- a/shopfloor/tests/test_location_content_transfer_start.py +++ b/shopfloor/tests/test_location_content_transfer_start.py @@ -152,7 +152,7 @@ class LocationContentTransferStartSpecialCase(LocationContentTransferCommonCase) * /scan_location """ - def test_scan_location_wrong_picking_type(self): + def test_scan_location_wrong_picking_type_error(self): """Content has different picking type than menu""" picking = self._create_picking( picking_type=self.wh.pick_type_id, @@ -173,6 +173,93 @@ def test_scan_location_wrong_picking_type(self): }, ) + def test_scan_location_wrong_picking_type_allow_unreserve_ok(self): + """Content has different picking type than menu, option to unreserve + + The content must be unreserved, new moves created and the previous + content re-reserved. + """ + self.menu.sudo().allow_unreserve_other_moves = True + + picking = self._create_picking( + picking_type=self.wh.pick_type_id, + lines=[(self.product_a, 10), (self.product_b, 10)], + ) + self._fill_stock_for_moves( + picking.move_lines, in_package=True, location=self.content_loc + ) + picking.action_assign() + # place goods in shelf1 to ensure the original picking can take goods here + other_pack_a = self.env["stock.quant.package"].create({}) + other_pack_b = self.env["stock.quant.package"].create({}) + self._update_qty_in_location( + self.shelf1, self.product_a, 10, package=other_pack_a + ) + self._update_qty_in_location( + self.shelf1, self.product_b, 10, package=other_pack_b + ) + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + new_picking = self.env["stock.picking"].search( + [("picking_type_id", "=", self.picking_type.id)] + ) + self.assertEqual(len(new_picking), 1) + self.assert_response_scan_destination_all(response, new_picking) + self.assertRecordValues(new_picking, [{"user_id": self.env.uid}]) + self.assertRecordValues( + new_picking.move_line_ids, [{"qty_done": 10.0}, {"qty_done": 10.0}], + ) + self.assertRecordValues(new_picking.package_level_ids, [{"is_done": True}]) + + # the original picking must be reserved again, should have taken the goods + # of shelf1 + self.assertRecordValues( + picking.move_line_ids, + [ + { + "qty_done": 0.0, + "location_id": self.shelf1.id, + "package_id": other_pack_a.id, + }, + { + "qty_done": 0.0, + "location_id": self.shelf1.id, + "package_id": other_pack_b.id, + }, + ], + ) + + def test_scan_location_wrong_picking_type_allow_unreserve_error(self): + """Content has different picking type than menu, option to unreserve + + If quantity has been partially picked on the existing transfer, prevent + to unreserve them. + """ + self.menu.sudo().allow_unreserve_other_moves = True + + picking = self._create_picking( + picking_type=self.wh.pick_type_id, + lines=[(self.product_a, 10), (self.product_b, 10)], + ) + self._fill_stock_for_moves( + picking.move_lines, in_package=True, location=self.content_loc + ) + picking.action_assign() + # a user picked qty + picking.move_line_ids[0].qty_done = 10 + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + self.assert_response_start( + response, + message=self.service.msg_store.picking_already_started_in_location(picking), + ) + # check that the original moves are still assigned + self.assertRecordValues( + picking.move_lines, [{"state": "assigned"}, {"state": "assigned"}] + ) + def test_scan_location_create_moves(self): """The scanned location has no move lines but has some quants to move.""" picking_type = self.menu.picking_type_ids From 32c2f6659205beac57cc877c3edad1146e551ab7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 7 Oct 2020 14:10:23 +0200 Subject: [PATCH 384/940] single pack transfer: implement unreserve of existing moves When we scan a package which already have reserved move lines for other picking types, and the option is active on the menu, unreserve the moves, create new moves for the location content transfer and re-assign the unreserved moves. If one of the move that would be unreserved has already been picked (qty_done > 0), an error is returned. --- shopfloor/services/single_pack_transfer.py | 57 +++++ shopfloor/tests/test_single_pack_transfer.py | 214 ++++++++++++++++-- .../tests/test_single_pack_transfer_base.py | 26 +++ 3 files changed, 273 insertions(+), 24 deletions(-) create mode 100644 shopfloor/tests/test_single_pack_transfer_base.py diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 46f5526225..5f67f6c6d4 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -1,3 +1,10 @@ +import uuid + +from psycopg2 import sql + +from odoo import fields +from odoo.sql_db import clear_env, flush_env + from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -83,6 +90,39 @@ def start(self, barcode, confirmation=False): ("picking_id.picking_type_id", "in", picking_types.ids), ] ) + + # Start a savepoint because we are may unreserve moves of other + # picking types. If we do and we can't create a package level after, + # we rollback to the initial state + savepoint_name = uuid.uuid1().hex + flush_env(self.env.cr, clear=False) + # pylint: disable=sql-injection + self.env.cr.execute( + sql.SQL("SAVEPOINT {}").format(sql.Identifier(savepoint_name)) + ) + unreserved_moves = self.env["stock.move"].browse() + if not package_level: + other_move_lines = self.env["stock.move.line"].search( + [ + ("package_id", "=", package.id), + # to exclude canceled and done + ("state", "in", ("assigned", "partially_available")), + ] + ) + if any(line.qty_done > 0 for line in other_move_lines) or ( + other_move_lines and not self.work.menu.allow_unreserve_other_moves + ): + picking = fields.first(other_move_lines).picking_id + return self._response_for_start( + message=self.msg_store.package_already_picked_by(package, picking) + ) + elif other_move_lines and self.work.menu.allow_unreserve_other_moves: + + unreserved_moves = other_move_lines.move_id + other_package_levels = other_move_lines.package_level_id + other_package_levels.explode_package() + unreserved_moves._do_unreserve() + # State is computed, can't use it in the domain. And it's probably faster # to filter here rather than using a domain on "picking_id.state" that would # use a sub-search on stock.picking: we shouldn't have dozens of package levels @@ -95,6 +135,14 @@ def start(self, barcode, confirmation=False): package_level = self._create_package_level(package) if not package_level: + # restore any unreserved move/package level + clear_env(self.env.cr) # required to refresh cache data previous savepoint + # pylint: disable=sql-injection + self.env.cr.execute( + sql.SQL("ROLLBACK TO SAVEPOINT {}").format( + sql.Identifier(savepoint_name) + ) + ) return self._response_for_start( message=self.msg_store.no_pending_operation_for_pack(package) ) @@ -104,6 +152,15 @@ def start(self, barcode, confirmation=False): ) if not package_level.is_done: package_level.is_done = True + + unreserved_moves._action_assign() + + flush_env(self.env.cr, clear=False) + # pylint: disable=sql-injection + self.env.cr.execute( + sql.SQL("RELEASE SAVEPOINT {}").format(sql.Identifier(savepoint_name)) + ) + return self._response_for_scan_location(package_level) def _create_package_level(self, package): diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index 041aa51b56..9057f51755 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -1,22 +1,12 @@ from odoo.tests.common import Form -from .common import CommonCase +from .test_single_pack_transfer_base import SinglePackTransferCommonBase -class SinglePackTransferCase(CommonCase): - @classmethod - def setUpClassVars(cls, *args, **kwargs): - super().setUpClassVars(*args, **kwargs) - cls.menu = cls.env.ref("shopfloor.shopfloor_menu_single_pallet_transfer") - cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") - cls.wh = cls.profile.warehouse_id - cls.picking_type = cls.menu.picking_type_ids - +class SinglePackTransferCase(SinglePackTransferCommonBase): @classmethod def setUpClassBaseData(cls, *args, **kwargs): super().setUpClassBaseData(*args, **kwargs) - # we activate the move creation in tests when needed - cls.menu.sudo().allow_move_create = False cls.pack_a = cls.env["stock.quant.package"].create( {"location_id": cls.stock_location.id} ) @@ -34,15 +24,6 @@ def setUpClassBaseData(cls, *args, **kwargs): ) cls.picking = cls._create_initial_move() - # disable the completion on the picking type, we'll have specific test(s) - # to check the behavior of this screen - cls.picking_type.sudo().display_completion_info = False - - def setUp(self): - super().setUp() - with self.work_on_services(menu=self.menu, profile=self.profile) as work: - self.service = work.component(usage="single_pack_transfer") - @classmethod def _create_initial_move(cls): """Create the move to satisfy the pre-condition before /start""" @@ -188,9 +169,7 @@ def test_start_no_operation_create(self): "confirmation_required": False, } - self.assert_response( - response, next_state="scan_location", data=expected_data, - ) + self.assert_response(response, next_state="scan_location", data=expected_data) def test_start_barcode_not_known(self): """Test /start when the barcode is unknown @@ -866,3 +845,190 @@ def test_cancel_not_found(self): "body": "This operation does not exist anymore.", }, ) + + +class SinglePackTransferSpecialCase(SinglePackTransferCommonBase): + def test_start_package_unreserve_ok(self): + """Test /start when the package was already reserved... + + ...for another picking type and unreserving is allowed. + + When we scan a location which contains only one package, + we want to move this package. + + The pre-conditions: + + * A package exists in Stock/Shelf1. + * A stock picking exists to move the package from Stock/Shelf1 to + Out with a different picking type. The move is "assigned". + * Another package exists in Stock + + Expected result: + + * the original transfer is reserved to move the other package from Stock + * a new transfer is created to move the package from Shelf1 + + The next step in the workflow is to call /validate with the + package level that will set the move and picking to done. + """ + self.menu.sudo().allow_move_create = True + self.menu.sudo().allow_unreserve_other_moves = True + + package = self.env["stock.quant.package"].create({}) + self._update_qty_in_location(self.shelf1, self.product_a, 10, package=package) + # create a picking of another picking type + picking = self._create_picking( + picking_type=self.wh.out_type_id, lines=[(self.product_a, 10)] + ) + picking.action_assign() + + # create another package that should be used when the picking will + # get re-assigned + package2 = self.env["stock.quant.package"].create({}) + self._update_qty_in_location( + self.stock_location, self.product_a, 10, package=package2 + ) + + barcode = self.shelf1.barcode + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + + new_picking = self.env["stock.picking"].search( + [("picking_type_id", "=", self.picking_type.id)] + ) + self.assertEqual(len(new_picking), 1) + new_package_level = new_picking.package_level_ids + + self.assert_response( + # We only care about the fact that we jump to the next + # screen, so it found the pack. The details are already + # checked in the test_start test. + response, + next_state="scan_location", + data=dict( + self.service._data_after_package_scanned(new_package_level), + confirmation_required=False, + ), + ) + self.assertRecordValues( + picking.package_level_ids, [{"package_id": package2.id}] + ) + + self.assertRecordValues(new_package_level, [{"package_id": package.id}]) + + def test_start_package_unreserve_picked_error(self): + """Test /start when the package was already reserved... + + ...for another picking type and the other move is already picked. + + When we scan a location which contains only one package, + we want to move this package. + + The pre-conditions: + + * A package exists in Stock/Shelf1. + * A stock picking exists to move the package from Stock/Shelf1 to + Out with a different picking type. The move is "assigned". + + Expected result: + + * receive an error that we cannot pick the package + """ + self.menu.sudo().allow_move_create = True + self.menu.sudo().allow_unreserve_other_moves = True + + package = self.env["stock.quant.package"].create({}) + self._update_qty_in_location(self.shelf1, self.product_a, 10, package=package) + # create a picking of another picking type + picking = self._create_picking( + picking_type=self.wh.out_type_id, lines=[(self.product_a, 10)] + ) + picking.action_assign() + + # pick the goods + picking.move_line_ids.qty_done = 10 + + barcode = self.shelf1.barcode + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + next_state="start", + message=self.service.msg_store.package_already_picked_by(package, picking), + ) + # no change in the picking + self.assertEqual(picking.state, "assigned") + self.assertRecordValues(picking.package_level_ids, [{"package_id": package.id}]) + + def test_start_package_unreserve_disabled_error(self): + """Test /start when the package was already reserved... + + ...for another picking type and unreserving is disallowed. + + When we scan a location which contains only one package, + we want to move this package. + + The pre-conditions: + + * A package exists in Stock/Shelf1. + * A stock picking exists to move the package from Stock/Shelf1 to + Out with a different picking type. The move is "assigned". + + Expected result: + + * receive an error that we cannot pick the package + """ + self.menu.sudo().allow_move_create = True + self.menu.sudo().allow_unreserve_other_moves = False + + package = self.env["stock.quant.package"].create({}) + self._update_qty_in_location(self.shelf1, self.product_a, 10, package=package) + # create a picking of another picking type + picking = self._create_picking( + picking_type=self.wh.out_type_id, lines=[(self.product_a, 10)] + ) + picking.action_assign() + barcode = self.shelf1.barcode + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + next_state="start", + message=self.service.msg_store.package_already_picked_by(package, picking), + ) + # no change in the picking + self.assertEqual(picking.state, "assigned") + self.assertRecordValues(picking.package_level_ids, [{"package_id": package.id}]) + + def test_start_package_unreserve_no_create_error(self): + """Test /start when the package was already reserved... + + ...for another picking type and unreserving is allowed + and the option to create a move is not allowed. + + This test ensure that the unreservation of the first package + is rollbacked. + + """ + self.menu.sudo().allow_move_create = False + self.menu.sudo().allow_unreserve_other_moves = True + + package = self.env["stock.quant.package"].create({}) + self._update_qty_in_location(self.shelf1, self.product_a, 10, package=package) + # create a picking of another picking type + picking = self._create_picking( + picking_type=self.wh.out_type_id, lines=[(self.product_a, 10)] + ) + picking.action_assign() + self.assertEqual(picking.state, "assigned") + barcode = self.shelf1.barcode + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + next_state="start", + message=self.service.msg_store.no_pending_operation_for_pack(package), + ) + # no change in the picking + self.assertEqual(picking.state, "assigned") + self.assertRecordValues(picking.package_level_ids, [{"package_id": package.id}]) diff --git a/shopfloor/tests/test_single_pack_transfer_base.py b/shopfloor/tests/test_single_pack_transfer_base.py new file mode 100644 index 0000000000..8f04301fc9 --- /dev/null +++ b/shopfloor/tests/test_single_pack_transfer_base.py @@ -0,0 +1,26 @@ +from .common import CommonCase + + +class SinglePackTransferCommonBase(CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_single_pallet_transfer") + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + cls.wh = cls.profile.warehouse_id + cls.picking_type = cls.menu.picking_type_ids + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + # we activate the move creation in tests when needed + cls.menu.sudo().allow_move_create = False + + # disable the completion on the picking type, we'll have specific test(s) + # to check the behavior of this screen + cls.picking_type.sudo().display_completion_info = False + + def setUp(self): + super().setUp() + with self.work_on_services(menu=self.menu, profile=self.profile) as work: + self.service = work.component(usage="single_pack_transfer") From ed16c5f22910dbb4c6a620ded6e54172ba8649f0 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 7 Oct 2020 14:39:37 +0200 Subject: [PATCH 385/940] backend: Add a component to factorize savepoint methods --- shopfloor/actions/__init__.py | 1 + shopfloor/actions/savepoint.py | 44 +++++++++++++++++++ .../services/location_content_transfer.py | 28 ++---------- shopfloor/services/single_pack_transfer.py | 26 ++--------- 4 files changed, 51 insertions(+), 48 deletions(-) create mode 100644 shopfloor/actions/savepoint.py diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index 7aa11fa809..464f279228 100644 --- a/shopfloor/actions/__init__.py +++ b/shopfloor/actions/__init__.py @@ -25,3 +25,4 @@ from . import message from . import search from . import inventory +from . import savepoint diff --git a/shopfloor/actions/savepoint.py b/shopfloor/actions/savepoint.py new file mode 100644 index 0000000000..9e031de542 --- /dev/null +++ b/shopfloor/actions/savepoint.py @@ -0,0 +1,44 @@ +import uuid + +from psycopg2 import sql + +from odoo.sql_db import clear_env, flush_env + +from odoo.addons.component.core import Component + + +class SavepointBuilder(Component): + """Return a new Savepoint instance""" + + _name = "shopfloor.savepoint.action" + _inherit = "shopfloor.process.action" + _usage = "savepoint" + + def new(self): + return Savepoint(self.env.cr) + + +class Savepoint(object): + """Wrapper for SQL Savepoint + + Close to "cr.savepoint()" context manager but this class gives more control + over when the release/rollback are called. + """ + + def __init__(self, cr): + self._cr = cr + self.name = uuid.uuid1().hex + flush_env(self._cr, clear=False) + self._execute("SAVEPOINT {}") + + def rollback(self): + clear_env(self._cr) + self._execute("ROLLBACK TO SAVEPOINT {}") + + def release(self): + flush_env(self._cr, clear=False) + self._execute("RELEASE SAVEPOINT {}") + + def _execute(self, query): + # pylint: disable=sql-injection + self._cr.execute(sql.SQL(query).format(sql.Identifier(self.name))) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index abc5dfeee2..88e0762ea3 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -1,9 +1,4 @@ -import uuid - -from psycopg2 import sql - from odoo import _ -from odoo.sql_db import clear_env, flush_env from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -294,12 +289,7 @@ def scan_location(self, barcode): pickings = move_lines.picking_id picking_types = pickings.mapped("picking_type_id") - savepoint_name = uuid.uuid1().hex - flush_env(self.env.cr, clear=False) - # pylint: disable=sql-injection - self.env.cr.execute( - sql.SQL("SAVEPOINT {}").format(sql.Identifier(savepoint_name)) - ) + savepoint = self.actions_for("savepoint").new() unreserved_moves = self.env["stock.move"].browse() if self.work.menu.allow_unreserve_other_moves: @@ -355,15 +345,7 @@ def scan_location(self, barcode): new_moves._action_confirm(merge=False) new_moves._action_assign() if not all([x.state == "assigned" for x in new_moves]): - clear_env( - self.env.cr - ) # required to refresh cache data previous savepoint - # pylint: disable=sql-injection - self.env.cr.execute( - sql.SQL("ROLLBACK TO SAVEPOINT {}").format( - sql.Identifier(savepoint_name) - ) - ) + savepoint.rollback() return self._response_for_start( message=self.msg_store.new_move_lines_not_assigned() ) @@ -383,11 +365,7 @@ def scan_location(self, barcode): unreserved_moves._action_assign() - flush_env(self.env.cr, clear=False) - # pylint: disable=sql-injection - self.env.cr.execute( - sql.SQL("RELEASE SAVEPOINT {}").format(sql.Identifier(savepoint_name)) - ) + savepoint.release() return self._router_single_or_all_destination(pickings) diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 5f67f6c6d4..0af66ebb2d 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -1,9 +1,4 @@ -import uuid - -from psycopg2 import sql - from odoo import fields -from odoo.sql_db import clear_env, flush_env from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -94,12 +89,7 @@ def start(self, barcode, confirmation=False): # Start a savepoint because we are may unreserve moves of other # picking types. If we do and we can't create a package level after, # we rollback to the initial state - savepoint_name = uuid.uuid1().hex - flush_env(self.env.cr, clear=False) - # pylint: disable=sql-injection - self.env.cr.execute( - sql.SQL("SAVEPOINT {}").format(sql.Identifier(savepoint_name)) - ) + savepoint = self.actions_for("savepoint").new() unreserved_moves = self.env["stock.move"].browse() if not package_level: other_move_lines = self.env["stock.move.line"].search( @@ -136,13 +126,7 @@ def start(self, barcode, confirmation=False): if not package_level: # restore any unreserved move/package level - clear_env(self.env.cr) # required to refresh cache data previous savepoint - # pylint: disable=sql-injection - self.env.cr.execute( - sql.SQL("ROLLBACK TO SAVEPOINT {}").format( - sql.Identifier(savepoint_name) - ) - ) + savepoint.rollback() return self._response_for_start( message=self.msg_store.no_pending_operation_for_pack(package) ) @@ -155,11 +139,7 @@ def start(self, barcode, confirmation=False): unreserved_moves._action_assign() - flush_env(self.env.cr, clear=False) - # pylint: disable=sql-injection - self.env.cr.execute( - sql.SQL("RELEASE SAVEPOINT {}").format(sql.Identifier(savepoint_name)) - ) + savepoint.release() return self._response_for_scan_location(package_level) From f248c84612ac2705bee33496ca56212c66843aef Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 7 Sep 2020 11:04:28 +0200 Subject: [PATCH 386/940] Rework change of package/lot The previous implementation suffered from several bugs or limitations, mainly due to how the package levels were handled. The new behavior when a user asks to change a package is: * search for other lines that were moving the package, if the lines are not picked (qty_done > 0), unreserve them * if the scanned package was not expected in the current location, make an inventory adjustment to fix it (as before) * explode [0] the package level of the unreserved lines, otherwise, the next assign on them would try to reserve the same package again * explode [0] the package level of the move line where we replace the package * update the move line with the package, and update the move line with the same lot as the quant in the package if any * if the available quantity in the quant of the package is lesser than the original quantity, update the move line quantity and run again "_action_assign" on the move that will generate a new move line for the remaining * the call on _action_assign will also regenerate a package level for the move line (which will set the "result_package_id" to the same value) if the new package is entirely moved * try to reserve again the lines previously unreserved [0] delete the package level and set "result_package_id" to False on related move lines The same way, changing a lot unreserve other lines using the same lot if the whole quantity is reserved. If the quantity is partially reserved, it only reduces the current quantity to the available (so the user may do proceeds in 2 steps: take the available part, scan again the lot which will unreserve the other lines). When a lot is scanned but there is no known quant in the location, it means there is an error in the inventory: add the quantity in odoo so the user is not blocked, and create a control inventory to check. Tests are moved to test case working only on the "change.package.lot" component, the tests to change a package or lot in the services are kept only for simple cases so we tests the responses for success or errors. --- shopfloor/actions/change_package_lot.py | 235 ++-- shopfloor/actions/inventory.py | 45 +- shopfloor/actions/message.py | 18 +- shopfloor/models/stock_move_line.py | 108 +- shopfloor/models/stock_package_level.py | 25 +- shopfloor/models/stock_quant_package.py | 33 + shopfloor/services/cluster_picking.py | 6 +- .../services/location_content_transfer.py | 3 +- shopfloor/tests/__init__.py | 1 + shopfloor/tests/common.py | 28 + .../tests/test_actions_change_package_lot.py | 1167 +++++++++++++++++ .../test_cluster_picking_change_pack_lot.py | 434 +----- .../test_zone_picking_change_pack_lot.py | 68 +- 13 files changed, 1524 insertions(+), 647 deletions(-) create mode 100644 shopfloor/tests/test_actions_change_package_lot.py diff --git a/shopfloor/actions/change_package_lot.py b/shopfloor/actions/change_package_lot.py index 5d96a56ea2..2537cea5b5 100644 --- a/shopfloor/actions/change_package_lot.py +++ b/shopfloor/actions/change_package_lot.py @@ -1,4 +1,5 @@ -from odoo import _ +from odoo import _, exceptions +from odoo.tools.float_utils import float_compare, float_is_zero from odoo.addons.component.core import Component @@ -24,28 +25,30 @@ def change_lot(self, move_line, lot, response_ok_func, response_error_func): # different, ...) # * if we have several packages for the same lot, we can't know which # one the operator is moving, ask to scan a package - lot_package_quants = self.env["stock.quant"].search( + lot_quants = self.env["stock.quant"].search( [ ("lot_id", "=", lot.id), ("location_id", "=", move_line.location_id.id), - ("package_id", "!=", False), ("quantity", ">", 0), ] ) - if move_line.package_id and not lot_package_quants: + package_quants = lot_quants.filtered(lambda quant: quant.package_id) + unit_quants = lot_quants - package_quants + + if len(package_quants) > 1 or (package_quants and unit_quants): + # When we can't know which package to take, ask to scan a package. + # If we have both units and package, they have to scan the package + # first. return response_error_func( - move_line, message=self.msg_store.lot_is_not_a_package(lot), + move_line, + message=self.msg_store.several_packs_in_location(move_line.location_id), ) - if len(lot_package_quants) == 1: - package = lot_package_quants.package_id + elif len(package_quants) == 1: + # change the package directly + package = package_quants.package_id return self.change_package( move_line, package, response_ok_func, response_error_func ) - elif len(lot_package_quants) > 1: - return response_error_func( - move_line, - message=self.msg_store.several_packs_in_location(move_line.location_id), - ) return self._change_pack_lot_change_lot( move_line, lot, response_ok_func, response_error_func ) @@ -53,6 +56,9 @@ def change_lot(self, move_line, lot, response_ok_func, response_error_func): def _change_pack_lot_change_lot( self, move_line, lot, response_ok_func, response_error_func ): + def is_lesser(value, other, rounding): + return float_compare(value, other, precision_rounding=rounding) == -1 + inventory = self.actions_for("inventory") product = move_line.product_id if lot.product_id != product: @@ -61,111 +67,142 @@ def _change_pack_lot_change_lot( ) previous_lot = move_line.lot_id # Changing the lot on the move line updates the reservation on the quants - move_line.lot_id = lot - message = self.msg_store.lot_replaced_by_lot(previous_lot, lot) - # check that we are supposed to have enough of this lot in the source location - quant = lot.quant_ids.filtered(lambda q: q.location_id == move_line.location_id) - if not quant: - # not supposed to have this lot here... (if there is a quant - # but not enough quantity we don't care here: user will report - # a stock issue) - inventory.create_control_stock( - move_line.location_id, - move_line.product_id, - move_line.package_id, - move_line.lot_id, - _("Pick: stock issue on lot: {} found in {}").format( - lot.name, move_line.location_id.name - ), + message_parts = [] + + values = {"lot_id": lot.id} + + available_quantity = self.env["stock.quant"]._get_available_quantity( + product, move_line.location_id, lot_id=lot, strict=True + ) + + if move_line.package_id: + move_line.package_level_id.explode_package() + values["package_id"] = False + + to_assign_moves = self.env["stock.move"] + if float_is_zero( + available_quantity, precision_rounding=product.uom_id.rounding + ): + quants = self.env["stock.quant"]._gather( + product, move_line.location_id, lot_id=lot, strict=True + ) + if quants: + # we have quants but they are all reserved by other lines: + # unreserve the other lines and reserve them again after + unreservable_lines = self.env["stock.move.line"].search( + [ + ("lot_id", "=", lot.id), + ("product_id", "=", product.id), + ("location_id", "=", move_line.location_id.id), + ("qty_done", "=", 0), + ] + ) + if not unreservable_lines: + return response_error_func( + move_line, + message=self.msg_store.cannot_change_lot_already_picked(lot), + ) + available_quantity = sum(unreservable_lines.mapped("product_qty")) + to_assign_moves = unreservable_lines.move_id + # if we leave the package level, it will try to reserve the same + # one again + unreservable_lines.package_level_id.explode_package() + # unreserve qties of other lines + unreservable_lines.unlink() + else: + # * we have *no* quant: + # The lot is not found at all, but the user scanned it, which means + # it's an error in the stock data! To allow the user to continue, + # we post an inventory to add the missing quantity, and a second + # draft inventory to check later + inventory.create_stock_correction( + move_line.move_id, + move_line.location_id, + self.env["stock.quant.package"].browse(), + lot, + move_line.product_qty, + ) + inventory.create_control_stock( + move_line.location_id, + move_line.product_id, + move_line.package_id, + move_line.lot_id, + _("Pick: stock issue on lot: {} found in {}").format( + lot.name, move_line.location_id.name + ), + ) + message_parts.append( + _("A draft inventory has been created for control.") + ) + + # re-evaluate float_is_zero because we may have changed available_quantity + if not float_is_zero( + available_quantity, precision_rounding=product.uom_id.rounding + ) and is_lesser( + available_quantity, move_line.product_qty, product.uom_id.rounding + ): + new_uom_qty = product.uom_id._compute_quantity( + available_quantity, move_line.product_uom_id, rounding_method="HALF-UP" ) - message["body"] += _(" A draft inventory has been created for control.") + values["product_uom_qty"] = new_uom_qty - return response_ok_func(move_line, message=message) + move_line.write(values) - def _package_identical_move_lines_qty(self, package, move_lines): - grouped_quants = {} - for quant in package.quant_ids: - grouped_quants.setdefault(quant.product_id, 0) - grouped_quants[quant.product_id] += quant.quantity + if "product_uom_qty" in values: + # when we change the quantity of the move, the state + # will still be "assigned" and be skipped by "_action_assign", + # recompute the state to be "partially_available" + move_line.move_id._recompute_state() - grouped_lines = {} - for move_line in move_lines: - grouped_lines.setdefault(move_line.product_id, 0) - grouped_lines[move_line.product_id] += move_line.product_uom_qty + # if the new package has less quantities, assign will create new move + # lines + move_line.move_id._action_assign() - return grouped_quants == grouped_lines + # Find other available goods for the lines which were using the + # lot before... + to_assign_moves._action_assign() - def change_package(self, move_line, package, response_ok_func, response_error_func): - inventory = self.actions_for("inventory") + message = self.msg_store.lot_replaced_by_lot(previous_lot, lot) + if message_parts: + message["body"] = "{} {}".format(message["body"], " ".join(message_parts)) + return response_ok_func(move_line, message=message) - package_level = move_line.package_level_id - # several move lines can be moved by the package level, we'll have - # to update all of them - move_lines = package_level.move_line_ids + def _package_content_replacement_allowed(self, package, move_line): + # we can't replace by a package which doesn't contain the product... + return move_line.product_id in package.quant_ids.product_id - # prevent to replace a package by a package with a different content - identical_content = self._package_identical_move_lines_qty(package, move_lines) - if not identical_content: + def change_package(self, move_line, package, response_ok_func, response_error_func): + # prevent to replace a package by a package that would not satisfy the + # move (different product) + content_replacement_allowed = self._package_content_replacement_allowed( + package, move_line + ) + if not content_replacement_allowed: return response_error_func( move_line, message=self.msg_store.package_different_content(package) ) previous_package = move_line.package_id - if package.location_id != move_line.location_id: - # the package has been scanned in the current location so we know its - # a mistake in the data... fix the quant to move the package here - inventory.move_package_quants_to_location(package, move_line.location_id) - - # search a package level which would already move the scanned package - reserved_level = ( - self.env["stock.package_level"].search([("package_id", "=", package.id)]) - # not possible to search on state - .filtered(lambda level: level.state in ("new", "assigned")) - ) - if reserved_level: - reserved_level.ensure_one() - if reserved_level.is_done: - # Not really supposed to happen: if someone sets is_done, the package - # should no longer be here! But we have to check this and inform the - # user in any case. + # /!\ be sure to box the side-effects before calling "replace_package" + # in the savepoint, as we catch the error, we must be sure that any + # change is rollbacked + try: + with self.env.cr.savepoint(): + # if no quantity is available in the package, this call will + # raise a UserError, which will revert the savepoint + move_line.replace_package(package) + except exceptions.UserError as err: return response_error_func( move_line, - message=self.msg_store.package_already_picked_by( - package, reserved_level.picking_id - ), + message=self.msg_store.package_change_error(package, err.name), ) - # Switch the package with the level which was moving it, as we know - # that: - # * only one package level at a time is supposed to move a package - # * the content of the other package is the same (as we checked the - # content is the same as the current move lines) - # * if we left the reserved level with the scanned package, we would - # have 2 levels for the same package and odoo would unreserve the - # move lines as soon as we confirm the current moves - # Considering this, we should be safe to interchange the packages - if reserved_level: - # Ignore updates on quant reservation, which would prevent to switch - # 2 packages between 2 assigned package levels: when writing the - # package of the second level to the first level, it would unreserve - # it because the second level is still using the package. - # But here, we know they both available before and must be available after! - reserved_level.with_context(bypass_reservation_update=True).replace_package( - previous_package - ) - package_level.with_context(bypass_reservation_update=True).replace_package( - package + if previous_package: + message = self.msg_store.package_replaced_by_package( + previous_package, package ) else: - # when we are not switching packages, we expect the quant - # reservations to be aligned - package_level.replace_package(package) - - return response_ok_func( - move_line, - message=self.msg_store.package_replaced_by_package( - previous_package, package - ), - ) + message = self.msg_store.units_replaced_by_package(package) + return response_ok_func(move_line, message=message) diff --git a/shopfloor/actions/inventory.py b/shopfloor/actions/inventory.py index c0a32de3fe..a46bee4bd8 100644 --- a/shopfloor/actions/inventory.py +++ b/shopfloor/actions/inventory.py @@ -52,37 +52,6 @@ def _create_draft_inventory(self, location, product, name): } ) - def move_package_quants_to_location(self, package, dest_location): - """Create inventories to move a package to a different location - - It should be called when the package is - in real life - already in - the destination. It creates an inventory to remove the package from - the source location and a second inventory to place the package - in the destination (to reflect the reality). - - The source location is the current location of the package. - """ - quant_values = [] - # sudo and the key in context activate is_inventory_mode on quants - quants = package.quant_ids.sudo().with_context(inventory_mode=True) - for quant in quants: - quantity = quant.quantity - quant.inventory_quantity = 0 - quant_values.append(self._quant_move_values(quant, dest_location, quantity)) - - quant_model = self.env["stock.quant"].sudo().with_context(inventory_mode=True) - quant_model.create(quant_values) - - def _quant_move_values(self, quant, location, quantity): - return { - "product_id": quant.product_id.id, - "inventory_quantity": quantity, - "location_id": location.id, - "lot_id": quant.lot_id.id, - "package_id": quant.package_id.id, - "owner_id": quant.owner_id.id, - } - def create_control_stock(self, location, product, package, lot, name=None): """Create a draft inventory so a user has to check a location @@ -109,13 +78,17 @@ def create_stock_issue(self, move, location, package, lot): move, location, package, lot ) qty_to_keep = sum(other_lines.mapped("product_qty")) - values = self._stock_issue_inventory_values( - move, location, package, lot, qty_to_keep + self.create_stock_correction(move, location, package, lot, qty_to_keep) + move._action_assign() + + def create_stock_correction(self, move, location, package, lot, quantity): + """Create an inventory with a forced quantity""" + values = self._stock_correction_inventory_values( + move, location, package, lot, quantity ) inventory = self.inventory_model.sudo().create(values) inventory.action_start() inventory.action_validate() - move._action_assign() def _stock_issue_get_related_move_lines(self, move, location, package, lot): """Lookup for all the other moves lines that match given move line""" @@ -128,7 +101,9 @@ def _stock_issue_get_related_move_lines(self, move, location, package, lot): ] return self.env["stock.move.line"].search(domain) - def _stock_issue_inventory_values(self, move, location, package, lot, line_qty): + def _stock_correction_inventory_values( + self, move, location, package, lot, line_qty + ): name = _( "{picking.name} stock correction in location {location.name} " "for {product_desc}" diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index e084794e86..1b5cc7f559 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -432,10 +432,24 @@ def package_already_picked_by(self, package, picking): ).format(package.name, picking.name), } - def lot_is_not_a_package(self, lot): + def units_replaced_by_package(self, new_package): + return { + "message_type": "success", + "body": _("Units replaced by package {}.").format(new_package.name), + } + + def package_change_error(self, package, error_msg): return { "message_type": "error", - "body": _("Lot {} is not a package.").format(lot.name), + "body": _("Package {} cannot be used: {} ").format(package.name, error_msg), + } + + def cannot_change_lot_already_picked(self, lot): + return { + "message_type": "error", + "body": _("Cannot change to lot {} which is entirely picked.").format( + lot.name + ), } def buffer_complete(self): diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py index f089481203..b9146dc8aa 100644 --- a/shopfloor/models/stock_move_line.py +++ b/shopfloor/models/stock_move_line.py @@ -1,4 +1,4 @@ -from odoo import _, fields, models +from odoo import _, exceptions, fields, models from odoo.exceptions import UserError from odoo.tools.float_utils import float_compare @@ -137,3 +137,109 @@ def _split_qty_to_be_done(self, qty_done, split_partial=True, **split_default_va self.with_context(bypass_reservation_update=True).product_uom_qty = qty_done return (new_line, "lesser") return (new_line, "full") + + def replace_package(self, new_package): + """Replace a package on an assigned move line""" + self.ensure_one() + + # search other move lines which should already pick the scanned package + other_reserved_lines = self.env["stock.move.line"].search( + [ + ("package_id", "=", new_package.id), + ("state", "in", ("partially_available", "assigned")), + ] + ) + + # we can't change already picked lines + unreservable_lines = other_reserved_lines.filtered( + lambda line: line.qty_done == 0 + ) + to_assign_moves = unreservable_lines.move_id + + # if we leave the package level, it will try to reserve the same + # one again + unreservable_lines.package_level_id.explode_package() + # unreserve qties of other lines + unreservable_lines.unlink() + + if new_package.location_id != self.location_id: + if new_package.quant_ids.reserved_quantity: + # this is a unexpected condition: if we started picking a package + # in another location, user should never be able to scan it in + # another location, block the operation + raise exceptions.UserError( + _( + "Package {} has been partially picked in another location" + ).format(new_package.display_name) + ) + # the package has been scanned in the current location so we know its + # a mistake in the data... fix the quant to move the package here + new_package.move_package_to_location(self.location_id) + + # several move lines can be moved by the package level, if we change + # the package for the current one, we destroy the package level because + # we are no longer moving the entire package + self.package_level_id.explode_package() + + def is_greater(value, other, rounding): + return float_compare(value, other, precision_rounding=rounding) == 1 + + def is_lesser(value, other, rounding): + return float_compare(value, other, precision_rounding=rounding) == -1 + + quant = fields.first( + new_package.quant_ids.filtered( + lambda quant: quant.product_id == self.product_id + and is_greater( + quant.quantity, + quant.reserved_quantity, + quant.product_uom_id.rounding, + ) + ) + ) + if not quant: + raise exceptions.UserError( + _( + "Package {} does not contain available product {}," + " cannot replace package." + ).format(new_package.display_name, self.product_id.display_name) + ) + + values = { + "package_id": new_package.id, + "lot_id": quant.lot_id.id, + "owner_id": quant.owner_id.id, + "result_package_id": False, + } + + available_quantity = quant.quantity - quant.reserved_quantity + if is_lesser( + available_quantity, self.product_qty, quant.product_uom_id.rounding + ): + new_uom_qty = self.product_id.uom_id._compute_quantity( + available_quantity, self.product_uom_id, rounding_method="HALF-UP" + ) + values["product_uom_qty"] = new_uom_qty + + self.write(values) + + # try reassign the move in case we had a partial qty, also, it will + # recreate a package level if it applies + if "product_uom_qty" in values: + # when we change the quantity of the move, the state + # will still be "assigned" and be skipped by "_action_assign", + # recompute the state to be "partially_available" + self.move_id._recompute_state() + + # if the new package has less quantities, assign will create new move + # lines + self.move_id._action_assign() + + # Find other available goods for the lines which were using the + # package before... + to_assign_moves._action_assign() + + # computation of the 'state' of the package levels is not + # triggered, force it + to_assign_moves.move_line_ids.package_level_id.modified(["move_line_ids"]) + self.package_level_id.modified(["move_line_ids"]) diff --git a/shopfloor/models/stock_package_level.py b/shopfloor/models/stock_package_level.py index a8569cdc3d..84822602e4 100644 --- a/shopfloor/models/stock_package_level.py +++ b/shopfloor/models/stock_package_level.py @@ -5,36 +5,15 @@ class StockPackageLevel(models.Model): _name = "stock.package_level" _inherit = ["stock.package_level", "shopfloor.priority.postpone.mixin"] - def replace_package(self, new_package): - """Replace a package on an assigned package level and related records - - The replacement package must have the same properties (same products - and quantities). - """ - if self.state not in ("new", "assigned"): - return - - move_lines = self.move_line_ids - # the write method on stock.move.line updates the reservation on quants - move_lines.package_id = new_package - # when a package is set on a line, the destination package is the same - # by default - move_lines.result_package_id = new_package - for quant in new_package.quant_ids: - for line in move_lines: - if line.product_id == quant.product_id: - line.lot_id = quant.lot_id - line.owner_id = quant.owner_id - - self.package_id = new_package - def shallow_unlink(self): """Unlink but keep the moves + A package level has a relation to "move_ids" only when the package level was created first from the UI and it created its move. When we unlink a package level, it deletes the move it created. But in some cases, we want to keep the move, e.g.: + * create a package level from the UI to move a package * it generates a move for the matching product quantity * we use a barcode scenario such as cluster or zone picking diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py index b768c06280..f45e08e920 100644 --- a/shopfloor/models/stock_quant_package.py +++ b/shopfloor/models/stock_quant_package.py @@ -43,3 +43,36 @@ def _constrain_name_unique(self): for rec in self: if self.search_count([("name", "=", rec.name), ("id", "!=", rec.id)]): raise exceptions.UserError(_("Package name must be unique!")) + + def move_package_to_location(self, dest_location): + """Create inventories to move a package to a different location + + It should be called when the package is - in real life - already in + the destination. It creates an inventory to remove the package from + the source location and a second inventory to place the package + in the destination (to reflect the reality). + + The source location is the current location of the package. + """ + quant_values = [] + # sudo and the key in context activate is_inventory_mode on quants + quants = self.quant_ids.sudo().with_context(inventory_mode=True) + for quant in quants: + quantity = quant.quantity + quant.inventory_quantity = 0 + quant_values.append( + self._move_package_quant_move_values(quant, dest_location, quantity) + ) + + quant_model = self.env["stock.quant"].sudo().with_context(inventory_mode=True) + quant_model.create(quant_values) + + def _move_package_quant_move_values(self, quant, location, quantity): + return { + "product_id": quant.product_id.id, + "inventory_quantity": quantity, + "location_id": location.id, + "lot_id": quant.lot_id.id, + "package_id": quant.package_id.id, + "owner_id": quant.owner_id.id, + } diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 5edecae6f6..5cfc44e760 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -354,7 +354,11 @@ def _last_picked_line(self, picking): """Get the last line picked and put in a pack for this picking""" return fields.first( picking.move_line_ids.filtered( - lambda l: l.qty_done > 0 and l.result_package_id + lambda l: l.qty_done > 0 + and l.result_package_id + # if we are moving the entire package, we shouldn't + # add stuff inside it, it's not a new package + and l.package_id != l.result_package_id ).sorted(key="write_date", reverse=True) ) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 88e0762ea3..9a92d0c28f 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -869,14 +869,13 @@ def dismiss_package_level(self, location_id, package_level_id): message=self.msg_store.record_not_found(), ) move_lines = package_level.move_line_ids + package_level.explode_package() move_lines.write( { - "result_package_id": False, # ensure all the lines in the package are the next ones to be processed "shopfloor_priority": 1, } ) - package_level.unlink() return self._response_for_start_single( move_lines.mapped("picking_id"), message=self.msg_store.package_open() ) diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 571ed4a34f..575ef7fba1 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -2,6 +2,7 @@ from . import test_menu from . import test_openapi from . import test_profile +from . import test_actions_change_package_lot from . import test_actions_data from . import test_actions_data_detail from . import test_single_pack_transfer diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 7569e5aa4b..383a9d1068 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -92,6 +92,8 @@ def setUpClass(cls): cls.data = work.component(usage="data") with cls.work_on_actions(cls) as work: cls.data_detail = work.component(usage="data_detail") + with cls.work_on_actions(cls) as work: + cls.msg_store = work.component(usage="message") with cls.work_on_services(cls) as work: cls.schema = work.component(usage="schema") with cls.work_on_services(cls) as work: @@ -318,6 +320,32 @@ def _fill_stock_for_moves( location, product, qty, package=package, lot=lot ) + # used by _create_package_in_location + PackageContent = namedtuple( + "PackageContent", + # recordset of the product, + # quantity in float + # recordset of the lot (optional) + "product quantity lot", + ) + + def _create_package_in_location(self, location, content): + """Create a package and quants in a location + + content is a list of PackageContent + """ + package = self.env["stock.quant.package"].create({}) + for product, quantity, lot in content: + self._update_qty_in_location( + location, product, quantity, package=package, lot=lot + ) + return package + + def _create_lot(self, product): + return self.env["stock.production.lot"].create( + {"product_id": product.id, "company_id": self.env.company.id} + ) + class PickingBatchMixin: diff --git a/shopfloor/tests/test_actions_change_package_lot.py b/shopfloor/tests/test_actions_change_package_lot.py new file mode 100644 index 0000000000..4f8f4c0852 --- /dev/null +++ b/shopfloor/tests/test_actions_change_package_lot.py @@ -0,0 +1,1167 @@ +from odoo.tests.common import Form + +from .common import CommonCase + + +class TestActionsChangePackageLot(CommonCase): + """Tests covering changing a package on a move line""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + with cls.work_on_actions(cls) as work: + cls.change_package_lot = work.component(usage="change.package.lot") + + @classmethod + def setUpClassVars(cls): + super().setUpClassVars() + cls.wh = cls.env.ref("stock.warehouse0") + cls.picking_type = cls.wh.out_type_id + + def _create_picking_with_package_level(self, packages): + picking_form = Form(self.env["stock.picking"]) + picking_form.partner_id = self.customer + picking_form.origin = "test" + picking_form.picking_type_id = self.picking_type + picking_form.location_id = self.stock_location + picking_form.location_dest_id = self.packing_location + for package in packages: + with picking_form.package_level_ids_details.new() as move: + move.package_id = package + picking = picking_form.save() + picking.action_confirm() + picking.action_assign() + return picking + + def assert_quant_reserved_qty(self, move_line, qty_func, package=None, lot=None): + domain = [ + ("location_id", "=", move_line.location_id.id), + ("product_id", "=", move_line.product_id.id), + ] + if package: + domain.append(("package_id", "=", package.id)) + if lot: + domain.append(("lot_id", "=", lot.id)) + quant = self.env["stock.quant"].search(domain) + self.assertEqual(quant.reserved_quantity, qty_func()) + + def assert_quant_package_qty(self, location, package, qty_func): + quant = self.env["stock.quant"].search( + [("location_id", "=", location.id), ("package_id", "=", package.id)] + ) + self.assertEqual(quant.quantity, qty_func()) + + def assert_control_stock_inventory(self, location, product, lot): + inventory = self.env["stock.inventory"].search([], order="id desc", limit=1) + self.assertRecordValues( + inventory, + [ + { + "state": "draft", + "product_ids": product.ids, + "name": "Pick: stock issue on lot: {} found in {}".format( + lot.name, location.name + ), + } + ], + ) + + @staticmethod + def unreachable_func(move_line, message=None): + raise AssertionError("should not reach this function") + + def test_change_lot_ok(self): + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + source_location = line.location_id + new_lot = self._create_lot(self.product_a) + # ensure we have our new package in the same location + self._update_qty_in_location(source_location, line.product_id, 10, lot=new_lot) + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + lambda move_line, message=None: self.assertEqual( + message, self.msg_store.lot_replaced_by_lot(initial_lot, new_lot) + ), + # failure callback + self.unreachable_func, + ) + self.assertRecordValues(line, [{"lot_id": new_lot.id}]) + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot) + self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot) + + def test_change_lot_less_quantity_ok(self): + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + source_location = line.location_id + new_lot = self._create_lot(self.product_a) + # ensure we have our new package in the same location + self._update_qty_in_location(source_location, line.product_id, 8, lot=new_lot) + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + lambda move_line, message=None: self.assertEqual( + message, self.msg_store.lot_replaced_by_lot(initial_lot, new_lot) + ), + # failure callback + self.unreachable_func, + ) + self.assertRecordValues(line, [{"lot_id": new_lot.id, "product_qty": 8}]) + other_line = line.move_id.move_line_ids - line + self.assertRecordValues( + other_line, [{"lot_id": initial_lot.id, "product_qty": 2}] + ) + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 2, lot=initial_lot) + self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot) + + def test_change_lot_zero_quant_ok(self): + """No quant in the location for the scanned lot + + As the user scanned it, it's an inventory error. + We expect a new posted inventory that updates the quantity. + And another control one. + """ + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + new_lot = self._create_lot(self.product_a) + expected_message = self.msg_store.lot_replaced_by_lot(initial_lot, new_lot) + expected_message["body"] += " A draft inventory has been created for control." + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + lambda move_line, message=None: self.assertEqual(message, expected_message), + # failure callback + self.unreachable_func, + ) + + self.assertRecordValues(line, [{"lot_id": new_lot.id, "product_qty": 10}]) + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot) + self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot) + + def test_change_lot_package_explode_ok(self): + """Scan a lot on units replacing a package""" + initial_lot = self._create_lot(self.product_a) + package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)] + ) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.assertEqual(line.lot_id, initial_lot) + self.assertEqual(line.package_id, package) + + new_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=new_lot) + expected_message = self.msg_store.lot_replaced_by_lot(initial_lot, new_lot) + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + lambda move_line, message=None: self.assertEqual(message, expected_message), + # failure callback + self.unreachable_func, + ) + + self.assertRecordValues( + line, + [ + { + "lot_id": new_lot.id, + "product_qty": 10, + "package_id": False, + "package_level_id": False, + } + ], + ) + + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot) + self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot) + + def test_change_lot_reserved_qty_ok(self): + """Scan a lot already reserved by other lines + + It should unreserve the other line, use the lot for the current line, + and re-reserve the other move. + """ + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.assertEqual(line.lot_id, initial_lot) + + new_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=new_lot) + picking2 = self._create_picking(lines=[(self.product_a, 10)]) + picking2.action_assign() + line2 = picking2.move_line_ids + self.assertEqual(line2.lot_id, new_lot) + + expected_message = self.msg_store.lot_replaced_by_lot(initial_lot, new_lot) + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + lambda move_line, message=None: self.assertEqual(message, expected_message), + # failure callback + self.unreachable_func, + ) + + self.assertRecordValues(line, [{"lot_id": new_lot.id, "product_qty": 10}]) + # line has been re-created + line2 = picking2.move_line_ids + self.assertRecordValues(line2, [{"lot_id": initial_lot.id, "product_qty": 10}]) + + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot) + self.assert_quant_reserved_qty( + line2, lambda: line2.product_qty, lot=initial_lot + ) + + def test_change_lot_reserved_partial_qty_ok(self): + """Scan a lot already reserved by other lines and can only be reserved + partially + + It should unreserve the other line, use the lot for the current line, + and re-reserve the other move. The quantity for the current line must + be adapted to the available + """ + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.assertEqual(line.lot_id, initial_lot) + + new_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 8, lot=new_lot) + picking2 = self._create_picking(lines=[(self.product_a, 8)]) + picking2.action_assign() + line2 = picking2.move_line_ids + self.assertEqual(line2.lot_id, new_lot) + + expected_message = self.msg_store.lot_replaced_by_lot(initial_lot, new_lot) + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + lambda move_line, message=None: self.assertEqual(message, expected_message), + # failure callback + self.unreachable_func, + ) + + self.assertRecordValues(line, [{"lot_id": new_lot.id, "product_qty": 8}]) + other_line = picking.move_line_ids - line + self.assertRecordValues( + other_line, [{"lot_id": initial_lot.id, "product_qty": 2}] + ) + # line has been re-created + line2 = picking2.move_line_ids + self.assertRecordValues(line2, [{"lot_id": initial_lot.id, "product_qty": 8}]) + + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot) + # both line2 and the line for the 2 remaining will re-reserve the initial lot + self.assert_quant_reserved_qty( + other_line, + lambda: line2.product_qty + other_line.product_qty, + lot=initial_lot, + ) + + def test_change_lot_reserved_qty_done_error(self): + """Scan a lot already reserved by other *picked* lines + + Cannot "steal" lot from picked lines + """ + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.assertEqual(line.lot_id, initial_lot) + + new_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=new_lot) + picking2 = self._create_picking(lines=[(self.product_a, 10)]) + picking2.action_assign() + line2 = picking2.move_line_ids + self.assertEqual(line2.lot_id, new_lot) + line2.qty_done = 10.0 + + expected_message = self.msg_store.cannot_change_lot_already_picked(new_lot) + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + self.unreachable_func, + # failure callback + lambda move_line, message=None: self.assertEqual(message, expected_message), + ) + + # no changes + self.assertRecordValues(line, [{"lot_id": initial_lot.id, "product_qty": 10}]) + self.assertRecordValues( + line2, [{"lot_id": new_lot.id, "product_qty": 10, "qty_done": 10.0}] + ) + self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=initial_lot) + self.assert_quant_reserved_qty(line2, lambda: line2.product_qty, lot=new_lot) + + def test_change_lot_different_location_ok(self): + self.product_a.tracking = "lot" + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + new_lot = self._create_lot(self.product_a) + # ensure we have our new package in a different location + self._update_qty_in_location(self.shelf2, line.product_id, 10, lot=new_lot) + expected_message = self.msg_store.lot_replaced_by_lot(initial_lot, new_lot) + expected_message["body"] += " A draft inventory has been created for control." + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + lambda move_line, message=None: self.assertEqual(message, expected_message), + # failure callback + self.unreachable_func, + ) + + self.assertRecordValues(line, [{"lot_id": new_lot.id}]) + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot) + self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot) + self.assert_control_stock_inventory(self.shelf1, line.product_id, new_lot) + + def test_change_lot_in_several_packages_error(self): + self.product_a.tracking = "lot" + initial_lot = self._create_lot(self.product_a) + self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)] + ) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + # create 2 packages for the same new lot in the same location + new_lot = self._create_lot(self.product_a) + self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, new_lot)] + ) + self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, new_lot)] + ) + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + self.unreachable_func, + # failure callback + lambda move_line, message=None: self.assertEqual( + message, self.msg_store.several_packs_in_location(self.shelf1) + ), + ) + + def test_change_lot_in_package_ok(self): + self.product_a.tracking = "lot" + initial_lot = self._create_lot(self.product_a) + initial_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)] + ) + # ensure we have our new package in the same location + new_lot = self._create_lot(self.product_a) + new_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=new_lot)] + ) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + lambda move_line, message=None: self.assertEqual( + message, + self.msg_store.package_replaced_by_package( + initial_package, new_package + ), + ), + # failure callback + self.unreachable_func, + ) + self.assertRecordValues( + line, + [ + { + "package_id": new_package.id, + "result_package_id": new_package.id, + "lot_id": new_lot.id, + "product_qty": 10.0, + } + ], + ) + self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package) + self.assert_quant_reserved_qty( + line, lambda: line.product_qty, package=new_package + ) + + def test_change_lot_in_package_no_initial_package_ok(self): + self.product_a.tracking = "lot" + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + # ensure we have our new package in the same location + new_lot = self._create_lot(self.product_a) + new_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=new_lot)] + ) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + lambda move_line, message=None: self.assertEqual( + message, self.msg_store.units_replaced_by_package(new_package) + ), + # failure callback + self.unreachable_func, + ) + self.assertRecordValues( + line, + [ + { + "package_id": new_package.id, + "result_package_id": new_package.id, + "lot_id": new_lot.id, + "product_qty": 10.0, + } + ], + ) + self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot) + self.assert_quant_reserved_qty( + line, lambda: line.product_qty, package=new_package + ) + + def test_change_pack_different_content_error(self): + # create the initial package, that will be reserved first + initial_package = self._create_package_in_location( + self.shelf1, + [ + self.PackageContent(self.product_a, 10, lot=None), + self.PackageContent(self.product_b, 10, lot=None), + ], + ) + picking = self._create_picking_with_package_level(initial_package) + # create a new package in the same location + # with a different content + new_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_b, 8, lot=None)] + ) + + lines = picking.move_line_ids + # try to use the new package, which doesn't contain our product, + # cannot be changed + self.change_package_lot.change_package( + lines[0], + new_package, + # success callback + self.unreachable_func, + # failure callback + lambda move_line, message=None: self.assertEqual( + message, self.msg_store.package_different_content(new_package) + ), + ) + + def test_change_pack_multi_content_with_lot(self): + """Switch package for a line which was part of a multi-products package + + We have a move line which is part of a package with more than one + product and the other product is moved by another move line. + + We want to pick the goods for product A in a different package. What + should happen is: + + * the package level is exploded, as we will no longer move the entire + package + * the move line for product A should now use the new package, and be + updated with the lot of the package + * the move line for the other product should keep the other package, if + the user want to change the package for the other product too, they + can do it when they pick it + """ + (self.product_a + self.product_b).tracking = "lot" + # create a package with 2 products tracked by lot, stored in shelf1 + # this package is reserved first on the move line + initial_lot_a = self._create_lot(self.product_a) + initial_lot_b = self._create_lot(self.product_b) + initial_package = self._create_package_in_location( + self.shelf1, + [ + self.PackageContent(self.product_a, 10, initial_lot_a), + self.PackageContent(self.product_b, 10, initial_lot_b), + ], + ) + + # create and reserve our transfer using the initial package + picking = self._create_picking_with_package_level(initial_package) + + lines = picking.move_line_ids + + # create a second package with the same content, which will be used + # as replacement + new_lot_a = self._create_lot(self.product_a) + new_lot_b = self._create_lot(self.product_b) + new_package = self._create_package_in_location( + self.shelf1, + [ + self.PackageContent(self.product_a, 10, new_lot_a), + self.PackageContent(self.product_b, 10, new_lot_b), + ], + ) + line1, line2 = lines + self.change_package_lot.change_package( + line1, + new_package, + # success callback + lambda move_line, message=None: self.assertEqual( + message, + self.msg_store.package_replaced_by_package( + initial_package, new_package + ), + ), + # failure callback + self.unreachable_func, + ) + self.assertRecordValues( + line1, + [ + { + "package_id": new_package.id, + # we are no longer moving an entire package + "result_package_id": False, + "lot_id": new_lot_a.id, + "product_qty": 10.0, + } + ], + ) + self.assertRecordValues( + line2, + [ + { + "package_id": initial_package.id, + # we are no longer moving an entire package + "result_package_id": False, + "lot_id": initial_lot_b.id, + "product_qty": 10.0, + } + ], + ) + # check that reservations have been updated + self.assert_quant_reserved_qty(line1, lambda: 0, package=initial_package) + self.assert_quant_reserved_qty( + line2, lambda: line2.product_qty, package=initial_package + ) + self.assert_quant_reserved_qty( + line1, lambda: line1.product_qty, package=new_package + ) + self.assert_quant_reserved_qty(line2, lambda: 0, package=new_package) + + def test_change_pack_different_location(self): + initial_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + # put a package in shelf2 in the system, but we assume that in real, + # the operator put it in shelf1 + new_package = self._create_package_in_location( + self.shelf2, [self.PackageContent(self.product_a, 10, lot=None)] + ) + + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + # when the operator wants to pick the initial package, in shelf1, the new + # package is in front of the other so they want to change the package + self.change_package_lot.change_package( + line, + new_package, + # success callback + lambda move_line, message=None: self.assertEqual( + message, + self.msg_store.package_replaced_by_package( + initial_package, new_package + ), + ), + # failure callback + self.unreachable_func, + ) + + self.assertRecordValues( + line, [{"package_id": new_package.id, "result_package_id": new_package.id}] + ) + self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) + # check that reservations have been updated, the new package is not + # supposed to be in shelf2 anymore, and we should have no reserved qty + # for the initial package anymore + self.assert_quant_package_qty(self.shelf2, new_package, lambda: 0) + self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package) + self.assert_quant_reserved_qty( + line, lambda: line.product_qty, package=new_package + ) + + def test_change_pack_different_location_reserved_package(self): + initial_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.assertEqual(line.package_id, initial_package) + + # put a package in shelf2 in the system, but we assume that in real, + # the operator put it in shelf1 + new_package = self._create_package_in_location( + self.shelf2, [self.PackageContent(self.product_a, 10, lot=None)] + ) + picking2 = self._create_picking(lines=[(self.product_a, 10)]) + picking2.action_assign() + line2 = picking2.move_line_ids + self.assertEqual(line2.package_id, new_package) + + # When the operator wants to pick the initial package, in shelf1, the new + # package is in front of the other so they want to change the package. + # The new package was supposed to be in shelf2 but is in fact in + # shelf1. + # An inventory must move it in shelf1 before we change the package on the line. + # Line2 must be unreserved and reserved again. + self.change_package_lot.change_package( + line, + new_package, + # success callback + lambda move_line, message=None: self.assertEqual( + message, + self.msg_store.package_replaced_by_package( + initial_package, new_package + ), + ), + # failure callback + self.unreachable_func, + ) + + # line2 has been re-created + line2 = picking2.move_line_ids + self.assertRecordValues( + line + line2, + [ + { + "package_id": new_package.id, + "result_package_id": new_package.id, + "location_id": self.shelf1.id, + "product_qty": 10.0, + }, + { + "package_id": initial_package.id, + "result_package_id": initial_package.id, + "location_id": self.shelf1.id, + "product_qty": 10.0, + }, + ], + ) + self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) + self.assertRecordValues( + line2.package_level_id, [{"package_id": initial_package.id}] + ) + # check that reservations have been updated, the new package is not + # supposed to be in shelf2 anymore, and we should have no reserved qty + # for the initial package anymore + self.assert_quant_package_qty(self.shelf2, new_package, lambda: 0) + self.assert_quant_reserved_qty( + line, lambda: line.product_qty, package=new_package + ) + self.assert_quant_reserved_qty( + line2, lambda: line2.product_qty, package=initial_package + ) + + def test_change_pack_different_location_reserved_package_qty_done(self): + initial_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.assertEqual(line.package_id, initial_package) + + # put a package in shelf2 in the system, but we assume that in real, + # the operator put it in shelf1 + new_package = self._create_package_in_location( + self.shelf2, [self.PackageContent(self.product_a, 10, lot=None)] + ) + picking2 = self._create_picking(lines=[(self.product_a, 10)]) + picking2.action_assign() + line2 = picking2.move_line_ids + self.assertEqual(line2.package_id, new_package) + line2.qty_done = 10.0 + + # The new package was supposed to be in shelf2 but is in fact in shelf1. + # The package has already been picked in shelf2 (unlikely to happen... + # still we have to handle it). Forbid to pick. + expected_message = self.msg_store.package_change_error( + new_package, + "Package {} has been partially picked in another location".format( + new_package.display_name, line.product_id.display_name + ), + ) + self.change_package_lot.change_package( + line, + new_package, + # success callback + self.unreachable_func, + # failure callback + lambda move_line, message=None: self.assertEqual(message, expected_message), + ) + + # line2 has been re-created + line2 = picking2.move_line_ids + self.assertRecordValues( + line + line2, + [ + { + "package_id": initial_package.id, + "result_package_id": initial_package.id, + "location_id": self.shelf1.id, + "product_qty": 10.0, + }, + { + "package_id": new_package.id, + "result_package_id": new_package.id, + "location_id": self.shelf2.id, + "product_qty": 10.0, + }, + ], + ) + # no change + self.assertRecordValues( + line.package_level_id, [{"package_id": initial_package.id}] + ) + self.assertRecordValues( + line2.package_level_id, [{"package_id": new_package.id}] + ) + self.assert_quant_package_qty(self.shelf2, new_package, lambda: 10.0) + self.assert_quant_reserved_qty( + line, lambda: line.product_qty, package=initial_package + ) + self.assert_quant_reserved_qty( + line2, lambda: line2.product_qty, package=new_package + ) + + def test_change_pack_lot_change_pack_less_qty_ok(self): + initial_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 100, lot=None)] + ) + + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + + self.assertRecordValues( + line, + [ + { + "package_id": initial_package.id, + # since we don't move the entire package (10 out of 100), no + # result package + "result_package_id": False, + "product_qty": 10.0, + } + ], + ) + self.assertFalse(line.package_level_id) + + # ensure we have our new package in the same location + new_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + self.change_package_lot.change_package( + line, + new_package, + # success callback + lambda move_line, message=None: self.assertEqual( + message, + self.msg_store.package_replaced_by_package( + initial_package, new_package + ), + ), + # failure callback + self.unreachable_func, + ) + self.assertRecordValues( + line, + [ + { + "package_id": new_package.id, + "result_package_id": new_package.id, + "product_qty": 10.0, + } + ], + ) + self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) + + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package) + self.assert_quant_reserved_qty( + line, lambda: line.product_qty, package=new_package + ) + + def test_change_pack_steal_from_other_move_line(self): + """Exchange pack with another line + + When we scan the package used on another line not picked yet (qty_done + == 0), we unreserve the other line and use its package. The other line + is reserved again and should reserve the package used initially on our + move line. + """ + # create 2 picking, each with its own package + package1 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + picking1 = self._create_picking_with_package_level(package1) + self.assertEqual(picking1.move_line_ids.package_id, package1) + + package2 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + picking2 = self._create_picking_with_package_level(package2) + self.assertEqual(picking2.move_line_ids.package_id, package2) + + line = picking1.move_line_ids + + # We "steal" package2 for the picking1 + self.change_package_lot.change_package( + line, + package2, + # success callback + lambda move_line, message=None: self.assertEqual( + message, self.msg_store.package_replaced_by_package(package1, package2) + ), + # failure callback + self.unreachable_func, + ) + + self.assertRecordValues( + picking1.move_line_ids, + [ + { + "package_id": package2.id, + "result_package_id": package2.id, + "state": "assigned", + "product_qty": 10.0, + } + ], + ) + self.assertRecordValues( + picking2.move_line_ids, + [ + { + "package_id": package1.id, + "result_package_id": package1.id, + "state": "assigned", + "product_qty": 10.0, + } + ], + ) + self.assertRecordValues( + picking1.package_level_ids, + [{"package_id": package2.id, "state": "assigned"}], + ) + self.assertRecordValues( + picking2.package_level_ids, + [{"package_id": package1.id, "state": "assigned"}], + ) + # check that reservations have been updated + self.assert_quant_reserved_qty( + picking1.move_line_ids, + lambda: picking1.move_line_ids.product_qty, + package=package2, + ) + self.assert_quant_reserved_qty( + picking2.move_line_ids, + lambda: picking2.move_line_ids.product_qty, + package=package1, + ) + + def test_other_line_with_qty_done(self): + """Try to exchange pack with other line with qty_done + + When we scan the package used on another line which has been picked + (qty_done > 0), do not unreserve the other line. + """ + # create 2 picking, each with its own package + package1 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + picking1 = self._create_picking_with_package_level(package1) + self.assertEqual(picking1.move_line_ids.package_id, package1) + + package2 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + picking2 = self._create_picking_with_package_level(package2) + self.assertEqual(picking2.move_line_ids.package_id, package2) + + line1 = picking1.move_line_ids + line2 = picking2.move_line_ids + line2.qty_done = 10 + + self.change_package_lot.change_package( + line1, + package2, + # success callback + self.unreachable_func, + # failure callback + lambda move_line, message=None: self.assertEqual( + message, + self.msg_store.package_change_error( + package2, + "Package {} does not contain available product {}," + " cannot replace package.".format( + package2.display_name, line1.product_id.display_name + ), + ), + ), + ) + + # did not change + self.assertRecordValues( + picking1.move_line_ids, + [ + { + "package_id": package1.id, + "result_package_id": package1.id, + "state": "assigned", + } + ], + ) + self.assertRecordValues( + picking2.move_line_ids, + [ + { + "package_id": package2.id, + "result_package_id": package2.id, + "state": "assigned", + } + ], + ) + self.assertRecordValues( + picking1.package_level_ids, + [{"package_id": package1.id, "state": "assigned"}], + ) + self.assertRecordValues( + picking2.package_level_ids, + [{"package_id": package2.id, "state": "assigned"}], + ) + # check that reservations have been updated + self.assert_quant_reserved_qty( + picking1.move_line_ids, + lambda: picking1.move_line_ids.product_qty, + package=package1, + ) + self.assert_quant_reserved_qty( + picking2.move_line_ids, + lambda: picking2.move_line_ids.product_qty, + package=package2, + ) + + def test_package_partial(self): + """Try to exchange pack with a package partially picked + + When we scan the package used on another line which has been picked + (qty_done > 0), but the new package still has unreserved quantity: + + * the current line is updated for the remaining unreserved quantity + * a new line is created for the remaining + * the other already picked line is untouched + """ + # create 2 picking, each with its own package + package1 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + picking1 = self._create_picking_with_package_level(package1) + line1 = picking1.move_line_ids + self.assertEqual(line1.package_id, package1) + + package2 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + + # take partially in package2 (no package level as moving partial + # package) + picking2 = self._create_picking(lines=[(self.product_a, 8)]) + picking2.action_assign() + line2 = picking2.move_line_ids + self.assertEqual(line2.package_id, package2) + + # this line is picked, should not be changed, but we still have + # 2 units in package2 + line2.qty_done = line2.product_qty + + self.change_package_lot.change_package( + line1, + package2, + # success callback + lambda move_line, message=None: self.assertEqual( + message, self.msg_store.package_replaced_by_package(package1, package2) + ), + # failure callback + self.unreachable_func, + ) + + self.assertRecordValues( + line1, + [ + { + "package_id": package2.id, + # not moved entirely by this transfer + "result_package_id": False, + "state": "assigned", + # as the remaining was 2 units, the line is + # changed to take only 2 + "product_qty": 2.0, + } + ], + ) + self.assertRecordValues( + # this line should be unchanged + line2, + [ + { + "package_id": package2.id, + # not moved entirely by this transfer + "result_package_id": False, + "state": "assigned", + "product_qty": 8.0, + } + ], + ) + + # A new line has been created for the quantity the line1 + # couldn't take in package2. It will take the first goods + # available, which happen to be package1 (which was unreserved + # when we changed the package of line1). + remaining_line = picking1.move_line_ids - line1 + self.assertRecordValues( + remaining_line, + [ + { + "package_id": package1.id, + # not moved entirely by this transfer + "result_package_id": False, + "state": "assigned", + # remaining qty for the 1st move + "product_qty": 8.0, + } + ], + ) + + # the package1 must have only 8 reserved, for the remaining + # of the line + self.assertEqual(package1.quant_ids.reserved_quantity, 8) + self.assertEqual(package2.quant_ids.reserved_quantity, 10) + + # no package is moved entirely at once + self.assertFalse(picking1.package_level_ids) + self.assertFalse(picking2.package_level_ids) + + def test_package_2_lines_1_move(self): + """Keep picked move line if we have 2 lines on a move + + Create a situation where we have 2 move lines on a move, with different + packages, 1 one of them is already picked (qty_done > 0), we change the + package on the second one: the first one must not be changed. + """ + package1 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 4, lot=None)] + ) + package2 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 8, lot=None)] + ) + + # take partially in package2 (no package level as moving partial + # package) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + move = picking.move_lines + line1, line2 = move.move_line_ids + self.assertEqual(line1.package_id, package1) + self.assertEqual(line2.package_id, package2) + + # package to switch to + package3 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 8, lot=None)] + ) + + # this line is picked and must not be changed + line1.qty_done = line1.product_qty + + # as we change for package2, the line should get only the remaining + # part of the package + + self.change_package_lot.change_package( + line2, + package3, + # success callback + lambda move_line, message=None: self.assertEqual( + message, self.msg_store.package_replaced_by_package(package2, package3) + ), + # failure callback + self.unreachable_func, + ) + + self.assertRecordValues( + line1 | line2, + [ + { + "package_id": package1.id, + "state": "assigned", + "product_qty": 4.0, + "qty_done": 4.0, + }, + { + "package_id": package3.id, + "state": "assigned", + "product_qty": 6.0, + "qty_done": 0.0, + }, + ], + ) + + # package1 is moved entirely + self.assertTrue(line1.package_level_id) + # package2 is not moved entirely + self.assertFalse(line2.package_level_id) + + # the package1 must have only 8 reserved, for the remaining + # of the line + self.assertEqual(package1.quant_ids.reserved_quantity, 4) + self.assertEqual(package2.quant_ids.reserved_quantity, 0) + self.assertEqual(package3.quant_ids.reserved_quantity, 6) diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py index 1f924f10b3..190b8f9c41 100644 --- a/shopfloor/tests/test_cluster_picking_change_pack_lot.py +++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py @@ -1,36 +1,18 @@ -from collections import namedtuple - -from odoo.tests.common import Form - from .test_cluster_picking_base import ClusterPickingCommonCase -class ClusterPickingChangePackLotCommon(ClusterPickingCommonCase): - - # used by _create_package_in_location - PackageContent = namedtuple( - "PackageContent", - # recordset of the product, - # quantity in float - # recordset of the lot (optional) - "product quantity lot", - ) - - def _create_package_in_location(self, location, content): - """Create a package and quants in a location +class ClusterPickingChangePackLotCase(ClusterPickingCommonCase): + """Tests covering the /change_pack_lot endpoint - content is a list of PackageContent - """ - package = self.env["stock.quant.package"].create({}) - for product, quantity, lot in content: - self._update_qty_in_location( - location, product, quantity, package=package, lot=lot - ) - return package + Only simple cases are tested to check the flow of responses on success and + error, the "change.package.lot" component is tested in its own tests. + """ - def _create_lot(self, product): - return self.env["stock.production.lot"].create( - {"product_id": product.id, "company_id": self.env.company.id} + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.batch = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=10)]] ) def _test_change_pack_lot(self, line, barcode, success=True, message=None): @@ -57,62 +39,8 @@ def _test_change_pack_lot(self, line, barcode, success=True, message=None): next_state="change_pack_lot", data=self._line_data(line), ) - - def _skip_line(self, line, next_line=None): - batch = line.picking_id.batch_id - response = self.service.dispatch( - "skip_line", params={"picking_batch_id": batch.id, "move_line_id": line.id} - ) - if next_line: - self.assert_response( - response, next_state="start_line", data=self._line_data(next_line) - ) return response - def assert_quant_reserved_qty(self, move_line, qty_func, package=None, lot=None): - domain = [ - ("location_id", "=", move_line.location_id.id), - ("product_id", "=", move_line.product_id.id), - ] - if package: - domain.append(("package_id", "=", package.id)) - if lot: - domain.append(("lot_id", "=", lot.id)) - quant = self.env["stock.quant"].search(domain) - self.assertEqual(quant.reserved_quantity, qty_func()) - - def assert_quant_package_qty(self, location, package, qty_func): - quant = self.env["stock.quant"].search( - [("location_id", "=", location.id), ("package_id", "=", package.id)] - ) - self.assertEqual(quant.quantity, qty_func()) - - def assert_control_stock_inventory(self, location, product, lot): - inventory = self.env["stock.inventory"].search([], order="id desc", limit=1) - self.assertRecordValues( - inventory, - [ - { - "state": "draft", - "product_ids": product.ids, - "name": "Pick: stock issue on lot: {} found in {}".format( - lot.name, location.name - ), - }, - ], - ) - - -class ClusterPickingChangePackLotCase(ClusterPickingChangePackLotCommon): - """Tests covering the /change_pack_lot endpoint""" - - @classmethod - def setUpClassBaseData(cls, *args, **kwargs): - super().setUpClassBaseData(*args, **kwargs) - cls.batch = cls._create_picking_batch( - [[cls.BatchProduct(product=cls.product_a, quantity=10)]] - ) - def test_change_pack_lot_change_pack_ok(self): initial_package = self._create_package_in_location( self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] @@ -134,138 +62,17 @@ def test_change_pack_lot_change_pack_ok(self): ), ) - self.assertRecordValues( - line, [{"package_id": new_package.id, "result_package_id": new_package.id}] - ) - - self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) - # check that reservations have been updated - self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package) - self.assert_quant_reserved_qty( - line, lambda: line.product_qty, package=new_package - ) - - def test_change_pack_lot_change_pack_different_location(self): - initial_package = self._create_package_in_location( - self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] - ) - - # initial_package from shelf1 will be selected in our move line - self._simulate_batch_selected(self.batch, fill_stock=False) - - # put a package in shelf2 in the system, but we assume that in real, - # the operator put it in shelf1 - new_package = self._create_package_in_location( - self.shelf2, [self.PackageContent(self.product_a, 10, lot=None)] - ) - - line = self.batch.picking_ids.move_line_ids - # when the operator wants to pick the initial package, in shelf1, the new - # package is in front of the other so they want to change the package - self._test_change_pack_lot( - line, - new_package.name, - success=True, - message=self.service.msg_store.package_replaced_by_package( - initial_package, new_package - ), - ) - - self.assertRecordValues( - line, [{"package_id": new_package.id, "result_package_id": new_package.id}] - ) - self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) - # check that reservations have been updated, the new package is not - # supposed to be in shelf2 anymore, and we should have no reserved qty - # for the initial package anymore - self.assert_quant_package_qty(self.shelf2, new_package, lambda: 0) - self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package) - self.assert_quant_reserved_qty( - line, lambda: line.product_qty, package=new_package - ) - - def test_change_pack_lot_change_lot_in_package_ok(self): - self.product_a.tracking = "lot" - initial_lot = self._create_lot(self.product_a) - initial_package = self._create_package_in_location( - self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)] - ) - self._simulate_batch_selected(self.batch, fill_stock=False) - # ensure we have our new package in the same location - new_lot = self._create_lot(self.product_a) - new_package = self._create_package_in_location( - self.shelf1, [self.PackageContent(self.product_a, 10, lot=new_lot)] - ) - line = self.batch.picking_ids.move_line_ids - self._test_change_pack_lot( - line, - new_lot.name, - success=True, - message=self.service.msg_store.package_replaced_by_package( - initial_package, new_package - ), - ) - self.assertRecordValues( line, [ { "package_id": new_package.id, "result_package_id": new_package.id, - "lot_id": new_lot.id, + "product_qty": 10.0, } ], ) self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) - # check that reservations have been updated - self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package) - self.assert_quant_reserved_qty( - line, lambda: line.product_qty, package=new_package - ) - - def test_change_pack_lot_change_lot_in_several_packages_error(self): - self.product_a.tracking = "lot" - initial_lot = self._create_lot(self.product_a) - self._create_package_in_location( - self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)] - ) - self._simulate_batch_selected(self.batch, fill_stock=False) - line = self.batch.picking_ids.move_line_ids - # create 2 packages for the same new lot in the same location - new_lot = self._create_lot(self.product_a) - self._create_package_in_location( - self.shelf1, [self.PackageContent(self.product_a, 10, new_lot)] - ) - self._create_package_in_location( - self.shelf1, [self.PackageContent(self.product_a, 10, new_lot)] - ) - self._test_change_pack_lot( - line, - new_lot.name, - success=False, - message=self.service.msg_store.several_packs_in_location(self.shelf1), - ) - - def test_change_pack_lot_change_lot_from_package_error(self): - # we shouldn't be allowed to replace a package by a lot - # if the lot is not a package in the quants (because we - # could then replace a package by a single unit) - self.product_a.tracking = "lot" - initial_lot = self._create_lot(self.product_a) - self._create_package_in_location( - self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)] - ) - self._simulate_batch_selected(self.batch, fill_stock=False) - line = self.batch.picking_ids.move_line_ids - # create a lot and put a unit in the location without package - new_lot = self._create_lot(self.product_a) - self._update_qty_in_location(self.shelf1, line.product_id, 1, lot=new_lot) - self._test_change_pack_lot( - line, - new_lot.name, - success=False, - message=self.service.msg_store.lot_is_not_a_package(new_lot), - ) def test_change_pack_lot_change_lot_ok(self): initial_lot = self._create_lot(self.product_a) @@ -282,226 +89,17 @@ def test_change_pack_lot_change_lot_ok(self): success=True, message=self.service.msg_store.lot_replaced_by_lot(initial_lot, new_lot), ) - self.assertRecordValues(line, [{"lot_id": new_lot.id}]) - # check that reservations have been updated - self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot) - self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot) - def test_change_pack_lot_change_lot_different_location_ok(self): - self.product_a.tracking = "lot" + def test_change_pack_lot_change_error(self): initial_lot = self._create_lot(self.product_a) self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) self._simulate_batch_selected(self.batch, fill_stock=False) line = self.batch.picking_ids.move_line_ids - new_lot = self._create_lot(self.product_a) - # ensure we have our new package in a different location - self._update_qty_in_location(self.shelf2, line.product_id, 10, lot=new_lot) - message = self.service.msg_store.lot_replaced_by_lot(initial_lot, new_lot) - message["body"] += " A draft inventory has been created for control." - self._test_change_pack_lot( - line, new_lot.name, success=True, message=message, - ) - - self.assertRecordValues(line, [{"lot_id": new_lot.id}]) - # check that reservations have been updated - self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot) - self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot) - self.assert_control_stock_inventory(self.shelf1, line.product_id, new_lot) - - -class ClusterPickingChangePackLotCaseSpecial(ClusterPickingChangePackLotCommon): - """Tests covering the /change_pack_lot endpoint - - Special cases where we use a custom batch transfer - """ - - def _create_picking_with_package_level(self, packages): - picking_form = Form(self.env["stock.picking"]) - picking_form.partner_id = self.customer - picking_form.origin = "test" - picking_form.picking_type_id = self.picking_type - picking_form.location_id = self.stock_location - picking_form.location_dest_id = self.packing_location - for package in packages: - with picking_form.package_level_ids_details.new() as move: - move.package_id = package - picking = picking_form.save() - picking.action_confirm() - picking.action_assign() - return picking - - def _create_batch_with_pickings(self, pickings): - batch_form = Form(self.env["stock.picking.batch"]) - for picking in pickings: - batch_form.picking_ids.add(picking) - batch = batch_form.save() - return batch - - def test_change_pack_lot_change_pack_different_content_error(self): - # create the initial package, that will be reserved first - initial_package = self._create_package_in_location( - self.shelf1, - [ - self.PackageContent(self.product_a, 10, lot=None), - self.PackageContent(self.product_b, 10, lot=None), - ], - ) - picking = self._create_picking_with_package_level(initial_package) - batch = self._create_batch_with_pickings(picking) - self._simulate_batch_selected(batch, fill_stock=False) - - # create a new package in the same location - # with a different content - new_package = self._create_package_in_location( - self.shelf1, - [ - self.PackageContent(self.product_a, 10, lot=None), - self.PackageContent(self.product_b, 8, lot=None), - ], - ) - - lines = batch.picking_ids.move_line_ids - # try to use the new package, which has a different content, - # not accepted - self._test_change_pack_lot( - lines[0], - new_package.name, - success=False, - message=self.service.msg_store.package_different_content(new_package), - ) - - def test_change_pack_lot_change_pack_multi_content_with_lot(self): - (self.product_a + self.product_b).tracking = "lot" - # create a package with 2 products tracked by lot, stored in shelf1 - # this package is reserved first on the move line - initial_lot_a = self._create_lot(self.product_a) - initial_lot_b = self._create_lot(self.product_b) - initial_package = self._create_package_in_location( - self.shelf1, - [ - self.PackageContent(self.product_a, 10, initial_lot_a), - self.PackageContent(self.product_b, 10, initial_lot_b), - ], - ) - - # create and reserve our transfer using the initial package - picking = self._create_picking_with_package_level(initial_package) - batch = self._create_batch_with_pickings(picking) - self._simulate_batch_selected(batch, fill_stock=False) - - lines = picking.move_line_ids - package_level = lines.mapped("package_level_id") - - # create a second package with the same content, which will be used - # as replacement - new_lot_a = self._create_lot(self.product_a) - new_lot_b = self._create_lot(self.product_b) - new_package = self._create_package_in_location( - self.shelf1, - [ - self.PackageContent(self.product_a, 10, new_lot_a), - self.PackageContent(self.product_b, 10, new_lot_b), - ], - ) - # changing the package of the first line will change all of them - self._test_change_pack_lot( - lines[0], - new_package.name, - success=True, - message=self.service.msg_store.package_replaced_by_package( - initial_package, new_package - ), - ) - - self.assertRecordValues( - lines, - [ - { - "package_id": new_package.id, - "result_package_id": new_package.id, - "lot_id": new_lot_a.id, - }, - { - "package_id": new_package.id, - "result_package_id": new_package.id, - "lot_id": new_lot_b.id, - }, - ], - ) - - self.assertRecordValues(package_level, [{"package_id": new_package.id}]) - # check that reservations have been updated - for line in lines: - self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package) - self.assert_quant_reserved_qty( - line, lambda: line.product_qty, package=new_package - ) - - def test_change_pack_lot_change_pack_steal_from_other_move_line(self): - # create 2 picking, each with its own package - package1 = self._create_package_in_location( - self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)], - ) - picking1 = self._create_picking_with_package_level(package1) - self.assertEqual(picking1.move_line_ids.package_id, package1) - - package2 = self._create_package_in_location( - self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)], - ) - picking2 = self._create_picking_with_package_level(package2) - self.assertEqual(picking2.move_line_ids.package_id, package2) - - batch = self._create_batch_with_pickings(picking1 + picking2) - self._simulate_batch_selected(batch, fill_stock=False) - - line = picking1.move_line_ids - # We "steal" package2 for the picking1 + # ensure we have our new package in the same location self._test_change_pack_lot( line, - package2.name, - success=True, - message=self.service.msg_store.package_replaced_by_package( - package1, package2 - ), - ) - - self.assertRecordValues( - picking1.move_line_ids, - [ - { - "package_id": package2.id, - "result_package_id": package2.id, - "state": "assigned", - } - ], - ) - self.assertRecordValues( - picking2.move_line_ids, - [ - { - "package_id": package1.id, - "result_package_id": package1.id, - "state": "assigned", - } - ], - ) - self.assertRecordValues( - picking1.package_level_ids, - [{"package_id": package2.id, "state": "assigned"}], - ) - self.assertRecordValues( - picking2.package_level_ids, - [{"package_id": package1.id, "state": "assigned"}], - ) - # check that reservations have been updated - self.assert_quant_reserved_qty( - picking1.move_line_ids, - lambda: picking1.move_line_ids.product_qty, - package=package2, - ) - self.assert_quant_reserved_qty( - picking2.move_line_ids, - lambda: picking2.move_line_ids.product_qty, - package=package1, + "NOT_FOUND", + success=False, + message=self.service.msg_store.no_package_or_lot_for_barcode("NOT_FOUND"), ) diff --git a/shopfloor/tests/test_zone_picking_change_pack_lot.py b/shopfloor/tests/test_zone_picking_change_pack_lot.py index 06f6047342..8818f68391 100644 --- a/shopfloor/tests/test_zone_picking_change_pack_lot.py +++ b/shopfloor/tests/test_zone_picking_change_pack_lot.py @@ -6,6 +6,8 @@ class ZonePickingChangePackLotCase(ZonePickingCommonCase): * /change_pack_lot + Only simple cases are tested to check the flow of responses on success and + error, the "change.package.lot" component is tested in its own tests. """ def test_change_pack_lot_wrong_parameters(self): @@ -190,69 +192,3 @@ def test_change_pack_lot_change_lot_ok(self): previous_lot, self.free_lot ), ) - - def test_change_pack_lot_change_lot_ok_with_control_stock(self): - zone_location = self.zone_location - picking_type = self.picking2.picking_type_id - move_line = self.picking2.move_line_ids[0] - previous_lot = move_line.lot_id - self.free_lot.product_id = move_line.product_id - # ensure we have our new lot but in another location - self._update_qty_in_location( - self.zone_sublocation1, - move_line.product_id, - move_line.product_uom_qty, - lot=self.free_lot, - ) - # change lot - response = self.service.dispatch( - "change_pack_lot", - params={ - "zone_location_id": zone_location.id, - "picking_type_id": picking_type.id, - "move_line_id": move_line.id, - "barcode": self.free_lot.name, - }, - ) - # check data - self.assertRecordValues(move_line, [{"lot_id": self.free_lot.id}]) - # check that reservations could not be made as the lot is - # theoretically elsewhere - previous_quant = self.env["stock.quant"].search( - [ - ("location_id", "=", move_line.location_id.id), - ("product_id", "=", move_line.product_id.id), - ("lot_id", "=", previous_lot.id), - ] - ) - self.assertEqual(previous_quant.quantity, 10) - self.assertEqual(previous_quant.reserved_quantity, 0) - new_quant = self.env["stock.quant"].search( - [ - ("location_id", "=", move_line.location_id.id), - ("product_id", "=", move_line.product_id.id), - ("lot_id", "=", self.free_lot.id), - ] - ) - self.assertFalse(new_quant) - # as such an inventory of control has been generated to check this issue - control_inventory_name = "Pick: stock issue on lot: {} found in {}".format( - self.free_lot.name, move_line.location_id.name - ) - control_inventory = self.env["stock.inventory"].search( - [ - ("name", "=", control_inventory_name), - ("location_ids", "in", move_line.location_id.id), - ("product_ids", "in", move_line.product_id.id), - ("state", "in", ("draft", "confirm")), - ] - ) - self.assertTrue(control_inventory) - # check response - message = self.service.msg_store.lot_replaced_by_lot( - previous_lot, self.free_lot - ) - message["body"] += " A draft inventory has been created for control." - self.assert_response_set_line_destination( - response, zone_location, picking_type, move_line, message=message, - ) From 4ecf94e2d074fbb302d9412dc6bd4c0aa20e7411 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 9 Oct 2020 15:58:15 +0200 Subject: [PATCH 387/940] shopfloor: add readme --- shopfloor/readme/CONFIGURE.rst | 23 ++++++++++++++++++++++- shopfloor/readme/CONTRIBUTORS.rst | 12 ++++++++++-- shopfloor/readme/CREDITS.rst | 5 +++++ shopfloor/readme/DESCRIPTION.rst | 15 ++++++++++++++- shopfloor/readme/ROADMAP.rst | 1 - 5 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 shopfloor/readme/CREDITS.rst diff --git a/shopfloor/readme/CONFIGURE.rst b/shopfloor/readme/CONFIGURE.rst index fe0cc8cdee..059a7dda31 100644 --- a/shopfloor/readme/CONFIGURE.rst +++ b/shopfloor/readme/CONFIGURE.rst @@ -1,4 +1,25 @@ -writeme +Profiles +-------- + +In Inventory / Configuration / Shopfloor / Profiles. + +The profiles are used to restrict which menus are shown on the frontend +application. When a user logs in the scanner application, they have to +select their profile, so the correct menus are shown. + +Menus +----- + +In Inventory / Configuration / Shopfloor / Menus. + +The menus are displayed on the frontend application and store the configuration +of the scenarios. Each menu must use a scenario and defines which Operation Types +they are allowed to process. + +Their profile will restrict the visibility to the profile chosen on the device. +If a menu has no profile, it is shown in every profile. + +Some scenarios may have additional options, which are explained in tooltips. Logs retention -------------- diff --git a/shopfloor/readme/CONTRIBUTORS.rst b/shopfloor/readme/CONTRIBUTORS.rst index 6fc648282a..4d4d24ed1b 100644 --- a/shopfloor/readme/CONTRIBUTORS.rst +++ b/shopfloor/readme/CONTRIBUTORS.rst @@ -1,4 +1,12 @@ -* Alexandre Fayolle * Guewen Baconnier +* Simone Orsi +* Sébastien Alix +* Alexandre Fayolle +* Benoit Guillot +* Thierry Ducrest + +Design +~~~~~~ - ADD YOURSELF +* Joël Grand-Guillaume +* Jacques-Etienne Baudoux diff --git a/shopfloor/readme/CREDITS.rst b/shopfloor/readme/CREDITS.rst new file mode 100644 index 0000000000..dd789bcc35 --- /dev/null +++ b/shopfloor/readme/CREDITS.rst @@ -0,0 +1,5 @@ +**Financial support** + +* Cosanum +* Camptocamp R&D +* Akretion R&D diff --git a/shopfloor/readme/DESCRIPTION.rst b/shopfloor/readme/DESCRIPTION.rst index 756a0db67d..6242bc91fc 100644 --- a/shopfloor/readme/DESCRIPTION.rst +++ b/shopfloor/readme/DESCRIPTION.rst @@ -1 +1,14 @@ -write me +Shopfloor is a barcode scanner application for internal warehouse operations. + +The application supports scenarios, to relate to Operation Types: + +* Cluster Picking +* Zone Picking +* Checkout/Packing +* Delivery +* Location Content Transfer +* Single Pack Transfer + +This module provides REST APIs to support the scenarios. It needs a frontend +to consume the backend APIs and provide screens for users on barcode devices. +A default front-end application is provided by ``shopfloor_mobile``. diff --git a/shopfloor/readme/ROADMAP.rst b/shopfloor/readme/ROADMAP.rst index 756a0db67d..e69de29bb2 100644 --- a/shopfloor/readme/ROADMAP.rst +++ b/shopfloor/readme/ROADMAP.rst @@ -1 +0,0 @@ -write me From 06ad3d5e4c16286453e5c45216f706a2705ecf2e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 9 Oct 2020 16:24:17 +0200 Subject: [PATCH 388/940] shopfloor: add copyrights --- shopfloor/__manifest__.py | 4 +++- shopfloor/actions/base_action.py | 2 ++ shopfloor/actions/change_package_lot.py | 2 ++ shopfloor/actions/completion_info.py | 2 ++ shopfloor/actions/data.py | 2 ++ shopfloor/actions/data_detail.py | 2 ++ shopfloor/actions/inventory.py | 2 ++ shopfloor/actions/location_content_transfer_sorter.py | 2 ++ shopfloor/actions/message.py | 2 ++ shopfloor/actions/savepoint.py | 2 ++ shopfloor/actions/search.py | 2 ++ shopfloor/controllers/main.py | 3 +++ shopfloor/migrations/13.0.1.1.0/pre-migration.py | 2 ++ shopfloor/models/priority_postpone_mixin.py | 2 ++ shopfloor/models/res_partner.py | 2 ++ shopfloor/models/shopfloor_log.py | 2 ++ shopfloor/models/shopfloor_menu.py | 2 ++ shopfloor/models/shopfloor_profile.py | 2 ++ shopfloor/models/stock_inventory.py | 2 ++ shopfloor/models/stock_location.py | 2 ++ shopfloor/models/stock_move.py | 2 ++ shopfloor/models/stock_move_line.py | 2 ++ shopfloor/models/stock_package_level.py | 2 ++ shopfloor/models/stock_picking.py | 2 ++ shopfloor/models/stock_picking_batch.py | 2 ++ shopfloor/models/stock_picking_type.py | 2 ++ shopfloor/models/stock_quant.py | 2 ++ shopfloor/models/stock_quant_package.py | 2 ++ shopfloor/services/app.py | 2 ++ shopfloor/services/checkout.py | 2 ++ shopfloor/services/cluster_picking.py | 2 ++ shopfloor/services/delivery.py | 2 ++ shopfloor/services/location_content_transfer.py | 2 ++ shopfloor/services/menu.py | 2 ++ shopfloor/services/picking_batch.py | 2 ++ shopfloor/services/profile.py | 2 ++ shopfloor/services/scan_anything.py | 2 ++ shopfloor/services/schema.py | 2 ++ shopfloor/services/schema_detail.py | 2 ++ shopfloor/services/service.py | 3 +++ shopfloor/services/single_pack_transfer.py | 3 +++ shopfloor/services/validator.py | 2 ++ shopfloor/services/zone_picking.py | 2 ++ shopfloor/tests/common.py | 2 ++ shopfloor/tests/test_actions_change_package_lot.py | 2 ++ shopfloor/tests/test_actions_data.py | 2 ++ shopfloor/tests/test_actions_data_detail.py | 2 ++ shopfloor/tests/test_app.py | 2 ++ shopfloor/tests/test_checkout_base.py | 2 ++ shopfloor/tests/test_checkout_cancel_line.py | 2 ++ shopfloor/tests/test_checkout_change_packaging.py | 2 ++ shopfloor/tests/test_checkout_done.py | 2 ++ shopfloor/tests/test_checkout_list_package.py | 2 ++ shopfloor/tests/test_checkout_new_package.py | 2 ++ shopfloor/tests/test_checkout_no_package.py | 2 ++ shopfloor/tests/test_checkout_scan.py | 2 ++ shopfloor/tests/test_checkout_scan_line.py | 2 ++ shopfloor/tests/test_checkout_scan_package_action.py | 2 ++ shopfloor/tests/test_checkout_select.py | 2 ++ shopfloor/tests/test_checkout_select_line.py | 2 ++ shopfloor/tests/test_checkout_select_package_base.py | 4 ++++ shopfloor/tests/test_checkout_set_qty.py | 3 +++ shopfloor/tests/test_checkout_summary.py | 3 +++ shopfloor/tests/test_cluster_picking_base.py | 3 +++ shopfloor/tests/test_cluster_picking_batch.py | 3 +++ shopfloor/tests/test_cluster_picking_change_pack_lot.py | 3 +++ shopfloor/tests/test_cluster_picking_scan.py | 3 +++ shopfloor/tests/test_cluster_picking_select.py | 3 +++ shopfloor/tests/test_cluster_picking_skip.py | 3 +++ shopfloor/tests/test_cluster_picking_stock_issue.py | 3 +++ shopfloor/tests/test_cluster_picking_unload.py | 3 +++ shopfloor/tests/test_delivery_base.py | 3 +++ shopfloor/tests/test_delivery_scan_deliver.py | 3 +++ shopfloor/tests/test_location_content_transfer_base.py | 3 +++ .../test_location_content_transfer_set_destination_all.py | 3 +++ shopfloor/tests/test_location_content_transfer_single.py | 3 +++ shopfloor/tests/test_location_content_transfer_start.py | 3 +++ shopfloor/tests/test_menu.py | 3 +++ shopfloor/tests/test_misc.py | 3 +++ shopfloor/tests/test_move_action_assign.py | 3 +++ shopfloor/tests/test_openapi.py | 3 +++ shopfloor/tests/test_profile.py | 3 +++ shopfloor/tests/test_scan_anything.py | 3 +++ shopfloor/tests/test_single_pack_transfer.py | 4 ++++ shopfloor/tests/test_single_pack_transfer_base.py | 3 +++ shopfloor/tests/test_stock_split.py | 2 ++ shopfloor/tests/test_zone_picking_base.py | 2 ++ shopfloor/tests/test_zone_picking_change_pack_lot.py | 2 ++ shopfloor/tests/test_zone_picking_select_line.py | 2 ++ shopfloor/tests/test_zone_picking_select_picking_type.py | 2 ++ shopfloor/tests/test_zone_picking_set_line_destination.py | 2 ++ shopfloor/tests/test_zone_picking_start.py | 2 ++ shopfloor/tests/test_zone_picking_stock_issue.py | 2 ++ shopfloor/tests/test_zone_picking_unload_all.py | 2 ++ shopfloor/tests/test_zone_picking_unload_set_destination.py | 2 ++ shopfloor/tests/test_zone_picking_unload_single.py | 2 ++ shopfloor/tests/test_zone_picking_zero_check.py | 2 ++ 97 files changed, 225 insertions(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index b1d12b54fb..899cf0321b 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -1,4 +1,6 @@ -# © 2020 Camptocamp, Akretion, BCIM +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020 Akretion (http://www.akretion.com) +# Copyright 2020 BCIM # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). { diff --git a/shopfloor/actions/base_action.py b/shopfloor/actions/base_action.py index b094c2c812..fb7eb2ae12 100644 --- a/shopfloor/actions/base_action.py +++ b/shopfloor/actions/base_action.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo.addons.component.core import AbstractComponent diff --git a/shopfloor/actions/change_package_lot.py b/shopfloor/actions/change_package_lot.py index 2537cea5b5..c21e8e92e8 100644 --- a/shopfloor/actions/change_package_lot.py +++ b/shopfloor/actions/change_package_lot.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _, exceptions from odoo.tools.float_utils import float_compare, float_is_zero diff --git a/shopfloor/actions/completion_info.py b/shopfloor/actions/completion_info.py index 16cab4a0cf..a8fec75f2e 100644 --- a/shopfloor/actions/completion_info.py +++ b/shopfloor/actions/completion_info.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _ from odoo.addons.component.core import Component diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 5603890e76..5301f90245 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import fields from odoo.addons.component.core import Component diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py index 4e25abbe42..2bef510cbf 100644 --- a/shopfloor/actions/data_detail.py +++ b/shopfloor/actions/data_detail.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo.tools.float_utils import float_round from odoo.addons.component.core import Component diff --git a/shopfloor/actions/inventory.py b/shopfloor/actions/inventory.py index a46bee4bd8..f3f54f9770 100644 --- a/shopfloor/actions/inventory.py +++ b/shopfloor/actions/inventory.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _ from odoo.addons.component.core import Component diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py index 49b45430dc..1ad4aa1f8b 100644 --- a/shopfloor/actions/location_content_transfer_sorter.py +++ b/shopfloor/actions/location_content_transfer_sorter.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo.addons.component.core import Component diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 1b5cc7f559..e600f00c4e 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _ from odoo.addons.component.core import Component diff --git a/shopfloor/actions/savepoint.py b/shopfloor/actions/savepoint.py index 9e031de542..96daa8fe61 100644 --- a/shopfloor/actions/savepoint.py +++ b/shopfloor/actions/savepoint.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import uuid from psycopg2 import sql diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py index a757257583..922df08040 100644 --- a/shopfloor/actions/search.py +++ b/shopfloor/actions/search.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo.addons.component.core import Component diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py index 3f04afa240..86676e235d 100644 --- a/shopfloor/controllers/main.py +++ b/shopfloor/controllers/main.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020 Akretion (http://www.akretion.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from werkzeug.exceptions import BadRequest from odoo.http import request diff --git a/shopfloor/migrations/13.0.1.1.0/pre-migration.py b/shopfloor/migrations/13.0.1.1.0/pre-migration.py index 8255f2c535..ef555c78c0 100644 --- a/shopfloor/migrations/13.0.1.1.0/pre-migration.py +++ b/shopfloor/migrations/13.0.1.1.0/pre-migration.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from psycopg2 import sql from odoo.tools import column_exists diff --git a/shopfloor/models/priority_postpone_mixin.py b/shopfloor/models/priority_postpone_mixin.py index 41fa877961..80832f6d7e 100644 --- a/shopfloor/models/priority_postpone_mixin.py +++ b/shopfloor/models/priority_postpone_mixin.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import api, fields, models diff --git a/shopfloor/models/res_partner.py b/shopfloor/models/res_partner.py index b9a8986af0..35111600bb 100644 --- a/shopfloor/models/res_partner.py +++ b/shopfloor/models/res_partner.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import fields, models diff --git a/shopfloor/models/shopfloor_log.py b/shopfloor/models/shopfloor_log.py index a17e9a1432..9a0401cedd 100644 --- a/shopfloor/models/shopfloor_log.py +++ b/shopfloor/models/shopfloor_log.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import logging from datetime import datetime, timedelta diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 0a16a91ab5..9429a8c142 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _, api, exceptions, fields, models diff --git a/shopfloor/models/shopfloor_profile.py b/shopfloor/models/shopfloor_profile.py index eb6b6e1309..2d490b3b3f 100644 --- a/shopfloor/models/shopfloor_profile.py +++ b/shopfloor/models/shopfloor_profile.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import api, fields, models diff --git a/shopfloor/models/stock_inventory.py b/shopfloor/models/stock_inventory.py index 3581a18dce..2e23d7b324 100644 --- a/shopfloor/models/stock_inventory.py +++ b/shopfloor/models/stock_inventory.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import models diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py index 9641cec1a0..bb4f74f099 100644 --- a/shopfloor/models/stock_location.py +++ b/shopfloor/models/stock_location.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import fields, models from odoo.tools.float_utils import float_compare diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py index f1690dd182..2794cf4449 100644 --- a/shopfloor/models/stock_move.py +++ b/shopfloor/models/stock_move.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _, models diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py index b9146dc8aa..bd8b2c3de3 100644 --- a/shopfloor/models/stock_move_line.py +++ b/shopfloor/models/stock_move_line.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _, exceptions, fields, models from odoo.exceptions import UserError from odoo.tools.float_utils import float_compare diff --git a/shopfloor/models/stock_package_level.py b/shopfloor/models/stock_package_level.py index 84822602e4..8dd4aea84a 100644 --- a/shopfloor/models/stock_package_level.py +++ b/shopfloor/models/stock_package_level.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import models diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py index f42bed1277..35cb047cc9 100644 --- a/shopfloor/models/stock_picking.py +++ b/shopfloor/models/stock_picking.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import api, fields, models diff --git a/shopfloor/models/stock_picking_batch.py b/shopfloor/models/stock_picking_batch.py index 07bf57a099..f16aa11da1 100644 --- a/shopfloor/models/stock_picking_batch.py +++ b/shopfloor/models/stock_picking_batch.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import api, fields, models diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py index 600fa3e58b..2525db4aaa 100644 --- a/shopfloor/models/stock_picking_type.py +++ b/shopfloor/models/stock_picking_type.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import api, fields, models diff --git a/shopfloor/models/stock_quant.py b/shopfloor/models/stock_quant.py index 77c6667359..d20af1278e 100644 --- a/shopfloor/models/stock_quant.py +++ b/shopfloor/models/stock_quant.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import models diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py index f45e08e920..8f7ee23462 100644 --- a/shopfloor/models/stock_quant_package.py +++ b/shopfloor/models/stock_quant_package.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _, api, exceptions, fields, models diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py index d9dcab6ef9..d0d2be4704 100644 --- a/shopfloor/services/app.py +++ b/shopfloor/services/app.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo.addons.component.core import Component diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index a4753fb0de..88ff30d3c9 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _ from odoo.addons.base_rest.components.service import to_int diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 5cfc44e760..ed3a1b77e7 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _, fields from odoo.osv import expression diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index debbea4126..74cf09bce6 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _, fields from odoo.osv import expression from odoo.tools.float_utils import float_is_zero diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 9a92d0c28f..4e296d82a8 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _ from odoo.addons.base_rest.components.service import to_int diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index 3d9bad9d98..3fce4f2182 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo.osv import expression from odoo.addons.base_rest.components.service import to_int diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py index 44de8eb7e8..836efe4cbd 100644 --- a/shopfloor/services/picking_batch.py +++ b/shopfloor/services/picking_batch.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo.osv import expression from odoo.addons.component.core import Component diff --git a/shopfloor/services/profile.py b/shopfloor/services/profile.py index af9b9d2841..55d2297937 100644 --- a/shopfloor/services/profile.py +++ b/shopfloor/services/profile.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component diff --git a/shopfloor/services/scan_anything.py b/shopfloor/services/scan_anything.py index de8b24dc1f..a943fc5c65 100644 --- a/shopfloor/services/scan_anything.py +++ b/shopfloor/services/scan_anything.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _ from odoo.addons.component.core import Component diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 4d8742930b..bb9071e9d7 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo.addons.component.core import Component diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py index 077df97344..f57d40e33f 100644 --- a/shopfloor/services/schema_detail.py +++ b/shopfloor/services/schema_detail.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo.addons.component.core import Component diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 9cc6c7a8d4..6bce31321b 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020 Akretion (http://www.akretion.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import traceback from werkzeug.urls import url_encode, url_join diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 0af66ebb2d..e264be477a 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020 Akretion (http://www.akretion.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import fields from odoo.addons.base_rest.components.service import to_int diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py index 21c46847de..b26d011375 100644 --- a/shopfloor/services/validator.py +++ b/shopfloor/services/validator.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo.addons.component.core import AbstractComponent diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 602e387cfc..8201eaec44 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import functools from odoo.fields import first diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 383a9d1068..88bddd0ba2 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from collections import namedtuple from contextlib import contextmanager from pprint import pformat diff --git a/shopfloor/tests/test_actions_change_package_lot.py b/shopfloor/tests/test_actions_change_package_lot.py index 4f8f4c0852..967729f044 100644 --- a/shopfloor/tests/test_actions_change_package_lot.py +++ b/shopfloor/tests/test_actions_change_package_lot.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo.tests.common import Form from .common import CommonCase diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 43bff8b1e3..6215ea5e8e 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import logging from .common import CommonCase, PickingBatchMixin diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index 22821ee0a5..1595c0af85 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import base64 import io diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py index d45965861e..11088781ad 100644 --- a/shopfloor/tests/test_app.py +++ b/shopfloor/tests/test_app.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .common import CommonCase diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index 25cbdc1dc5..4c6ffe65c7 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .common import CommonCase diff --git a/shopfloor/tests/test_checkout_cancel_line.py b/shopfloor/tests/test_checkout_cancel_line.py index 108df0e03c..a18adba265 100644 --- a/shopfloor/tests/test_checkout_cancel_line.py +++ b/shopfloor/tests/test_checkout_cancel_line.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_checkout_base import CheckoutCommonCase diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py index f9488e6714..b3bcf36aa6 100644 --- a/shopfloor/tests/test_checkout_change_packaging.py +++ b/shopfloor/tests/test_checkout_change_packaging.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_checkout_base import CheckoutCommonCase diff --git a/shopfloor/tests/test_checkout_done.py b/shopfloor/tests/test_checkout_done.py index c5ff9e9240..2b675ed565 100644 --- a/shopfloor/tests/test_checkout_done.py +++ b/shopfloor/tests/test_checkout_done.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_checkout_base import CheckoutCommonCase diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py index fe81fbb366..e32fcf16d6 100644 --- a/shopfloor/tests/test_checkout_list_package.py +++ b/shopfloor/tests/test_checkout_list_package.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_checkout_base import CheckoutCommonCase from .test_checkout_select_package_base import CheckoutSelectPackageMixin diff --git a/shopfloor/tests/test_checkout_new_package.py b/shopfloor/tests/test_checkout_new_package.py index 7e62c0f3f0..a6a038f72f 100644 --- a/shopfloor/tests/test_checkout_new_package.py +++ b/shopfloor/tests/test_checkout_new_package.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_checkout_base import CheckoutCommonCase from .test_checkout_select_package_base import CheckoutSelectPackageMixin diff --git a/shopfloor/tests/test_checkout_no_package.py b/shopfloor/tests/test_checkout_no_package.py index f997ccad18..7242077825 100644 --- a/shopfloor/tests/test_checkout_no_package.py +++ b/shopfloor/tests/test_checkout_no_package.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_checkout_base import CheckoutCommonCase from .test_checkout_select_package_base import CheckoutSelectPackageMixin diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py index 672bfec403..1af8473da2 100644 --- a/shopfloor/tests/test_checkout_scan.py +++ b/shopfloor/tests/test_checkout_scan.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_checkout_base import CheckoutCommonCase diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py index 94b4b5d890..bedc37a8bf 100644 --- a/shopfloor/tests/test_checkout_scan_line.py +++ b/shopfloor/tests/test_checkout_scan_line.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_checkout_base import CheckoutCommonCase from .test_checkout_select_package_base import CheckoutSelectPackageMixin diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py index 26ab339947..948e335840 100644 --- a/shopfloor/tests/test_checkout_scan_package_action.py +++ b/shopfloor/tests/test_checkout_scan_package_action.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from itertools import product from .test_checkout_base import CheckoutCommonCase diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py index 3458b76e06..60968810b1 100644 --- a/shopfloor/tests/test_checkout_select.py +++ b/shopfloor/tests/test_checkout_select.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_checkout_base import CheckoutCommonCase diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py index c84d5bed9d..477fed1f3f 100644 --- a/shopfloor/tests/test_checkout_select_line.py +++ b/shopfloor/tests/test_checkout_select_line.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_checkout_base import CheckoutCommonCase from .test_checkout_select_package_base import CheckoutSelectPackageMixin diff --git a/shopfloor/tests/test_checkout_select_package_base.py b/shopfloor/tests/test_checkout_select_package_base.py index 845c6d8df0..151beb76e2 100644 --- a/shopfloor/tests/test_checkout_select_package_base.py +++ b/shopfloor/tests/test_checkout_select_package_base.py @@ -1,3 +1,7 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + class CheckoutSelectPackageMixin: def _assert_selected_response( self, response, selected_lines, message=None, packing_info=False diff --git a/shopfloor/tests/test_checkout_set_qty.py b/shopfloor/tests/test_checkout_set_qty.py index 99229c7e0d..b49b1aed5e 100644 --- a/shopfloor/tests/test_checkout_set_qty.py +++ b/shopfloor/tests/test_checkout_set_qty.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .test_checkout_base import CheckoutCommonCase from .test_checkout_select_package_base import CheckoutSelectPackageMixin diff --git a/shopfloor/tests/test_checkout_summary.py b/shopfloor/tests/test_checkout_summary.py index b7d71cc271..6fd3636f26 100644 --- a/shopfloor/tests/test_checkout_summary.py +++ b/shopfloor/tests/test_checkout_summary.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .test_checkout_base import CheckoutCommonCase diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py index 2fd44ecb46..093322dc1f 100644 --- a/shopfloor/tests/test_cluster_picking_base.py +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .common import CommonCase, PickingBatchMixin diff --git a/shopfloor/tests/test_cluster_picking_batch.py b/shopfloor/tests/test_cluster_picking_batch.py index b7a2e915fc..fc95b63af7 100644 --- a/shopfloor/tests/test_cluster_picking_batch.py +++ b/shopfloor/tests/test_cluster_picking_batch.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .common import CommonCase, PickingBatchMixin diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py index 190b8f9c41..966816fbaf 100644 --- a/shopfloor/tests/test_cluster_picking_change_pack_lot.py +++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .test_cluster_picking_base import ClusterPickingCommonCase diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index 5f550f7205..000aeb6b99 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .test_cluster_picking_base import ( ClusterPickingCommonCase, ClusterPickingLineCommonCase, diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py index 1b86cdf690..48c6d9f60e 100644 --- a/shopfloor/tests/test_cluster_picking_select.py +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .test_cluster_picking_base import ClusterPickingCommonCase diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py index a0054f741e..bb28881c70 100644 --- a/shopfloor/tests/test_cluster_picking_skip.py +++ b/shopfloor/tests/test_cluster_picking_skip.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .test_cluster_picking_base import ClusterPickingCommonCase diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py index 130319cea7..eb878075f5 100644 --- a/shopfloor/tests/test_cluster_picking_stock_issue.py +++ b/shopfloor/tests/test_cluster_picking_stock_issue.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .test_cluster_picking_base import ClusterPickingCommonCase diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index 1df4f0e293..6616b1cb52 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .test_cluster_picking_base import ClusterPickingCommonCase diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py index 4fd5097dc9..a17f2f37fe 100644 --- a/shopfloor/tests/test_delivery_base.py +++ b/shopfloor/tests/test_delivery_base.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .common import CommonCase diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py index 97524254bf..c1aa38a00a 100644 --- a/shopfloor/tests/test_delivery_scan_deliver.py +++ b/shopfloor/tests/test_delivery_scan_deliver.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .test_delivery_base import DeliveryCommonCase diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py index 40e24385c5..f4076b1bfe 100644 --- a/shopfloor/tests/test_location_content_transfer_base.py +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .common import CommonCase diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_all.py b/shopfloor/tests/test_location_content_transfer_set_destination_all.py index 93cd3d80bc..5edc6ad50b 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_all.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .test_location_content_transfer_base import LocationContentTransferCommonCase diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py index cdbf6efc49..74f4ecc1f5 100644 --- a/shopfloor/tests/test_location_content_transfer_single.py +++ b/shopfloor/tests/test_location_content_transfer_single.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .test_location_content_transfer_base import LocationContentTransferCommonCase diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py index 62c4a6f694..20c3e7da07 100644 --- a/shopfloor/tests/test_location_content_transfer_start.py +++ b/shopfloor/tests/test_location_content_transfer_start.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .test_location_content_transfer_base import LocationContentTransferCommonCase diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py index 31ef016042..5ec40e40d7 100644 --- a/shopfloor/tests/test_menu.py +++ b/shopfloor/tests/test_menu.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .common import CommonCase diff --git a/shopfloor/tests/test_misc.py b/shopfloor/tests/test_misc.py index 3e29f16b8b..9955e75393 100644 --- a/shopfloor/tests/test_misc.py +++ b/shopfloor/tests/test_misc.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from odoo import exceptions from odoo.tests.common import SavepointCase diff --git a/shopfloor/tests/test_move_action_assign.py b/shopfloor/tests/test_move_action_assign.py index 7f387f7d6b..5b8b386ba1 100644 --- a/shopfloor/tests/test_move_action_assign.py +++ b/shopfloor/tests/test_move_action_assign.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .common import CommonCase diff --git a/shopfloor/tests/test_openapi.py b/shopfloor/tests/test_openapi.py index 913324c4a5..561b040072 100644 --- a/shopfloor/tests/test_openapi.py +++ b/shopfloor/tests/test_openapi.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .common import CommonCase diff --git a/shopfloor/tests/test_profile.py b/shopfloor/tests/test_profile.py index a3eb4489a5..52189578f7 100644 --- a/shopfloor/tests/test_profile.py +++ b/shopfloor/tests/test_profile.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .common import CommonCase diff --git a/shopfloor/tests/test_scan_anything.py b/shopfloor/tests/test_scan_anything.py index 87128304e0..e1d20ac132 100644 --- a/shopfloor/tests/test_scan_anything.py +++ b/shopfloor/tests/test_scan_anything.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from .test_actions_data_detail import ActionsDataDetailCaseBase diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index 9057f51755..e3d5916709 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -1,3 +1,7 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020 Akretion (http://www.akretion.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from odoo.tests.common import Form from .test_single_pack_transfer_base import SinglePackTransferCommonBase diff --git a/shopfloor/tests/test_single_pack_transfer_base.py b/shopfloor/tests/test_single_pack_transfer_base.py index 8f04301fc9..d512d10bcc 100644 --- a/shopfloor/tests/test_single_pack_transfer_base.py +++ b/shopfloor/tests/test_single_pack_transfer_base.py @@ -1,3 +1,6 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020 Akretion (http://www.akretion.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .common import CommonCase diff --git a/shopfloor/tests/test_stock_split.py b/shopfloor/tests/test_stock_split.py index a0554d6ea1..af2c925d5c 100644 --- a/shopfloor/tests/test_stock_split.py +++ b/shopfloor/tests/test_stock_split.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo.tests import tagged from odoo.tests.common import SavepointCase diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 948ec31161..95d59c2b8e 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .common import CommonCase diff --git a/shopfloor/tests/test_zone_picking_change_pack_lot.py b/shopfloor/tests/test_zone_picking_change_pack_lot.py index 8818f68391..bfcca57407 100644 --- a/shopfloor/tests/test_zone_picking_change_pack_lot.py +++ b/shopfloor/tests/test_zone_picking_change_pack_lot.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_zone_picking_base import ZonePickingCommonCase diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py index 614cd6a3ff..30818b6c34 100644 --- a/shopfloor/tests/test_zone_picking_select_line.py +++ b/shopfloor/tests/test_zone_picking_select_line.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_zone_picking_base import ZonePickingCommonCase diff --git a/shopfloor/tests/test_zone_picking_select_picking_type.py b/shopfloor/tests/test_zone_picking_select_picking_type.py index a07ee6be9b..76b999d929 100644 --- a/shopfloor/tests/test_zone_picking_select_picking_type.py +++ b/shopfloor/tests/test_zone_picking_select_picking_type.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_zone_picking_base import ZonePickingCommonCase diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py index 33fc91473c..3099b76eb8 100644 --- a/shopfloor/tests/test_zone_picking_set_line_destination.py +++ b/shopfloor/tests/test_zone_picking_set_line_destination.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_zone_picking_base import ZonePickingCommonCase diff --git a/shopfloor/tests/test_zone_picking_start.py b/shopfloor/tests/test_zone_picking_start.py index c8a6fc7933..0f7d2a61d3 100644 --- a/shopfloor/tests/test_zone_picking_start.py +++ b/shopfloor/tests/test_zone_picking_start.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_zone_picking_base import ZonePickingCommonCase diff --git a/shopfloor/tests/test_zone_picking_stock_issue.py b/shopfloor/tests/test_zone_picking_stock_issue.py index 3767f02905..88e66e8f2d 100644 --- a/shopfloor/tests/test_zone_picking_stock_issue.py +++ b/shopfloor/tests/test_zone_picking_stock_issue.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_zone_picking_base import ZonePickingCommonCase diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py index 05c6f8e16e..38e0632339 100644 --- a/shopfloor/tests/test_zone_picking_unload_all.py +++ b/shopfloor/tests/test_zone_picking_unload_all.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_zone_picking_base import ZonePickingCommonCase diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py index a27e4356af..2e63b6ec43 100644 --- a/shopfloor/tests/test_zone_picking_unload_set_destination.py +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_zone_picking_base import ZonePickingCommonCase diff --git a/shopfloor/tests/test_zone_picking_unload_single.py b/shopfloor/tests/test_zone_picking_unload_single.py index e6b67d7889..03f72b41a4 100644 --- a/shopfloor/tests/test_zone_picking_unload_single.py +++ b/shopfloor/tests/test_zone_picking_unload_single.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_zone_picking_base import ZonePickingCommonCase diff --git a/shopfloor/tests/test_zone_picking_zero_check.py b/shopfloor/tests/test_zone_picking_zero_check.py index dba09b2c70..49738d8366 100644 --- a/shopfloor/tests/test_zone_picking_zero_check.py +++ b/shopfloor/tests/test_zone_picking_zero_check.py @@ -1,3 +1,5 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from .test_zone_picking_base import ZonePickingCommonCase From 1a27860c2014146f965de5251bd06cc0783b9bd1 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 12 Oct 2020 10:00:10 +0200 Subject: [PATCH 389/940] Update ROADMAP.rst --- shopfloor/readme/ROADMAP.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shopfloor/readme/ROADMAP.rst b/shopfloor/readme/ROADMAP.rst index e69de29bb2..7f274ec28f 100644 --- a/shopfloor/readme/ROADMAP.rst +++ b/shopfloor/readme/ROADMAP.rst @@ -0,0 +1,2 @@ +* improve documentation +* split out scenario components to their own modules From cdd3ca82b765f36c377f1cdd9f180d819f2ec30a Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 13 Oct 2020 10:07:25 +0200 Subject: [PATCH 390/940] shopfloor: fix linting From de14c208d8c58d7ab821c1ed17de94c61d204dd3 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 13 Oct 2020 16:50:24 +0200 Subject: [PATCH 391/940] shopfloor: update manifest and README --- shopfloor/README.rst | 203 ++++++++- shopfloor/__manifest__.py | 5 +- shopfloor/readme/CONFIGURE.rst | 6 +- shopfloor/readme/USAGE.rst | 2 +- shopfloor/static/description/index.html | 530 ++++++++++++++++++++++++ 5 files changed, 723 insertions(+), 23 deletions(-) create mode 100644 shopfloor/static/description/index.html diff --git a/shopfloor/README.rst b/shopfloor/README.rst index 4adf2c2512..12590ac995 100644 --- a/shopfloor/README.rst +++ b/shopfloor/README.rst @@ -1,24 +1,193 @@ -**This file is going to be generated by oca-gen-addon-readme.** +========= +Shopfloor +========= -*Manual changes will be overwritten.* +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwms-lightgray.png?logo=github + :target: https://github.com/OCA/wms/tree/13.0/shopfloor + :alt: OCA/wms +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/285/13.0 + :alt: Try me on Runbot -TODO +|badge1| |badge2| |badge3| |badge4| |badge5| -Please provide content in the ``readme`` directory: +Shopfloor is a barcode scanner application for internal warehouse operations. -* **DESCRIPTION.rst** (required) -* INSTALL.rst (optional) -* CONFIGURE.rst (optional) -* **USAGE.rst** (optional, highly recommended) -* DEVELOP.rst (optional) -* ROADMAP.rst (optional) -* HISTORY.rst (optional, recommended) -* **CONTRIBUTORS.rst** (optional, highly recommended) -* CREDITS.rst (optional) +The application supports scenarios, to relate to Operation Types: -Content of this README will also be drawn from the addon manifest, -from keys such as name, authors, maintainers, development_status, -and license. +* Cluster Picking +* Zone Picking +* Checkout/Packing +* Delivery +* Location Content Transfer +* Single Pack Transfer -A good, one sentence summary in the manifest is also highly recommended. +This module provides REST APIs to support the scenarios. It needs a frontend +to consume the backend APIs and provide screens for users on barcode devices. +A default front-end application is provided by ``shopfloor_mobile``. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Profiles +~~~~~~~~ + +In Inventory / Configuration / Shopfloor / Profiles. + +The profiles are used to restrict which menus are shown on the frontend +application. When a user logs in the scanner application, they have to +select their profile, so the correct menus are shown. + +Menus +~~~~~ + +In Inventory / Configuration / Shopfloor / Menus. + +The menus are displayed on the frontend application and store the configuration +of the scenarios. Each menu must use a scenario and defines which Operation Types +they are allowed to process. + +Their profile will restrict the visibility to the profile chosen on the device. +If a menu has no profile, it is shown in every profile. + +Some scenarios may have additional options, which are explained in tooltips. + +Logs retention +~~~~~~~~~~~~~~ + +Logs are kept in database for every REST requests made by a client application. +They can be used for debugging and monitoring of the activity. + +The Logs menu is shown only with Developer tools (``?debug=1``) activated. + +By default, Shopfloor logs are kept 30 days. +You can change the duration of the retention by changing the System Parameter +``shopfloor.log.retention.days``. + +If the value is set to 0, the logs are not stored at all. + +Logged data is: request URL and method, parameters, headers, result or error. + +Usage +===== + +An API key is created in the Demo data (for development), using +the Demo user. The key to use in the HTTP header ``API-KEY`` is: 72B044F7AC780DAC + +Curl example:: + + curl -X POST "http://localhost:8069/shopfloor/app/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC" + +Known issues / Roadmap +====================== + +* improve documentation +* split out scenario components to their own modules + +Changelog +========= + +13.0.1.0.0 +~~~~~~~~~~ + +First official version. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp +* BCIM +* Akretion + +Contributors +~~~~~~~~~~~~ + +* Guewen Baconnier +* Simone Orsi +* Sébastien Alix +* Alexandre Fayolle +* Benoit Guillot +* Thierry Ducrest + +Design +~~~~~~ + +* Joël Grand-Guillaume +* Jacques-Etienne Baudoux + +Other credits +~~~~~~~~~~~~~ + +**Financial support** + +* Cosanum +* Camptocamp R&D +* Akretion R&D + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-guewen| image:: https://github.com/guewen.png?size=40px + :target: https://github.com/guewen + :alt: guewen +.. |maintainer-simahawk| image:: https://github.com/simahawk.png?size=40px + :target: https://github.com/simahawk + :alt: simahawk +.. |maintainer-sebalix| image:: https://github.com/sebalix.png?size=40px + :target: https://github.com/sebalix + :alt: sebalix + +Current `maintainers `__: + +|maintainer-guewen| |maintainer-simahawk| |maintainer-sebalix| + +This module is part of the `OCA/wms `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 899cf0321b..6a90cb2e79 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -9,8 +9,9 @@ "version": "13.0.1.1.0", "development_status": "Alpha", "category": "Inventory", - "website": "https://odoo-community.org", - "author": "Akretion, BCIM, Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/wms", + "author": "Camptocamp, BCIM, Akretion, Odoo Community Association (OCA)", + "maintainers": ["guewen", "simahawk", "sebalix"], "license": "AGPL-3", "application": True, "depends": [ diff --git a/shopfloor/readme/CONFIGURE.rst b/shopfloor/readme/CONFIGURE.rst index 059a7dda31..92102a8b42 100644 --- a/shopfloor/readme/CONFIGURE.rst +++ b/shopfloor/readme/CONFIGURE.rst @@ -1,5 +1,5 @@ Profiles --------- +~~~~~~~~ In Inventory / Configuration / Shopfloor / Profiles. @@ -8,7 +8,7 @@ application. When a user logs in the scanner application, they have to select their profile, so the correct menus are shown. Menus ------ +~~~~~ In Inventory / Configuration / Shopfloor / Menus. @@ -22,7 +22,7 @@ If a menu has no profile, it is shown in every profile. Some scenarios may have additional options, which are explained in tooltips. Logs retention --------------- +~~~~~~~~~~~~~~ Logs are kept in database for every REST requests made by a client application. They can be used for debugging and monitoring of the activity. diff --git a/shopfloor/readme/USAGE.rst b/shopfloor/readme/USAGE.rst index 2d5767567e..d628404b12 100644 --- a/shopfloor/readme/USAGE.rst +++ b/shopfloor/readme/USAGE.rst @@ -3,4 +3,4 @@ the Demo user. The key to use in the HTTP header ``API-KEY`` is: 72B044F7AC780DA Curl example:: - curl -X POST "http://localhost:8069/shopfloor/shopfloor/get_pack" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC" -d "{\"pack_name\":\"string\"}" + curl -X POST "http://localhost:8069/shopfloor/app/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC" diff --git a/shopfloor/static/description/index.html b/shopfloor/static/description/index.html new file mode 100644 index 0000000000..62e8f7eade --- /dev/null +++ b/shopfloor/static/description/index.html @@ -0,0 +1,530 @@ + + + + + + +Shopfloor + + + +
+

Shopfloor

+ + +

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 c111bec9df470c45a3639ba2e02d907db763aadd Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 13 Oct 2020 17:46:40 +0200 Subject: [PATCH 392/940] shopfloor: fix permission in tests --- shopfloor/tests/common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 88bddd0ba2..df5aafd7d2 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -365,9 +365,9 @@ def _create_picking_batch(cls, products): :param products: list of list of BatchProduct. The outer list creates pickings and the innerr list creates moves in these pickings """ - batch_form = Form(cls.env["stock.picking.batch"]) + batch_form = Form(cls.env["stock.picking.batch"].sudo()) for transfer in products: - picking_form = Form(cls.env["stock.picking"]) + picking_form = Form(cls.env["stock.picking"].sudo()) picking_form.picking_type_id = cls.picking_type picking_form.location_id = cls.stock_location picking_form.location_dest_id = cls.packing_location From 6b51c416a588c8948b96a4f1f15091aff295003f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 19 Oct 2020 10:52:49 +0200 Subject: [PATCH 393/940] backend: save the form only when all moves have been added --- shopfloor/tests/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index df5aafd7d2..0eb6612b5b 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -379,7 +379,7 @@ def _create_picking_batch(cls, products): with picking_form.move_ids_without_package.new() as move: move.product_id = product move.product_uom_qty = quantity - picking = picking_form.save() + picking = picking_form.save() batch_form.picking_ids.add(picking) batch = batch_form.save() From be5295bb2746a74e219612048fd7991ac6b86a0a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 19 Oct 2020 14:57:52 +0200 Subject: [PATCH 394/940] delivery: fix failing test When we call the "done" endpoint and a backorder is created, the test ensure the backorder is out of scope: nothing in the "done" endpoint ensures that the backorder is assigned; it calls "picking.action_done()": this check is testing the behavior of "action_done" which is supposed to be tested in the stock module. Actually, this condition currently fails because of another module (probably depends of the configuration), but this is not what we want to test here. --- shopfloor/tests/test_delivery_done.py | 1 - 1 file changed, 1 deletion(-) diff --git a/shopfloor/tests/test_delivery_done.py b/shopfloor/tests/test_delivery_done.py index e2ef593719..8c49038db3 100644 --- a/shopfloor/tests/test_delivery_done.py +++ b/shopfloor/tests/test_delivery_done.py @@ -100,5 +100,4 @@ def test_done_some_qty_done_confirm(self): self.assertEqual(self.picking.move_lines, self.raw_move) backorder = self.picking.backorder_ids self.assertTrue(backorder) - self.assertEqual(backorder.state, "assigned") self.assertEqual(self.pack1_moves.picking_id, backorder) From 7b07e52ba06c1df9d6c8c8b60152fb344fb65364 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 20 Oct 2020 10:33:32 +0200 Subject: [PATCH 395/940] backend: fix flaky tests --- shopfloor/tests/test_cluster_picking_scan.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index 000aeb6b99..03dc024de0 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -296,8 +296,12 @@ def setUpClassBaseData(cls, *args, **kwargs): [cls.BatchProduct(product=cls.product_a, quantity=10)], ] ) - cls.one_line_picking = cls.batch.picking_ids[0] - cls.two_lines_picking = cls.batch.picking_ids[1] + cls.one_line_picking = cls.batch.picking_ids.filtered( + lambda picking: len(picking.move_lines) == 1 + ) + cls.two_lines_picking = cls.batch.picking_ids.filtered( + lambda picking: len(picking.move_lines) == 2 + ) cls.bin1 = cls.env["stock.quant.package"].create({}) cls.bin2 = cls.env["stock.quant.package"].create({}) From 745a1f778c6ec1904ea4152c2402bd0e71cb0f36 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 20 Oct 2020 10:56:20 +0200 Subject: [PATCH 396/940] backend: fix more flaky tests --- .../tests/test_cluster_picking_unload.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index 6616b1cb52..dcd379f6f7 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -158,14 +158,22 @@ def test_set_destination_all_ok(self): def test_set_destination_all_remaining_lines(self): """Set destination on all lines for a part of the batch""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") + one_line_picking = self.batch.picking_ids.filtered( + lambda picking: len(picking.move_lines) == 1 + ) + two_lines_picking = self.batch.picking_ids.filtered( + lambda picking: len(picking.move_lines) == 2 + ) + move_lines = one_line_picking.move_line_ids + two_lines_picking.move_line_ids # Put destination packages, the whole quantity on lines and a similar # destination (when /set_destination_all is called, all the lines to # unload must have the same destination). # However, we keep a line without qty_done and destination package, # so when the dest location is set, the endpoint should route back # to the 'start_line' state to work on the remaining line. - lines_to_unload = move_lines[:2] + lines_to_unload = ( + one_line_picking.move_line_ids + two_lines_picking.move_line_ids[0] + ) self._set_dest_package_and_done(lines_to_unload, self.bin1) lines_to_unload.write({"location_dest_id": self.packing_location.id}) @@ -179,8 +187,6 @@ def test_set_destination_all_remaining_lines(self): # Since the whole batch is not complete, state should not be done. # The picking with one line should be "done" because we unloaded its line. # The second one still has a line to pick. - one_line_picking = self.batch.picking_ids[0] - two_lines_picking = self.batch.picking_ids[1] self.assertRecordValues(one_line_picking, [{"state": "done"}]) self.assertRecordValues(two_lines_picking, [{"state": "assigned"}]) self.assertRecordValues( @@ -468,8 +474,12 @@ def setUpClassBaseData(cls, *args, **kwargs): cls._set_dest_package_and_done(cls.bin2_lines, cls.bin2) cls.bin1_lines.write({"location_dest_id": cls.packing_a_location.id}) cls.bin2_lines.write({"location_dest_id": cls.packing_b_location.id}) - cls.one_line_picking = cls.batch.picking_ids[0] - cls.two_lines_picking = cls.batch.picking_ids[1] + cls.one_line_picking = cls.batch.picking_ids.filtered( + lambda picking: len(picking.move_lines) == 1 + ) + cls.two_lines_picking = cls.batch.picking_ids.filtered( + lambda picking: len(picking.move_lines) == 2 + ) def test_unload_scan_destination_ok(self): """Endpoint /unload_scan_destination is called, result ok""" From 0b224759f56928ffff9443da94dcf10963d2ca17 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 20 Oct 2020 12:24:02 +0200 Subject: [PATCH 397/940] backend: fix more flaky tests by forcing order of moves in tests --- shopfloor/tests/test_cluster_picking_scan.py | 2 +- .../tests/test_cluster_picking_unload.py | 65 ++++++++++--------- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py index 03dc024de0..49326a0f2b 100644 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ b/shopfloor/tests/test_cluster_picking_scan.py @@ -593,7 +593,7 @@ def test_scan_destination_pack_zero_check_disabled(self): }, ) - next_line = self.batch.picking_ids.move_line_ids[1] + next_line = self.two_lines_picking.move_line_ids[0] # continue to the next one, no zero check self.assert_response( response, diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index dcd379f6f7..43ee6ad58c 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -23,6 +23,24 @@ def setUpClassBaseData(cls, *args, **kwargs): ] ) cls._simulate_batch_selected(cls.batch) + + cls.one_line_picking = cls.batch.picking_ids.filtered( + lambda picking: len(picking.move_lines) == 1 + ) + cls.two_lines_picking = cls.batch.picking_ids.filtered( + lambda picking: len(picking.move_lines) == 2 + ) + two_lines_product_a = cls.two_lines_picking.move_line_ids.filtered( + lambda line: line.product_id == cls.product_a + ) + two_lines_product_b = cls.two_lines_picking.move_line_ids - two_lines_product_a + # force order of move lines to use in tests + cls.move_lines = ( + cls.one_line_picking.move_line_ids + + two_lines_product_a + + two_lines_product_b + ) + cls.bin1 = cls.env["stock.quant.package"].create({}) cls.bin2 = cls.env["stock.quant.package"].create({}) cls.packing_a_location = ( @@ -65,7 +83,7 @@ class ClusterPickingPrepareUnloadCase(ClusterPickingUnloadingCommonCase): def test_prepare_unload_all_same_dest(self): """All move lines have the same destination location""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") + move_lines = self.move_lines self._set_dest_package_and_done(move_lines[:2], self.bin1) self._set_dest_package_and_done(move_lines[2:], self.bin2) move_lines.write({"location_dest_id": self.packing_location.id}) @@ -80,7 +98,7 @@ def test_prepare_unload_all_same_dest(self): def test_prepare_unload_different_dest(self): """All move lines have different destination locations""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") + move_lines = self.move_lines self._set_dest_package_and_done(move_lines[:2], self.bin1) self._set_dest_package_and_done(move_lines[2:], self.bin2) move_lines[:1].write({"location_dest_id": self.packing_a_location.id}) @@ -106,7 +124,7 @@ class ClusterPickingSetDestinationAllCase(ClusterPickingUnloadingCommonCase): def test_set_destination_all_ok(self): """Set destination on all lines for the full batch and end the process""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") + move_lines = self.move_lines # put destination packages, the whole quantity on lines and a similar # destination (when /set_destination_all is called, all the lines to # unload must have the same destination) @@ -158,22 +176,13 @@ def test_set_destination_all_ok(self): def test_set_destination_all_remaining_lines(self): """Set destination on all lines for a part of the batch""" - one_line_picking = self.batch.picking_ids.filtered( - lambda picking: len(picking.move_lines) == 1 - ) - two_lines_picking = self.batch.picking_ids.filtered( - lambda picking: len(picking.move_lines) == 2 - ) - move_lines = one_line_picking.move_line_ids + two_lines_picking.move_line_ids # Put destination packages, the whole quantity on lines and a similar # destination (when /set_destination_all is called, all the lines to # unload must have the same destination). # However, we keep a line without qty_done and destination package, # so when the dest location is set, the endpoint should route back # to the 'start_line' state to work on the remaining line. - lines_to_unload = ( - one_line_picking.move_line_ids + two_lines_picking.move_line_ids[0] - ) + lines_to_unload = self.move_lines[:2] self._set_dest_package_and_done(lines_to_unload, self.bin1) lines_to_unload.write({"location_dest_id": self.packing_location.id}) @@ -187,16 +196,16 @@ def test_set_destination_all_remaining_lines(self): # Since the whole batch is not complete, state should not be done. # The picking with one line should be "done" because we unloaded its line. # The second one still has a line to pick. - self.assertRecordValues(one_line_picking, [{"state": "done"}]) - self.assertRecordValues(two_lines_picking, [{"state": "assigned"}]) + self.assertRecordValues(self.one_line_picking, [{"state": "done"}]) + self.assertRecordValues(self.two_lines_picking, [{"state": "assigned"}]) self.assertRecordValues( - move_lines, + self.move_lines, [ { "shopfloor_unloaded": True, "qty_done": 10, "state": "done", - "picking_id": one_line_picking.id, + "picking_id": self.one_line_picking.id, "location_dest_id": self.packing_location.id, }, { @@ -204,14 +213,14 @@ def test_set_destination_all_remaining_lines(self): "qty_done": 10, # will be done when the second line of the picking is unloaded "state": "assigned", - "picking_id": two_lines_picking.id, + "picking_id": self.two_lines_picking.id, "location_dest_id": self.packing_location.id, }, { "shopfloor_unloaded": False, "qty_done": 0, "state": "assigned", - "picking_id": two_lines_picking.id, + "picking_id": self.two_lines_picking.id, "location_dest_id": self.packing_location.id, }, ], @@ -222,13 +231,13 @@ def test_set_destination_all_remaining_lines(self): # the remaining move line still needs to be picked response, next_state="start_line", - data=self._line_data(move_lines[2]), + data=self._line_data(self.move_lines[2]), message={"body": "Batch Transfer line done", "message_type": "success"}, ) def test_set_destination_all_but_different_dest(self): """Endpoint was called but destinations are different""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") + move_lines = self.move_lines self._set_dest_package_and_done(move_lines, self.bin1) move_lines[:2].write({"location_dest_id": self.packing_a_location.id}) move_lines[2:].write({"location_dest_id": self.packing_b_location.id}) @@ -248,7 +257,7 @@ def test_set_destination_all_but_different_dest(self): def test_set_destination_all_error_location_not_found(self): """Endpoint called with a barcode not existing for a location""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") + move_lines = self.move_lines self._set_dest_package_and_done(move_lines, self.bin1) move_lines.write({"location_dest_id": self.packing_a_location.id}) @@ -274,7 +283,7 @@ def test_set_destination_all_error_location_invalid(self): It is invalid when the location is not the destination location or sublocation of the picking type. """ - move_lines = self.batch.mapped("picking_ids.move_line_ids") + move_lines = self.move_lines self._set_dest_package_and_done(move_lines, self.bin1) move_lines.write({"location_dest_id": self.packing_a_location.id}) @@ -300,7 +309,7 @@ def test_set_destination_all_error_location_move_invalid(self): It is invalid when the location is not the destination location or sublocation of move line's move """ - move_lines = self.batch.mapped("picking_ids.move_line_ids") + move_lines = self.move_lines self._set_dest_package_and_done(move_lines, self.bin1) move_lines.write({"location_dest_id": self.packing_a_location.id}) move_lines[0].move_id.location_dest_id = self.packing_a_location @@ -323,7 +332,7 @@ def test_set_destination_all_error_location_move_invalid(self): def test_set_destination_all_need_confirmation(self): """Endpoint called with a barcode for another (valid) location""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") + move_lines = self.move_lines self._set_dest_package_and_done(move_lines, self.bin1) move_lines.write({"location_dest_id": self.packing_a_location.id}) @@ -342,7 +351,7 @@ def test_set_destination_all_need_confirmation(self): def test_set_destination_all_with_confirmation(self): """Endpoint called with a barcode for another (valid) location, confirm""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") + move_lines = self.move_lines self._set_dest_package_and_done(move_lines, self.bin1) move_lines.write({"location_dest_id": self.packing_a_location.id}) @@ -380,7 +389,7 @@ class ClusterPickingUnloadSplitCase(ClusterPickingUnloadingCommonCase): def test_unload_split_ok(self): """Call /unload_split and continue to unload single""" - move_lines = self.batch.mapped("picking_ids.move_line_ids") + move_lines = self.move_lines # put destination packages, the whole quantity on lines and a similar # destination (when /set_destination_all is called, all the lines to # unload must have the same destination) @@ -413,7 +422,6 @@ class ClusterPickingUnloadScanPackCase(ClusterPickingUnloadingCommonCase): @classmethod def setUpClassBaseData(cls, *args, **kwargs): super().setUpClassBaseData(*args, **kwargs) - cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids") cls._set_dest_package_and_done(cls.move_lines, cls.bin1) cls.move_lines[:2].write({"location_dest_id": cls.packing_a_location.id}) cls.move_lines[2:].write({"location_dest_id": cls.packing_b_location.id}) @@ -467,7 +475,6 @@ class ClusterPickingUnloadScanDestinationCase(ClusterPickingUnloadingCommonCase) @classmethod def setUpClassBaseData(cls, *args, **kwargs): super().setUpClassBaseData(*args, **kwargs) - cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids") cls.bin1_lines = cls.move_lines[:1] cls.bin2_lines = cls.move_lines[1:] cls._set_dest_package_and_done(cls.bin1_lines, cls.bin1) From 4e1bc6032d4b8c03d705a13bcdc7faac4dc51ee7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 20 Oct 2020 13:04:20 +0200 Subject: [PATCH 398/940] cluster picking: make sort of packages deterministic In tests, the order would not be always the same with records in memory. Also, ensure the destination match the packages in the tests, it doesn't make sense to have several lines in a package with different destination locations. --- shopfloor/services/cluster_picking.py | 2 +- shopfloor/tests/test_cluster_picking_unload.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index ed3a1b77e7..33c3da1d3e 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -674,7 +674,7 @@ def _lines_to_unload(self, batch): def _bin_packages_to_unload(self, batch): lines = self._lines_to_unload(batch) - packages = lines.mapped("result_package_id") + packages = lines.mapped("result_package_id").sorted() return packages def _next_bin_package_for_unload_single(self, batch): diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index 43ee6ad58c..5d9ed6f0bb 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -101,8 +101,8 @@ def test_prepare_unload_different_dest(self): move_lines = self.move_lines self._set_dest_package_and_done(move_lines[:2], self.bin1) self._set_dest_package_and_done(move_lines[2:], self.bin2) - move_lines[:1].write({"location_dest_id": self.packing_a_location.id}) - move_lines[:1].write({"location_dest_id": self.packing_b_location.id}) + move_lines[:2].write({"location_dest_id": self.packing_a_location.id}) + move_lines[2:].write({"location_dest_id": self.packing_b_location.id}) response = self.service.dispatch( "prepare_unload", params={"picking_batch_id": self.batch.id} ) From ea1abd2eefd0d4801892cf75e4a7169c118d5473 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 19 Oct 2020 14:55:07 +0200 Subject: [PATCH 399/940] zone_picking/unload_set_destination: fix move.state = partially_available handling --- shopfloor/models/stock_move.py | 13 +++- shopfloor/services/zone_picking.py | 7 ++ ...est_zone_picking_unload_set_destination.py | 76 +++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py index 2794cf4449..6c8f2abb4d 100644 --- a/shopfloor/models/stock_move.py +++ b/shopfloor/models/stock_move.py @@ -18,13 +18,22 @@ def split_other_move_lines(self, move_lines, intersection=False): other_move_lines = self.move_line_ids & move_lines else: other_move_lines = self.move_line_ids - move_lines - if other_move_lines: - qty_to_split = sum(other_move_lines.mapped("product_uom_qty")) + if other_move_lines or self.state == "partially_available": + if intersection: + # TODO @sebalix: please check if we can abandon the flag. + # Thi behavior can be achieved by passing all move lines + # as done at zone_picking.py:1293 + qty_to_split = sum(other_move_lines.mapped("product_uom_qty")) + else: + qty_to_split = self.product_uom_qty - sum( + move_lines.mapped("product_uom_qty") + ) backorder_move_id = self._split(qty_to_split) backorder_move = self.browse(backorder_move_id) backorder_move.move_line_ids = other_move_lines backorder_move._recompute_state() backorder_move._action_assign() + self._recompute_state() return backorder_move return False diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 8201eaec44..ae09596710 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -1287,9 +1287,16 @@ def unload_set_destination( self._write_destination_on_lines(buffer_lines, location) # set lines to done + refresh buffer lines (should be empty) moves = buffer_lines.mapped("move_id") + # split move lines to a backorder move + # if quantity is not fully satisfied + for move in moves: + move.split_other_move_lines(buffer_lines & move.move_line_ids) + moves.extract_and_action_done() buffer_lines = self._find_buffer_move_lines(zone_location, picking_type) + if buffer_lines: + # TODO: return success message if line has been processed return self._response_for_unload_single( zone_location, picking_type, first(buffer_lines) ) diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py index 2e63b6ec43..0e85617345 100644 --- a/shopfloor/tests/test_zone_picking_unload_set_destination.py +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -10,6 +10,25 @@ class ZonePickingUnloadSetDestinationCase(ZonePickingCommonCase): """ + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.product_g = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product G", + "type": "product", + "default_code": "G", + "barcode": "G", + "weight": 7, + } + ) + ) + cls.picking_g = cls._create_picking(lines=[(cls.product_g, 40)]) + cls._update_qty_in_location(cls.zone_sublocation1, cls.product_g, 32) + def test_unload_set_destination_wrong_parameters(self): zone_location = self.zone_location picking_type = self.picking1.picking_type_id @@ -300,3 +319,60 @@ def test_unload_set_destination_ok_buffer_not_empty(self): buffer_line, popup=completion_info_popup, ) + + def test_unload_set_destination_partially_available_backorder(self): + zone_location = self.zone_location + picking_type = self.picking_g.picking_type_id + self.assertEqual(self.picking_g.move_lines[0].product_uom_qty, 40) + self.picking_g.action_assign() + move_line = self.picking_g.move_line_ids + self.assertEqual(move_line.product_uom_qty, 32) + self.assertEqual(move_line.move_id.state, "partially_available") + packing_sublocation = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Packing sublocation", + "location_id": self.packing_location.id, + "barcode": "PACKING_SUBLOCATION", + } + ) + ) + # set the destination package + self.service._set_destination_package( + zone_location, + picking_type, + move_line, + move_line.product_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_set_destination", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "package_id": self.free_package.id, + "barcode": packing_sublocation.barcode, + "confirmation": True, + }, + ) + # check data + # move line has been moved to a new picking + self.assertEqual(move_line.move_id.picking_id, self.picking_g.backorder_ids[0]) + # the old picking contains a new line w/ the rest of the qty + # that couldn't be processed + self.assertEqual(self.picking_g.move_lines[0].product_uom_qty, 8) + self.assertEqual(self.picking_g.state, "confirmed") + # the line has been processed + self.assertEqual(move_line.location_dest_id, packing_sublocation) + self.assertEqual(move_line.move_id.state, "done") + # check response + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.buffer_complete(), + ) From 0b54628dc533fa40eb6f0a984b886c268db68c89 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 14 Oct 2020 11:09:32 +0200 Subject: [PATCH 400/940] shopfloor: improve logs with exception detail + severity --- shopfloor/models/shopfloor_log.py | 71 +++++++- shopfloor/services/service.py | 31 +++- shopfloor/tests/test_db_logging.py | 218 ++++++++++++++++++++++++ shopfloor/views/shopfloor_log_views.xml | 9 +- 4 files changed, 320 insertions(+), 9 deletions(-) create mode 100644 shopfloor/tests/test_db_logging.py diff --git a/shopfloor/models/shopfloor_log.py b/shopfloor/models/shopfloor_log.py index 9a0401cedd..9e04a0f1ce 100644 --- a/shopfloor/models/shopfloor_log.py +++ b/shopfloor/models/shopfloor_log.py @@ -3,7 +3,7 @@ import logging from datetime import datetime, timedelta -from odoo import fields, models +from odoo import api, fields, models, tools _logger = logging.getLogger(__name__) @@ -14,6 +14,14 @@ class ShopfloorLog(models.Model): _order = "id desc" DEFAULT_RETENTION = 30 # days + EXCEPTION_SEVERITY_MAPPING = { + "odoo.exceptions.UserError": "functional", + "odoo.exceptions.ValidationError": "functional", + # something broken somewhere + "ValueError": "severe", + "AttributeError": "severe", + "UnboundLocalError": "severe", + } request_url = fields.Char(readonly=True, string="Request URL") request_method = fields.Char(readonly=True) @@ -21,9 +29,70 @@ class ShopfloorLog(models.Model): headers = fields.Text(readonly=True) result = fields.Text(readonly=True) error = fields.Text(readonly=True) + exception_name = fields.Char(readonly=True, string="Exception") + exception_message = fields.Text(readonly=True) state = fields.Selection( selection=[("success", "Success"), ("failed", "Failed")], readonly=True, ) + severity = fields.Selection( + selection=[ + ("functional", "Functional"), + ("warning", "Warning"), + ("severe", "Severe"), + ], + compute="_compute_severity", + store=True, + # Grant specific override services' dispatch_exception override + # or via UI: user can classify errors as preferred on demand + # (maybe using mass_edit) + readonly=False, + ) + + @api.depends("state", "exception_name", "error") + def _compute_severity(self): + for rec in self: + rec.severity = rec.severity or rec._get_severity() + + def _get_severity(self): + if not self.exception_name: + return False + mapping = self._get_exception_severity_mapping() + return mapping.get(self.exception_name, "warning") + + def _get_exception_severity_mapping_param(self): + param = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("shopfloor.log.severity.exception.mapping") + ) + return param.strip() if param else "" + + @tools.ormcache("self._get_exception_severity_mapping_param()") + def _get_exception_severity_mapping(self): + mapping = self.EXCEPTION_SEVERITY_MAPPING.copy() + param = self._get_exception_severity_mapping_param() + if not param: + return mapping + # param should be in the form + # `[module.dotted.path.]ExceptionName:severity,ExceptionName:severity` + for rule in param.split(","): + if not rule.strip(): + continue + exc_name = severity = None + try: + exc_name, severity = [x.strip() for x in rule.split(":")] + if not exc_name or not severity: + raise ValueError + except ValueError: + _logger.exception( + "Could not convert System Parameter" + " 'shopfloor.log.severity.exception.mapping' to mapping." + " The following rule will be ignored: %s", + rule, + ) + if exc_name and severity: + mapping[exc_name] = severity + return mapping def _logs_retention_days(self): retention = self.DEFAULT_RETENTION diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 6bce31321b..791b567d4d 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -100,15 +100,21 @@ def _dispatch_exception( self, exception_klass, orig_exception, _id=None, params=None ): tb = traceback.format_exc() + # TODO: how to test this? Cannot rollback nor use another cursor self.env.cr.rollback() with registry(self.env.cr.dbname).cursor() as cr: env = self.env(cr=cr) - log_entry = self._log_call_in_db(env, request, _id, params, error=tb) + log_entry = self._log_call_in_db( + env, request, _id, params, traceback=tb, orig_exception=orig_exception + ) log_entry_url = self._get_log_entry_url(log_entry) # UserError and alike have `name` attribute to store the msg - exc_msg = getattr(orig_exception, "name", str(orig_exception)) + exc_msg = self._get_exception_message(orig_exception) raise exception_klass(exc_msg, log_entry_url) from orig_exception + def _get_exception_message(self, exception): + return getattr(exception, "name", str(exception)) + def _get_log_entry_url(self, entry): base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") url_params = { @@ -124,7 +130,7 @@ def _get_log_entry_url(self, entry): def _log_call_header_strip(self): return ("Cookie", "Api-Key") - def _log_call_in_db_values(self, _request, _id, params, result=None, error=None): + def _log_call_in_db_values(self, _request, _id, params, **kw): httprequest = _request.httprequest headers = dict(httprequest.headers) for header_key in self._log_call_header_strip: @@ -132,6 +138,17 @@ def _log_call_in_db_values(self, _request, _id, params, result=None, error=None) headers[header_key] = "" if _id: params = dict(params, _id=_id) + + result = kw.get("result") + error = kw.get("traceback") + orig_exception = kw.get("orig_exception") + exception_name = None + exception_message = None + if orig_exception: + exception_name = orig_exception.__class__.__name__ + if hasattr(orig_exception, "__module__"): + exception_name = orig_exception.__module__ + "." + exception_name + exception_message = self._get_exception_message(orig_exception) return { "request_url": httprequest.url, "request_method": httprequest.method, @@ -139,13 +156,13 @@ def _log_call_in_db_values(self, _request, _id, params, result=None, error=None) "headers": headers, "result": result, "error": error, + "exception_name": exception_name, + "exception_message": exception_message, "state": "success" if result else "failed", } - def _log_call_in_db(self, env, _request, _id, params, result=None, error=None): - values = self._log_call_in_db_values( - _request, _id, params, result=result, error=error - ) + def _log_call_in_db(self, env, _request, _id, params, **kw): + values = self._log_call_in_db_values(_request, _id, params, **kw) if not values: return return env["shopfloor.log"].sudo().create(values) diff --git a/shopfloor/tests/test_db_logging.py b/shopfloor/tests/test_db_logging.py new file mode 100644 index 0000000000..f6a4a8ed48 --- /dev/null +++ b/shopfloor/tests/test_db_logging.py @@ -0,0 +1,218 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# from urllib.parse import urlparse +# import mock + +from odoo import exceptions + +from odoo.addons.website.tools import MockRequest + +from .common import CommonCase + + +class DBLoggingCaseBase(CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout") + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + cls.wh = cls.profile.warehouse_id + cls.picking_type = cls.menu.picking_type_ids + with cls.work_on_services(cls, menu=cls.menu, profile=cls.profile) as work: + cls.service = work.component(usage="checkout") + cls.log_model = cls.env["shopfloor.log"].sudo() + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.wh.sudo().delivery_steps = "pick_pack_ship" + cls.picking = cls._create_picking() + cls._fill_stock_for_moves(cls.picking.move_lines, in_package=True) + cls.picking.action_assign() + + def _get_mocked_request(self, httprequest=None, extra_headers=None): + mocked_request = MockRequest(self.env) + # Make sure headers are there, no header in default mocked request :( + headers = { + "Cookie": "IaMaCookie!", + "Api-Key": "I_MUST_STAY_SECRET", + } + headers.update(extra_headers or {}) + httprequest = httprequest or {} + httprequest["headers"] = headers + mocked_request.request["httprequest"] = httprequest + return mocked_request + + +class DBLoggingCase(DBLoggingCaseBase): + def test_no_log_entry(self): + self.service._log_calls_in_db = False + log_entry_count = self.log_model.search_count([]) + with self._get_mocked_request(): + resp = self.service.dispatch( + "scan_document", params={"barcode": self.picking.name} + ) + self.assertNotIn("log_entry_url", resp) + self.assertFalse(self.log_model.search_count([]) > log_entry_count) + + def test_log_entry(self): + log_entry_count = self.log_model.search_count([]) + with self._get_mocked_request(): + resp = self.service.dispatch( + "scan_document", params={"barcode": self.picking.name} + ) + self.assertIn("log_entry_url", resp) + self.assertTrue(self.log_model.search_count([]) > log_entry_count) + + # TODO: this is very tricky because when the exception is raised + # the transaction is explicitly rolled back and then our test env is gone + # and everything right after is broken. + # To fully test this we need a different test class setup and advanced mocking + # and/or rewrite code so that we can it properly. + # def test_log_exception(self): + # mock_path = \ + # "odoo.addons.shopfloor.services.checkout.Checkout.scan_document" + # log_entry_count = self.log_model.search_count([]) + # with self._get_mocked_request(): + # with mock.patch(mock_path, autospec=True) as mocked: + # exc = exceptions.UserError("Sorry, you broke it!") + # mocked.side_effect = exc + # resp = self.service.dispatch( + # "scan_document", params={"barcode": self.picking.name}) + # self.assertIn("log_entry_url", resp) + # self.assertTrue(self.log_model.search_count([]) > log_entry_count) + # log_entry_data = urlparse(resp["log_entry_url"]) + # pass + + def test_log_entry_values_success(self): + _id = "whatever-id" + params = {"barcode": self.picking.name} + kw = {"result": {"data": "worked!"}} + # test full data request only once, other tests will skip this part + httprequest = {"url": "https://my.odoo.test/service/endpoint", "method": "POST"} + extra_headers = {"KEEP-ME": "FOO"} + with self._get_mocked_request( + httprequest=httprequest, extra_headers=extra_headers + ) as mocked_request: + entry = self.service._log_call_in_db( + self.env, mocked_request, _id, params, **kw + ) + expected = { + "request_url": httprequest["url"], + "request_method": httprequest["method"], + "params": str(dict(params, _id=_id)), + "headers": str( + {"Cookie": "", "Api-Key": "", "KEEP-ME": "FOO"} + ), + "state": "success", + "result": str({"data": "worked!"}), + "error": False, + "exception_name": False, + "severity": False, + } + self.assertRecordValues(entry, [expected]) + + def test_log_entry_values_failed(self): + _id = "whatever-id" + params = {"barcode": self.picking.name} + # no result, will fail + kw = {"result": {}} + with self._get_mocked_request() as mocked_request: + entry = self.service._log_call_in_db( + self.env, mocked_request, _id, params, **kw + ) + expected = { + "state": "failed", + "result": str({}), + "error": False, + "exception_name": False, + "severity": False, + } + self.assertRecordValues(entry, [expected]) + + def _test_log_entry_values_failed_with_exception_default(self, severity=None): + _id = "whatever-id" + params = {"barcode": self.picking.name} + fake_tb = """ + [...] + File "/somewhere/in/your/custom/code/file.py", line 503, in write + [...] + ValueError: Ops, something went wrong + """ + orig_exception = ValueError("Ops, something went wrong") + kw = {"result": {}, "traceback": fake_tb, "orig_exception": orig_exception} + with self._get_mocked_request() as mocked_request: + entry = self.service._log_call_in_db( + self.env, mocked_request, _id, params, **kw + ) + expected = { + "state": "failed", + "result": str({}), + "error": fake_tb, + "exception_name": "ValueError", + "exception_message": "Ops, something went wrong", + "severity": severity or "severe", + } + self.assertRecordValues(entry, [expected]) + + def test_log_entry_values_failed_with_exception_default(self): + self._test_log_entry_values_failed_with_exception_default() + + def test_log_entry_values_failed_with_exception_functional(self): + _id = "whatever-id" + params = {"barcode": self.picking.name} + fake_tb = """ + [...] + File "/somewhere/in/your/custom/code/file.py", line 503, in write + [...] + UserError: You are doing something wrong Dave! + """ + orig_exception = exceptions.UserError("You are doing something wrong Dave!") + kw = {"result": {}, "traceback": fake_tb, "orig_exception": orig_exception} + with self._get_mocked_request() as mocked_request: + entry = self.service._log_call_in_db( + self.env, mocked_request, _id, params, **kw + ) + expected = { + "state": "failed", + "result": str({}), + "error": fake_tb, + "exception_name": "odoo.exceptions.UserError", + "exception_message": "You are doing something wrong Dave!", + "severity": "functional", + } + self.assertRecordValues(entry, [expected]) + + # test that we can still change severity as we like + entry.severity = "severe" + self.assertEqual(entry.severity, "severe") + + def test_log_entry_severity_mapping_param(self): + # test override of mapping via config param + mapping = self.log_model._get_exception_severity_mapping() + self.assertEqual(mapping, self.log_model.EXCEPTION_SEVERITY_MAPPING) + self.assertEqual(mapping["ValueError"], "severe") + self.assertEqual(mapping["odoo.exceptions.UserError"], "functional") + value = "ValueError: warning, odoo.exceptions.UserError: severe" + self.env["ir.config_parameter"].sudo().create( + {"key": "shopfloor.log.severity.exception.mapping", "value": value} + ) + mapping = self.log_model._get_exception_severity_mapping() + self.assertEqual(mapping["ValueError"], "warning") + self.assertEqual(mapping["odoo.exceptions.UserError"], "severe") + self._test_log_entry_values_failed_with_exception_default("warning") + + def test_log_entry_severity_mapping_param_bad_values(self): + # bad values are discarded + value = """ + ValueError: warning, + odoo.exceptions.UserError::badvalue, + VeryBadValue|error + """ + self.env["ir.config_parameter"].sudo().create( + {"key": "shopfloor.log.severity.exception.mapping", "value": value} + ) + mapping = self.log_model._get_exception_severity_mapping() + expected = self.log_model.EXCEPTION_SEVERITY_MAPPING.copy() + expected["ValueError"] = "warning" + self.assertEqual(mapping, expected) diff --git a/shopfloor/views/shopfloor_log_views.xml b/shopfloor/views/shopfloor_log_views.xml index 6ebb576f7c..7f250d96a3 100644 --- a/shopfloor/views/shopfloor_log_views.xml +++ b/shopfloor/views/shopfloor_log_views.xml @@ -10,6 +10,9 @@ + + +
@@ -58,7 +61,11 @@ name="error" attrs="{'invisible': [('state', '!=', 'failed')]}" > - + + + + + Date: Wed, 14 Oct 2020 11:10:24 +0200 Subject: [PATCH 401/940] shopfloor: improve logs filtering --- shopfloor/views/shopfloor_log_views.xml | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/shopfloor/views/shopfloor_log_views.xml b/shopfloor/views/shopfloor_log_views.xml index 7f250d96a3..5e4ed99f53 100644 --- a/shopfloor/views/shopfloor_log_views.xml +++ b/shopfloor/views/shopfloor_log_views.xml @@ -87,12 +87,35 @@ + + + + + + + + + + Date: Wed, 14 Oct 2020 13:48:01 +0200 Subject: [PATCH 402/940] shopfloor: log dict data as json --- shopfloor/services/service.py | 7 ++++--- shopfloor/tests/test_db_logging.py | 13 +++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 791b567d4d..7c11e20aac 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -1,6 +1,7 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # Copyright 2020 Akretion (http://www.akretion.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import json import traceback from werkzeug.urls import url_encode, url_join @@ -152,9 +153,9 @@ def _log_call_in_db_values(self, _request, _id, params, **kw): return { "request_url": httprequest.url, "request_method": httprequest.method, - "params": params, - "headers": headers, - "result": result, + "params": json.dumps(params), + "headers": json.dumps(headers), + "result": json.dumps(result), "error": error, "exception_name": exception_name, "exception_message": exception_message, diff --git a/shopfloor/tests/test_db_logging.py b/shopfloor/tests/test_db_logging.py index f6a4a8ed48..6795327292 100644 --- a/shopfloor/tests/test_db_logging.py +++ b/shopfloor/tests/test_db_logging.py @@ -2,6 +2,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). # from urllib.parse import urlparse # import mock +import json from odoo import exceptions @@ -100,12 +101,12 @@ def test_log_entry_values_success(self): expected = { "request_url": httprequest["url"], "request_method": httprequest["method"], - "params": str(dict(params, _id=_id)), - "headers": str( + "params": json.dumps(dict(params, _id=_id)), + "headers": json.dumps( {"Cookie": "", "Api-Key": "", "KEEP-ME": "FOO"} ), "state": "success", - "result": str({"data": "worked!"}), + "result": json.dumps({"data": "worked!"}), "error": False, "exception_name": False, "severity": False, @@ -123,7 +124,7 @@ def test_log_entry_values_failed(self): ) expected = { "state": "failed", - "result": str({}), + "result": "{}", "error": False, "exception_name": False, "severity": False, @@ -147,7 +148,7 @@ def _test_log_entry_values_failed_with_exception_default(self, severity=None): ) expected = { "state": "failed", - "result": str({}), + "result": "{}", "error": fake_tb, "exception_name": "ValueError", "exception_message": "Ops, something went wrong", @@ -175,7 +176,7 @@ def test_log_entry_values_failed_with_exception_functional(self): ) expected = { "state": "failed", - "result": str({}), + "result": "{}", "error": fake_tb, "exception_name": "odoo.exceptions.UserError", "exception_message": "You are doing something wrong Dave!", From 262342d5fd981414ef70b9b8f6d45718a846311f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 14 Oct 2020 13:49:18 +0200 Subject: [PATCH 403/940] shopfloor: log improve form display --- shopfloor/views/shopfloor_log_views.xml | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/shopfloor/views/shopfloor_log_views.xml b/shopfloor/views/shopfloor_log_views.xml index 5e4ed99f53..b2dee1d9f9 100644 --- a/shopfloor/views/shopfloor_log_views.xml +++ b/shopfloor/views/shopfloor_log_views.xml @@ -36,12 +36,8 @@ - - - - - - + + - + + From 16b5ade74c96ba2522c7092912246904bd0bffa4 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 14 Oct 2020 13:50:22 +0200 Subject: [PATCH 404/940] shopfloor: bump 13.0.1.2.0 with migration step --- shopfloor/__manifest__.py | 2 +- .../migrations/13.0.1.2.0/post-migration.py | 62 +++++++++++++++++++ shopfloor/models/shopfloor_log.py | 1 + shopfloor/services/service.py | 6 +- 4 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 shopfloor/migrations/13.0.1.2.0/post-migration.py diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 6a90cb2e79..f7d6b4a123 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "13.0.1.1.0", + "version": "13.0.1.2.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", diff --git a/shopfloor/migrations/13.0.1.2.0/post-migration.py b/shopfloor/migrations/13.0.1.2.0/post-migration.py new file mode 100644 index 0000000000..be81c55e98 --- /dev/null +++ b/shopfloor/migrations/13.0.1.2.0/post-migration.py @@ -0,0 +1,62 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import json +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger("shopfloor." + __name__) + + +def _compute_logs_new_values(env): + log_entries = env["shopfloor.log"].search([]) + for entry in log_entries: + new_vals = {} + for fname in ("params", "headers", "result"): + if not entry[fname]: + continue + # make it json-like + replace_map = [ + ("{'", '{"'), + ("'}", '"}'), + ("':", '":'), + (": '", ': "'), + ("',", '",'), + (", '", ', "'), + ("False", "false"), + ("True", "true"), + ("None", "null"), + ("\\xa0", " "), + ] + json_val = entry[fname] + for to_replace, replace_with in replace_map: + json_val = json_val.replace(to_replace, replace_with) + try: + val = json.loads(json_val) + except Exception: + # fail gracefully and do not break the whole thing + # just for not being able to convert a value. + # We don't use these values as json yet, no harm. + _logger.warning( + "`%s` JSON convert failed for record %d", (fname, entry.id) + ) + else: + new_vals[fname] = json.dumps(val, indent=4, sort_keys=True) + if entry.error and not entry.exception_name: + new_vals.update(_get_exception_details(entry)) + entry.write(new_vals) + + +def _get_exception_details(entry): + for line in reversed(entry.error.splitlines()): + if "Error:" in line: + name, msg = line.split(":", 1) + return { + "exception_name": name.strip(), + "exception_message": msg.strip("() "), + } + + +def migrate(cr, version): + env = api.Environment(cr, SUPERUSER_ID, {}) + _compute_logs_new_values(env) diff --git a/shopfloor/models/shopfloor_log.py b/shopfloor/models/shopfloor_log.py index 9e04a0f1ce..62bb26519b 100644 --- a/shopfloor/models/shopfloor_log.py +++ b/shopfloor/models/shopfloor_log.py @@ -26,6 +26,7 @@ class ShopfloorLog(models.Model): request_url = fields.Char(readonly=True, string="Request URL") request_method = fields.Char(readonly=True) params = fields.Text(readonly=True) + # TODO: make these fields serialized and use a computed field for displaying headers = fields.Text(readonly=True) result = fields.Text(readonly=True) error = fields.Text(readonly=True) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 7c11e20aac..7b0cac866c 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -153,9 +153,9 @@ def _log_call_in_db_values(self, _request, _id, params, **kw): return { "request_url": httprequest.url, "request_method": httprequest.method, - "params": json.dumps(params), - "headers": json.dumps(headers), - "result": json.dumps(result), + "params": json.dumps(params, indent=4, sort_keys=True), + "headers": json.dumps(headers, indent=4, sort_keys=True), + "result": json.dumps(result, indent=4, sort_keys=True), "error": error, "exception_name": exception_name, "exception_message": exception_message, From 25fd5ba001153c9038f6713c7b9a3c50c5c12267 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 19 Oct 2020 10:08:43 +0200 Subject: [PATCH 405/940] shopfloor: log use ace widget for params n headers --- shopfloor/views/shopfloor_log_views.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shopfloor/views/shopfloor_log_views.xml b/shopfloor/views/shopfloor_log_views.xml index b2dee1d9f9..603a185c3b 100644 --- a/shopfloor/views/shopfloor_log_views.xml +++ b/shopfloor/views/shopfloor_log_views.xml @@ -36,8 +36,8 @@ - - + + Date: Wed, 21 Oct 2020 11:32:26 +0200 Subject: [PATCH 406/940] location content transfer: add test to reproduce an error The following error is raised if there is no move line of another picking type to unreserve: ValueError: not enough values to unpack (expected 3, got 0) --- .../tests/test_location_content_transfer_start.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py index 20c3e7da07..29d0470d16 100644 --- a/shopfloor/tests/test_location_content_transfer_start.py +++ b/shopfloor/tests/test_location_content_transfer_start.py @@ -233,6 +233,20 @@ def test_scan_location_wrong_picking_type_allow_unreserve_ok(self): ], ) + def test_scan_location_wrong_picking_type_allow_unreserve_empty(self): + """Content has different picking type than menu, option to unreserve + + There is no move line of another picking type to unreserve. + """ + self.menu.sudo().allow_unreserve_other_moves = True + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + self.assert_response_start( + response, + message=self.service.msg_store.no_pack_in_location(self.content_loc), + ) + def test_scan_location_wrong_picking_type_allow_unreserve_error(self): """Content has different picking type than menu, option to unreserve From 28e32ec72512e9c1554aab1688a367824ce11da5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 21 Oct 2020 11:35:27 +0200 Subject: [PATCH 407/940] location content transfer: fix if no move line to unreserve --- shopfloor/services/location_content_transfer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 4e296d82a8..3ed93c4590 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -234,7 +234,7 @@ def _unreserve_other_lines(self, location, move_lines): lambda line: line.picking_id.picking_type_id not in self.picking_types ) if not lines_other_picking_types: - return move_lines + return (move_lines, None, None) unreserved_moves = move_lines.move_id location_move_lines = self.env["stock.move.line"].search( self._find_location_all_move_lines_domain(location) From a20f949ebe58efbfa5e4c6b3c84128a2b9097316 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 29 Sep 2020 11:20:10 +0200 Subject: [PATCH 408/940] zone_picking: list all zones on start When the operator starts with zone picking scenario the 1st screen presents the list of zones that have lines to process. --- shopfloor/services/zone_picking.py | 137 +++++++++++++++------ shopfloor/tests/test_zone_picking_base.py | 14 ++- shopfloor/tests/test_zone_picking_start.py | 65 +++++++++- 3 files changed, 178 insertions(+), 38 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index ae09596710..e61992cf2b 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -1,6 +1,7 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import functools +from itertools import groupby from odoo.fields import first from odoo.tools.float_utils import float_compare, float_is_zero @@ -82,7 +83,14 @@ class ZonePicking(Component): _description = __doc__ def _response_for_start(self, message=None): - return self._response(next_state="start", message=message) + zones = self.work.menu.picking_type_ids.mapped( + "default_location_src_id.child_ids" + ) + return self._response( + next_state="start", + data={"zones": self._data_for_select_zone(zones)}, + message=message, + ) def _response_for_select_picking_type( self, zone_location, picking_types, message=None @@ -189,30 +197,24 @@ def _data_for_select_picking_type(self, zone_location, picking_types): for datum in data["picking_types"]: picking_type = self.env["stock.picking.type"].browse(datum["id"]) zone_lines = self._picking_type_zone_lines(zone_location, picking_type) - priority_lines = zone_lines.filtered( - lambda line: line.picking_id.priority in ["2", "3"] - ) - - datum.update( - { - "lines_count": len(zone_lines), - "picking_count": len(zone_lines.mapped("picking_id")), - "priority_lines_count": len(priority_lines), - "priority_picking_count": len(priority_lines.mapped("picking_id")), - } - ) + datum.update(self._counters_for_zone_lines(zone_lines)) return data + def _counters_for_zone_lines(self, zone_lines): + # Not using mapped/filtered to support simple lists and generators + priority_lines = [x for x in zone_lines if x.picking_id.priority in ("2", "3")] + return { + "lines_count": len(zone_lines), + "picking_count": len({x.picking_id.id for x in zone_lines}), + "priority_lines_count": len(priority_lines), + "priority_picking_count": len({x.picking_id.id for x in priority_lines}), + } + def _picking_type_zone_lines(self, zone_location, picking_type): - return self.env["stock.move.line"].search( - [ - ("location_id", "child_of", zone_location.id), - # we have auto_join on picking_id - ("picking_id.picking_type_id", "=", picking_type.id), - ("qty_done", "=", 0), - ("state", "in", ("assigned", "partially_available")), - ] + domain = self._find_location_move_lines_domain( + zone_location, picking_type=picking_type ) + return self.env["stock.move.line"].search(domain) def _data_for_move_line(self, zone_location, picking_type, move_line): return { @@ -246,15 +248,49 @@ def _data_for_location(self, zone_location, picking_type, location): "location": self.data.location(location), } + def _zone_lines(self, zones): + return self.env["stock.move.line"].search( + self._find_location_move_lines_domain(zones) + ) + + def _data_for_select_zone(self, zones): + """Retrieve detailed info for each zone. + + Zone without lines are skipped. + Zone with lines will have line counters by operation type. + + :param zones: zone location recordset + :return: see _schema_for_select_zone + """ + res = [] + for zone in zones: + zone_data = self.data.location(zone) + zone_lines = self._zone_lines(zone).sorted( + key=lambda x: x.picking_id.picking_type_id + ) + if not zone_lines: + continue + zone_data["operation_types"] = [] + + for picking_type, lines in groupby( + zone_lines, lambda line: line.picking_id.picking_type_id + ): + op_type = self.data.picking_type(picking_type) + op_type.update(self._counters_for_zone_lines(list(lines))) + zone_data["operation_types"].append(op_type) + res.append(zone_data) + return res + def _find_location_move_lines_domain( - self, location, picking_type=None, package=None, product=None, lot=None + self, locations, picking_type=None, package=None, product=None, lot=None ): domain = [ - ("location_id", "child_of", location.id), + ("location_id", "child_of", locations.ids), ("qty_done", "=", 0), ("state", "in", ("assigned", "partially_available")), ] if picking_type: + # auto_join in place for this field domain += [("picking_id.picking_type_id", "=", picking_type.id)] else: domain += [("picking_id.picking_type_id", "in", self.picking_types.ids)] @@ -268,17 +304,17 @@ def _find_location_move_lines_domain( def _find_location_move_lines( self, - location, + locations, picking_type=None, package=None, product=None, lot=None, order="priority", ): - """Find lines that potentially are to move in the location""" + """Find lines that potentially need work in given locations.""" move_lines = self.env["stock.move.line"].search( self._find_location_move_lines_domain( - location, picking_type, package, product, lot + locations, picking_type, package, product, lot ) ) sort_keys_func, reverse = self._sort_key_move_lines(order) @@ -329,6 +365,16 @@ def _group_buffer_move_lines_by_package(self, move_lines): data[move_line.result_package_id] |= move_line return data + def select_zone(self): + """Retrieve all available zones to work with. + + A zone is defined by the first level location below the source location + of the operation types linked to the menu. + + The count of lines to process by available operations is computed per each zone. + """ + return self._response_for_start() + def scan_location(self, barcode): """Scan the zone location where the picking should occur @@ -1329,6 +1375,9 @@ class ShopfloorZonePickingValidator(Component): _name = "shopfloor.zone_picking.validator" _usage = "zone_picking.validator" + def select_zone(self): + return {} + def scan_location(self): return {"barcode": {"required": True, "type": "string"}} @@ -1446,7 +1495,7 @@ def _states(self): to the next state. """ return { - "start": {}, + "start": self._schema_for_select_zone, "select_picking_type": self._schema_for_select_picking_type, "select_line": self._schema_for_move_lines_empty_location, "set_line_destination": self._schema_for_move_line, @@ -1457,6 +1506,9 @@ def _states(self): "unload_set_destination": self._schema_for_move_line, } + def select_zone(self): + return self._response_schema(next_states={"start"}) + def scan_location(self): return self._response_schema(next_states={"start", "select_picking_type"}) @@ -1519,17 +1571,32 @@ def unload_set_destination(self): next_states={"unload_single", "unload_set_destination", "select_line"} ) + @property + def _schema_for_select_zone(self): + zone_schema = self.schemas.location() + picking_type_schema = self.schemas.picking_type() + picking_type_schema.update(self._schema_for_zone_line_counters) + zone_schema["operation_types"] = self.schemas._schema_list_of( + picking_type_schema + ) + zone_schema = { + "zones": self.schemas._schema_list_of(zone_schema), + } + return zone_schema + + @property + def _schema_for_zone_line_counters(self): + return { + "lines_count": {"type": "float", "required": True}, + "picking_count": {"type": "float", "required": True}, + "priority_lines_count": {"type": "float", "required": True}, + "priority_picking_count": {"type": "float", "required": True}, + } + @property def _schema_for_select_picking_type(self): picking_type = self.schemas.picking_type() - picking_type.update( - { - "lines_count": {"type": "float", "required": True}, - "picking_count": {"type": "float", "required": True}, - "priority_lines_count": {"type": "float", "required": True}, - "priority_picking_count": {"type": "float", "required": True}, - } - ) + picking_type.update(self._schema_for_zone_line_counters) schema = { "zone_location": self.schemas._schema_dict_of(self.schemas.location()), "picking_types": self.schemas._schema_list_of(picking_type), diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 95d59c2b8e..e6fdd4ec0c 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -29,6 +29,8 @@ def setUpClassBaseData(cls, *args, **kwargs): } ) ) + # Set default location for our picking type + cls.menu.picking_type_ids[0].sudo().default_location_src_id = cls.zone_location cls.zone_sublocation1 = ( cls.env["stock.location"] .sudo() @@ -186,8 +188,16 @@ def setUp(self): with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="zone_picking") - def assert_response_start(self, response, message=None): - self.assert_response(response, next_state="start", message=message) + def _assert_response_select_zone(self, response, zone_locations, message=None): + data = {"zones": self.service._data_for_select_zone(zone_locations)} + self.assert_response( + response, next_state="start", data=data, message=message, + ) + + def assert_response_start(self, response, zone_locations=None, message=None): + if zone_locations is None: + zone_locations = self.zone_location.child_ids + self._assert_response_select_zone(response, zone_locations, message=message) def _assert_response_select_picking_type( self, state, response, zone_location, picking_types, message=None diff --git a/shopfloor/tests/test_zone_picking_start.py b/shopfloor/tests/test_zone_picking_start.py index 0f7d2a61d3..2e1ecdc242 100644 --- a/shopfloor/tests/test_zone_picking_start.py +++ b/shopfloor/tests/test_zone_picking_start.py @@ -32,6 +32,66 @@ def setUpClassBaseData(cls, *args, **kwargs): cls._update_qty_in_location(cls.zone_sublocation1, cls.product_b, 10) extra_picking.action_assign() + def test_data_for_zone(self): + op_type_data = self.data.picking_type(self.menu.picking_type_ids[0]) + zones_data = self.service._response_for_start()["data"]["start"]["zones"] + expected_sub1 = dict( + self.data.location(self.zone_sublocation1), + operation_types=[ + dict( + op_type_data, + lines_count=1, + picking_count=1, + priority_lines_count=0, + priority_picking_count=0, + ) + ], + ) + expected_sub2 = dict( + self.data.location(self.zone_sublocation2), + operation_types=[ + dict( + op_type_data, + lines_count=2, + picking_count=1, + priority_lines_count=0, + priority_picking_count=0, + ) + ], + ) + expected_sub3 = dict( + self.data.location(self.zone_sublocation3), + operation_types=[ + dict( + op_type_data, + lines_count=2, + picking_count=2, + priority_lines_count=0, + priority_picking_count=0, + ) + ], + ) + expected_sub4 = dict( + self.data.location(self.zone_sublocation4), + operation_types=[ + dict( + op_type_data, + lines_count=3, + picking_count=2, + priority_lines_count=0, + priority_picking_count=0, + ) + ], + ) + self.assertEqual( + zones_data, [expected_sub1, expected_sub2, expected_sub3, expected_sub4] + ) + + def test_select_zone(self): + """Scanned location invalid, no location found.""" + response = self.service.dispatch("select_zone") + self.assert_response_start(response) + def test_scan_location_wrong_barcode(self): """Scanned location invalid, no location found.""" response = self.service.dispatch( @@ -54,8 +114,11 @@ def test_scan_location_not_allowed(self): def test_scan_location_no_move_lines(self): """Scanned location valid, but no move lines found in it.""" + sub1_lines = self.service._find_location_move_lines(self.zone_sublocation1) + # no more lines available + sub1_lines.picking_id.action_cancel() response = self.service.dispatch( - "scan_location", params={"barcode": self.shelf2.barcode}, + "scan_location", params={"barcode": self.zone_sublocation1.barcode}, ) self.assert_response_start( response, message=self.service.msg_store.no_lines_to_process(), From f2dcb0cfa1911f1bb6610394489e4d1e45591559 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 26 Oct 2020 08:17:11 +0100 Subject: [PATCH 409/940] Fix error with message being a dict, not str Traceback (most recent call last): File "/odoo/external-src/wms-shopfloor/setup/shopfloor/odoo/addons/shopfloor/services/service.py", line 69, in _dispatch_with_db_logging result = super().dispatch(method_name, _id=_id, params=params) File "/odoo/external-src/rest-framework/setup/base_rest/odoo/addons/base_rest/components/service.py", line 199, in dispatch res = func(**secure_params) File "/odoo/external-src/wms-shopfloor/setup/shopfloor/odoo/addons/shopfloor/services/delivery.py", line 123, in scan_deliver return self._deliver_package(picking, package) File "/odoo/external-src/wms-shopfloor/setup/shopfloor/odoo/addons/shopfloor/services/delivery.py", line 170, in _deliver_package message, TypeError: sequence item 1: expected str instance, dict found The result of _check_picking_status is the full dict for a message (message_type, body), so we have to update its body. --- shopfloor/services/delivery.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 74cf09bce6..476d97073a 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -164,12 +164,12 @@ def _deliver_package(self, picking, package): # State of the picking might change while we reach this point: check again! message = self._check_picking_status(lines.mapped("picking_id")) if message: - message = "\n".join( + message["body"] = "\n".join( [ _("Package {} belongs to a picking without a valid state.").format( package.name ), - message, + message["body"], ] ) return self._response_for_deliver(message=message) @@ -228,12 +228,12 @@ def _deliver_product(self, picking, product): # State of the picking might change while we reach this point: check again! message = self._check_picking_status(lines.mapped("picking_id")) if message: - message = "\n".join( + message["body"] = "\n".join( [ _("Product {} belongs to a picking without a valid state.").format( product.name ), - message, + message["body"], ] ) return self._response_for_deliver(message=message) @@ -277,12 +277,12 @@ def _deliver_lot(self, picking, lot): # State of the picking might change while we reach this point: check again! message = self._check_picking_status(lines.mapped("picking_id")) if message: - message = "\n".join( + message["body"] = "\n".join( [ _("Lot {} belongs to a picking without a valid state.").format( lot.name ), - message, + message["body"], ] ) return self._response_for_deliver(message=message) From 668fa0d080dbfbd62f3130b716fb36434be4ff0f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 26 Oct 2020 10:32:13 +0100 Subject: [PATCH 410/940] location content transfer: fix wrong return The caller expects a recordset of moves in the second item of the tuple. --- shopfloor/services/location_content_transfer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 3ed93c4590..35f8395fb4 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -234,7 +234,7 @@ def _unreserve_other_lines(self, location, move_lines): lambda line: line.picking_id.picking_type_id not in self.picking_types ) if not lines_other_picking_types: - return (move_lines, None, None) + return (move_lines, self.env["stock.move"].browse(), None) unreserved_moves = move_lines.move_id location_move_lines = self.env["stock.move.line"].search( self._find_location_all_move_lines_domain(location) From 2eacbcd0de0165c99131c424ccd3272fc82408ce Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 26 Oct 2020 09:10:43 +0100 Subject: [PATCH 411/940] shopfloor: refactor header validation Headers might depend on services and we hacked this via controller. Now the validation and the computation for their work ctx attribute is delegated to the service. This way we can control by service what is needed and customize validation per service if needed. --- shopfloor/controllers/main.py | 64 ----------------------------------- shopfloor/services/service.py | 61 +++++++++++++++++++++++++++++++++ shopfloor/tests/common.py | 6 +++- 3 files changed, 66 insertions(+), 65 deletions(-) diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py index 86676e235d..218e08e3b7 100644 --- a/shopfloor/controllers/main.py +++ b/shopfloor/controllers/main.py @@ -1,75 +1,11 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # Copyright 2020 Akretion (http://www.akretion.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from werkzeug.exceptions import BadRequest - -from odoo.http import request from odoo.addons.base_rest.controllers import main -MENU_ID_HEADER = "HTTP_SERVICE_CTX_MENU_ID" -# (name, model, dest_key) -MENU_HEADER_RULE = (MENU_ID_HEADER, "shopfloor.menu", "menu") - -PROFILE_ID_HEADER = "HTTP_SERVICE_CTX_PROFILE_ID" -PROFILE_HEADER_RULE = (PROFILE_ID_HEADER, "shopfloor.profile", "profile") - class ShopfloorController(main.RestController): _root_path = "/shopfloor/" _collection_name = "shopfloor.service" _default_auth = "api_key" - # TODO: this should come from registered services. - # We would need to change how their ctx is initialized tho - # because ATM the ctx is computed before lookup. - _service_headers_rules = { - # no special header required for config - "app/user_config": (), - "scan_anything/scan": (), - # profile header is required to get menu items - # fmt: off - # NOTE: turn off formatting here is mandatory - # otherwise black removes the space and flake8 w/ complain the comma - # before parenthesis which is required to make this a tuple! - "app/menu": (PROFILE_HEADER_RULE, ), - # profile + menu is required to call processes - "process": (PROFILE_HEADER_RULE, MENU_HEADER_RULE, ), - # fmt: on - } - - def _get_component_context(self): - """ - This method adds the component context: - * the shopfloor menu in ``self.work.menu`` from the service Components - * the shopfloor profile in ``self.work.profile`` from the service - Components - """ - res = super(ShopfloorController, self)._get_component_context() - res["menu"] = None - res["profile"] = None - res.update(self._get_process_context(request)) - return res - - # TODO: add tests - def _get_process_context(self, request): - ctx = {} - env = request.env - headers = request.httprequest.environ - # '/shopfloor/app/user_config' -> app/config - service_path = request.httprequest.path.split(self._root_path)[-1] - - # default to process rule - default = self._service_headers_rules["process"] - headers_map = self._service_headers_rules.get(service_path, default) - for header_name, model, dest_key in headers_map: - try: - rec_id = int(headers.get(header_name)) - except (TypeError, ValueError): - raise BadRequest("{} must be set with an integer".format(header_name)) - rec = env[model].browse(rec_id).exists() - if not rec: - raise BadRequest( - "Record {} with ID = {} not found".format(model, rec_id) - ) - ctx[dest_key] = rec - return ctx diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 7b0cac866c..5c39cf446c 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -4,6 +4,7 @@ import json import traceback +from werkzeug.exceptions import BadRequest from werkzeug.urls import url_encode, url_join from odoo import _, exceptions, registry @@ -56,6 +57,7 @@ class BaseShopfloorService(AbstractComponent): _log_calls_in_db = True def dispatch(self, method_name, _id=None, params=None): + self._validate_headers_update_work_context(request, method_name) if not self._db_logging_active(): return super().dispatch(method_name, _id=_id, params=params) return self._dispatch_with_db_logging(method_name, _id=_id, params=params) @@ -352,6 +354,65 @@ def data_detail(self): def msg_store(self): return self.actions_for("message") + # TODO: maybe to be proposed to base_rest + # TODO: add tests + def _validate_headers_update_work_context(self, request, method_name): + """Validate request and update context per service. + + Our services may require extra headers. + The service component is loaded after the ctx has been initialized + hence we need an hook were we can validate by component/service + if the request is compliant with what we need (eg: missing header) + """ + if self.env.context.get("_service_skip_request_validation"): + return + extra_work_ctx = {} + headers = request.httprequest.environ + for rule, active in self._validation_rules: + if not active: + continue + header_name, coerce_func, ctx_value_handler_name = rule + try: + header_value = coerce_func(headers.get(header_name)) + except (TypeError, ValueError) as err: + raise BadRequest( + "{} header validation error: {}".format(header_name, str(err)) + ) + ctx_value_handler = getattr(self, ctx_value_handler_name) + dest_key, value = ctx_value_handler(header_value) + if not value: + raise BadRequest("{} header value lookup error".format(header_name)) + extra_work_ctx[dest_key] = value + for k, v in extra_work_ctx.items(): + setattr(self.work, k, v) + + @property + def _validation_rules(self): + return ( + # rule to apply, active flag + (self.MENU_ID_HEADER_RULE, self._requires_header_menu), + (self.PROFILE_ID_HEADER_RULE, self._requires_header_profile), + ) + + MENU_ID_HEADER_RULE = ( + # header name, coerce func, ctx handler + "HTTP_SERVICE_CTX_MENU_ID", + int, + "_work_ctx_get_menu_id", + ) + PROFILE_ID_HEADER_RULE = ( + # header name, coerce func, ctx value handler + "HTTP_SERVICE_CTX_PROFILE_ID", + int, + "_work_ctx_get_profile_id", + ) + + def _work_ctx_get_menu_id(self, rec_id): + return "menu", self.env["shopfloor.menu"].browse(rec_id).exists() + + def _work_ctx_get_profile_id(self, rec_id): + return "profile", self.env["shopfloor.profile"].browse(rec_id).exists() + class BaseShopfloorProcess(AbstractComponent): """Base class for process rest service""" diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 0eb6612b5b..e3c7a056f6 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -82,7 +82,11 @@ def setUp(self): def setUpClass(cls): super(CommonCase, cls).setUpClass() cls.env = cls.env( - context=dict(cls.env.context, tracking_disable=cls.tracking_disable) + context=dict( + cls.env.context, + tracking_disable=cls.tracking_disable, + _service_skip_request_validation=True, + ) ) cls.setUpComponent() From 9b021c15b8ed057c6c08c4ef335d5e6a9ab8a11a Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 26 Oct 2020 09:14:33 +0100 Subject: [PATCH 412/940] shopfloor: split app/menu to user/menu --- shopfloor/README.rst | 2 +- shopfloor/readme/USAGE.rst | 2 +- shopfloor/services/__init__.py | 1 + shopfloor/services/app.py | 28 +---------- shopfloor/services/user.py | 52 ++++++++++++++++++++ shopfloor/static/description/index.html | 2 +- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_app.py | 49 ------------------- shopfloor/tests/test_user.py | 65 +++++++++++++++++++++++++ 9 files changed, 124 insertions(+), 78 deletions(-) create mode 100644 shopfloor/services/user.py create mode 100644 shopfloor/tests/test_user.py diff --git a/shopfloor/README.rst b/shopfloor/README.rst index 12590ac995..db94b7f8d3 100644 --- a/shopfloor/README.rst +++ b/shopfloor/README.rst @@ -100,7 +100,7 @@ the Demo user. The key to use in the HTTP header ``API-KEY`` is: 72B044F7AC780DA Curl example:: - curl -X POST "http://localhost:8069/shopfloor/app/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC" + curl -X POST "http://localhost:8069/shopfloor/user/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC" Known issues / Roadmap ====================== diff --git a/shopfloor/readme/USAGE.rst b/shopfloor/readme/USAGE.rst index d628404b12..9f4832dd09 100644 --- a/shopfloor/readme/USAGE.rst +++ b/shopfloor/readme/USAGE.rst @@ -3,4 +3,4 @@ the Demo user. The key to use in the HTTP header ``API-KEY`` is: 72B044F7AC780DA Curl example:: - curl -X POST "http://localhost:8069/shopfloor/app/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC" + curl -X POST "http://localhost:8069/shopfloor/user/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC" diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 166173654b..b3d8962f25 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -6,6 +6,7 @@ # generic services from . import app +from . import user from . import menu from . import profile from . import scan_anything diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py index d0d2be4704..26edc2cfb0 100644 --- a/shopfloor/services/app.py +++ b/shopfloor/services/app.py @@ -10,20 +10,14 @@ class ShopfloorApp(Component): _name = "shopfloor.app" _usage = "app" _description = __doc__ - # TODO this is required only for `menu` and not for `user_config` - # Maybe we should split them. - _requires_header_profile = True + # TODO: maybe rename to `config` or `app_config` + # as this is not related to current user conf def user_config(self): profiles_comp = self.component("profile") profiles = profiles_comp._to_json(profiles_comp._search()) return self._response(data={"profiles": profiles}) - def menu(self): - menu_comp = self.component("menu") - menus = menu_comp._to_json(menu_comp._search()) - return self._response(data={"menus": menus}) - class ShopfloorAppValidator(Component): """Validators for the Application endpoints""" @@ -35,9 +29,6 @@ class ShopfloorAppValidator(Component): def user_config(self): return {} - def menu(self): - return {} - class ShopfloorAppValidatorResponse(Component): """Validators for the Application endpoints responses""" @@ -60,18 +51,3 @@ def user_config(self): }, } ) - - def menu(self): - menu_return_validator = self.component("menu.validator.response") - return self._response_schema( - { - "menus": { - "type": "list", - "required": True, - "schema": { - "type": "dict", - "schema": menu_return_validator._record_schema, - }, - }, - } - ) diff --git a/shopfloor/services/user.py b/shopfloor/services/user.py new file mode 100644 index 0000000000..634264dddb --- /dev/null +++ b/shopfloor/services/user.py @@ -0,0 +1,52 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.component.core import Component + + +class ShopfloorUser(Component): + """Generic endpoints for user specific info.""" + + _inherit = "base.shopfloor.service" + _name = "shopfloor.user" + _usage = "user" + _description = __doc__ + _requires_header_profile = True + + def menu(self): + menu_comp = self.component("menu") + menus = menu_comp._to_json(menu_comp._search()) + return self._response(data={"menus": menus}) + + +class ShopfloorUserValidator(Component): + """Validators for the User endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.user.validator" + _usage = "user.validator" + + def menu(self): + return {} + + +class ShopfloorUserValidatorResponse(Component): + """Validators for the User endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.user.validator.response" + _usage = "user.validator.response" + + def menu(self): + menu_return_validator = self.component("menu.validator.response") + return self._response_schema( + { + "menus": { + "type": "list", + "required": True, + "schema": { + "type": "dict", + "schema": menu_return_validator._record_schema, + }, + }, + } + ) diff --git a/shopfloor/static/description/index.html b/shopfloor/static/description/index.html index 62e8f7eade..cc20fd6ecc 100644 --- a/shopfloor/static/description/index.html +++ b/shopfloor/static/description/index.html @@ -450,7 +450,7 @@

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 575ef7fba1..3a8e6d7fdb 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -1,4 +1,5 @@ from . import test_app +from . import test_user from . import test_menu from . import test_openapi from . import test_profile diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py index 11088781ad..f9b4f514be 100644 --- a/shopfloor/tests/test_app.py +++ b/shopfloor/tests/test_app.py @@ -36,52 +36,3 @@ def test_user_config(self): ], }, ) - - def test_menu_no_profile(self): - """Request /app/menu""" - # Simulate the client asking the menu - response = self.service.dispatch("menu") - menus = self.env["shopfloor.menu"].search([]) - self.assert_response( - response, - data={ - "menus": [ - { - "id": menu.id, - "name": menu.name, - "scenario": menu.scenario, - "picking_types": [ - {"id": picking_type.id, "name": picking_type.name} - for picking_type in menu.picking_type_ids - ], - } - for menu in menus - ] - }, - ) - - def test_menu_by_profile(self): - """Request /app/menu w/ a specific profile""" - # Simulate the client asking the menu - menus = self.env["shopfloor.menu"].sudo().search([]) - menu = menus[0] - menu.profile_ids = self.profile - (menus - menu).profile_ids = self.profile2 - - response = self.service.dispatch("menu") - self.assert_response( - response, - data={ - "menus": [ - { - "id": menu.id, - "name": menu.name, - "scenario": menu.scenario, - "picking_types": [ - {"id": picking_type.id, "name": picking_type.name} - for picking_type in menu.picking_type_ids - ], - } - ] - }, - ) diff --git a/shopfloor/tests/test_user.py b/shopfloor/tests/test_user.py new file mode 100644 index 0000000000..066a3f2b3b --- /dev/null +++ b/shopfloor/tests/test_user.py @@ -0,0 +1,65 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .common import CommonCase + + +class UserCase(CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_hb_truck_demo") + cls.profile2 = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + + def setUp(self): + super().setUp() + with self.work_on_services(profile=self.profile) as work: + self.service = work.component(usage="user") + + def test_menu_no_profile(self): + """Request /user/menu""" + # Simulate the client asking the menu + response = self.service.dispatch("menu") + menus = self.env["shopfloor.menu"].search([]) + self.assert_response( + response, + data={ + "menus": [ + { + "id": menu.id, + "name": menu.name, + "scenario": menu.scenario, + "picking_types": [ + {"id": picking_type.id, "name": picking_type.name} + for picking_type in menu.picking_type_ids + ], + } + for menu in menus + ] + }, + ) + + def test_menu_by_profile(self): + """Request /user/menu w/ a specific profile""" + # Simulate the client asking the menu + menus = self.env["shopfloor.menu"].sudo().search([]) + menu = menus[0] + menu.profile_ids = self.profile + (menus - menu).profile_ids = self.profile2 + + response = self.service.dispatch("menu") + self.assert_response( + response, + data={ + "menus": [ + { + "id": menu.id, + "name": menu.name, + "scenario": menu.scenario, + "picking_types": [ + {"id": picking_type.id, "name": picking_type.name} + for picking_type in menu.picking_type_ids + ], + } + ] + }, + ) From 0198adc3d1a857b8fe2780ac6e17d42d4e1ae304 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 23 Oct 2020 10:03:58 +0200 Subject: [PATCH 413/940] shopfloor: add picking edit form --- shopfloor/services/__init__.py | 3 + shopfloor/services/forms/__init__.py | 2 + shopfloor/services/forms/form_mixin.py | 85 ++++++++++++++++++++++++ shopfloor/services/forms/picking_form.py | 77 +++++++++++++++++++++ shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_picking_form.py | 61 +++++++++++++++++ 6 files changed, 229 insertions(+) create mode 100644 shopfloor/services/forms/__init__.py create mode 100644 shopfloor/services/forms/form_mixin.py create mode 100644 shopfloor/services/forms/picking_form.py create mode 100644 shopfloor/tests/test_picking_form.py diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index b3d8962f25..7fe2f664ea 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -18,3 +18,6 @@ from . import delivery from . import location_content_transfer from . import single_pack_transfer + +# forms +from . import forms diff --git a/shopfloor/services/forms/__init__.py b/shopfloor/services/forms/__init__.py new file mode 100644 index 0000000000..8d328d097a --- /dev/null +++ b/shopfloor/services/forms/__init__.py @@ -0,0 +1,2 @@ +from . import form_mixin +from . import picking_form diff --git a/shopfloor/services/forms/form_mixin.py b/shopfloor/services/forms/form_mixin.py new file mode 100644 index 0000000000..1389082c80 --- /dev/null +++ b/shopfloor/services/forms/form_mixin.py @@ -0,0 +1,85 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _ + +from odoo.addons.component.core import AbstractComponent + + +class ShopfloorFormMixin(AbstractComponent): + """Allow to edit records. + """ + + _inherit = "base.shopfloor.service" + _name = "shopfloor.form.mixin" + _usage = "form_mixin" + _description = __doc__ + _expose_model = "" + _requires_header_profile = True + _requires_header_menu = False + + def get(self, _id): + record = self._get(_id) + return self._response_for_form(record) + + def update(self, _id, **params): + record = self._get(_id) + record.write(self._prepare_params(params, mode="update")) + return self._response_for_form(record, message=self._msg_record_updated(record)) + + def _response_for_form(self, record, **kw): + record_data = self._record_data(record) + form_data = self._form_data(record) + return self._response(data={"record": record_data, "form": form_data}, **kw) + + def _record_data(self, record): + raise NotImplementedError() + + def _form_data(self, record): + raise NotImplementedError() + + def _prepare_params(self, params, mode="update"): + return params + + def _msg_record_updated(self, record): + model = self.env["ir.model"]._get(record._name) + body = _("%s updated.") % model.name + return {"message_type": "info", "body": body} + + +class ShopfloorFormMixinValidator(AbstractComponent): + """Validators for the ShopfloorFormMixin endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.form.validator.mixin" + _usage = "form_mixin.validator" + + def get(self): + raise NotImplementedError() + + def update(self): + raise NotImplementedError() + + +class ShopfloorFormMixinValidatorResponse(AbstractComponent): + """Validators for the ShopfloorFormMixin endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.form.validator.response.mixin" + _usage = "form_mixin.validator.response" + + def get(self): + schema = { + "record": self.schemas._schema_dict_of(self._record_schema()), + "form": self.schemas._schema_dict_of(self._form_schema()), + } + return self._response_schema(schema) + + def update(self): + return self.get() + + def _record_schema(self): + raise NotImplementedError() + + def _form_schema(self): + raise NotImplementedError() diff --git a/shopfloor/services/forms/picking_form.py b/shopfloor/services/forms/picking_form.py new file mode 100644 index 0000000000..dffebbf9ad --- /dev/null +++ b/shopfloor/services/forms/picking_form.py @@ -0,0 +1,77 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.component.core import Component + + +class ShopfloorPickingForm(Component): + """Allow to modify a stock.picking. + + Editable fields: carrier_id. + """ + + _inherit = "shopfloor.form.mixin" + _name = "shopfloor.form.stock.picking" + _usage = "form_edit_stock_picking" + _description = __doc__ + _expose_model = "stock.picking" + + def _record_data(self, record): + # TODO: we use _detail here because it has the carrier info + # but is plenty of data we don't need -> add specific schema for forms + return self.data_detail.picking_detail(record) + + def _form_data(self, record): + data = {} + available_carriers = self._get_available_carriers(record) + data["carrier_id"] = { + "value": record.carrier_id.id, + "select_options": available_carriers.jsonify(["id", "name"]), + } + return data + + def _get_available_carriers(self, record): + company_carriers = self.env["delivery.carrier"].search( + ["|", ("company_id", "=", False), ("company_id", "=", record.company_id.id)] + ) + available_carriers = company_carriers.available_carriers(record.partner_id) + return available_carriers + + +class ShopfloorPickingFormValidator(Component): + """Validators for the ShopfloorPickingForm endpoints""" + + _inherit = "shopfloor.form.validator.mixin" + _name = "shopfloor.form.stock.picking.validator" + _usage = "form_edit_stock_picking.validator" + + def get(self): + return {} + + def update(self): + return { + "carrier_id": {"type": "integer", "required": True}, + } + + +class ShopfloorPickingFormValidatorResponse(Component): + """Validators for the ShopfloorPickingForm endpoints responses""" + + _inherit = "shopfloor.form.validator.response.mixin" + _name = "shopfloor.form.stock.picking.validator.response" + _usage = "form_edit_stock_picking.validator.response" + + def _form_schema(self): + return { + "carrier_id": self.schemas._schema_dict_of( + { + "value": {"type": "integer", "required": True}, + "select_options": self.schemas._schema_list_of( + self.schemas._simple_record() + ), + } + ) + } + + def _record_schema(self): + return self.schemas_detail.picking_detail() diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 3a8e6d7fdb..9df6449500 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -60,3 +60,4 @@ from . import test_move_action_assign from . import test_scan_anything from . import test_stock_split +from . import test_picking_form diff --git a/shopfloor/tests/test_picking_form.py b/shopfloor/tests/test_picking_form.py new file mode 100644 index 0000000000..0ab9cc3e8c --- /dev/null +++ b/shopfloor/tests/test_picking_form.py @@ -0,0 +1,61 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .common import CommonCase + + +class PickingFormCase(CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout") + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + cls.wh = cls.profile.warehouse_id + cls.picking_type = cls.menu.picking_type_ids + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = cls._create_picking(lines=[(cls.product_a, 10)]) + + def setUp(self): + super().setUp() + with self.work_on_services(profile=self.profile) as work: + self.service = work.component(usage="form_edit_stock_picking") + + def test_picking_form_get(self): + available_carriers = self.service._get_available_carriers(self.picking) + response = self.service.dispatch("get", _id=self.picking.id) + self.assert_response( + response, + data={ + "record": self.data_detail.picking_detail(self.picking), + "form": { + "carrier_id": { + "value": self.picking.carrier_id.id, + "select_options": available_carriers.jsonify(["id", "name"]), + } + }, + }, + ) + + def test_picking_form_update(self): + available_carriers = self.service._get_available_carriers(self.picking) + self.picking.carrier_id = available_carriers[0] + params = {"carrier_id": available_carriers[1].id} + response = self.service.dispatch("update", _id=self.picking.id, params=params) + self.assert_response( + response, + data={ + "record": self.data_detail.picking_detail(self.picking), + "form": { + "carrier_id": { + "value": self.picking.carrier_id.id, + "select_options": available_carriers.jsonify(["id", "name"]), + } + }, + }, + message=self.service._msg_record_updated(self.picking), + ) + self.assertRecordValues( + self.picking, [{"carrier_id": available_carriers[1].id}] + ) From afe22f8305c5d9751326291f8eb42346ca329517 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 27 Oct 2020 11:00:33 +0100 Subject: [PATCH 414/940] shopfloor: picking info add picking_type_code --- shopfloor/actions/data_detail.py | 1 + shopfloor/services/schema_detail.py | 5 +++++ shopfloor/tests/test_actions_data_detail.py | 1 + 3 files changed, 7 insertions(+) diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py index 2bef510cbf..e3024da604 100644 --- a/shopfloor/actions/data_detail.py +++ b/shopfloor/actions/data_detail.py @@ -43,6 +43,7 @@ def pickings_detail(self, record, **kw): @property def _picking_detail_parser(self): return self._picking_parser + [ + "picking_type_code", ("priority", self._select_value_to_label), "scheduled_date", ("picking_type_id:operation_type", ["id", "name"]), diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py index f57d40e33f..934c27b3d4 100644 --- a/shopfloor/services/schema_detail.py +++ b/shopfloor/services/schema_detail.py @@ -32,6 +32,11 @@ def picking_detail(self): schema = self.picking() schema.update( { + "picking_type_code": { + "type": "string", + "nullable": True, + "required": False, + }, "priority": {"type": "string", "nullable": True, "required": False}, "operation_type": self._schema_dict_of(self._simple_record()), "carrier": self._schema_dict_of(self._simple_record()), diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index 1595c0af85..c20b2f1dc0 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -181,6 +181,7 @@ def test_data_picking(self): }, "carrier": {"id": carrier.id, "name": carrier.name}, "move_lines": self.data_detail.move_lines(picking.move_line_ids), + "picking_type_code": "outgoing", } self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-05-13") self.assertDictEqual(data, expected) From bdb3c33d75d5829e4013ea79c735d6cd4179db3d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 27 Oct 2020 11:01:01 +0100 Subject: [PATCH 415/940] shopfloor: location fix reserved move lines compute --- shopfloor/models/stock_location.py | 2 +- shopfloor/tests/test_actions_data_detail.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py index bb4f74f099..b66146f7ae 100644 --- a/shopfloor/models/stock_location.py +++ b/shopfloor/models/stock_location.py @@ -37,7 +37,7 @@ def is_sublocation_of(self, others, func=any): def _get_reserved_move_lines(self): return self.env["stock.move.line"].search( [ - ("location_id", "=", self.id), + ("location_id", "child_of", self.id), ("product_uom_qty", ">", 0), ("state", "not in", ("done", "cancel")), ] diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index c20b2f1dc0..a87ad57fc9 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -87,7 +87,7 @@ def test_data_location(self): self.assert_schema(self.schema_detail.location_detail(), data) move_lines = self.env["stock.move.line"].search( [ - ("location_id", "=", location.id), + ("location_id", "child_of", location.id), ("product_qty", ">", 0), ("state", "not in", ("done", "cancel")), ] From 1ade125b90a78f70f046a5db8accdfcd2c298117 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 26 Oct 2020 13:51:28 +0100 Subject: [PATCH 416/940] Prevent migration failure when exception is not parsed as expected When the exceptions doesn't contain 'Error:' (e.g. BdbQuit), skip details instead of crashing. --- shopfloor/migrations/13.0.1.2.0/post-migration.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shopfloor/migrations/13.0.1.2.0/post-migration.py b/shopfloor/migrations/13.0.1.2.0/post-migration.py index be81c55e98..e7f946fbcb 100644 --- a/shopfloor/migrations/13.0.1.2.0/post-migration.py +++ b/shopfloor/migrations/13.0.1.2.0/post-migration.py @@ -43,7 +43,9 @@ def _compute_logs_new_values(env): else: new_vals[fname] = json.dumps(val, indent=4, sort_keys=True) if entry.error and not entry.exception_name: - new_vals.update(_get_exception_details(entry)) + exception_details = _get_exception_details(entry) + if exception_details: + new_vals.update(exception_details) entry.write(new_vals) From abb80456243f436ab54af58916a3c368748a0bd6 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 26 Oct 2020 15:25:01 +0100 Subject: [PATCH 417/940] shopfloor menus: add option to ignore unavailable putaway This option will be used in single pack transfer and location content transfer. --- shopfloor/actions/message.py | 6 +++++ shopfloor/models/shopfloor_menu.py | 39 ++++++++++++++++++++++++++++++ shopfloor/views/shopfloor_menu.xml | 15 ++++++++++++ 3 files changed, 60 insertions(+) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index e600f00c4e..db9850e8ad 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -227,6 +227,12 @@ def no_pending_operation_for_pack(self, pack): "body": _("No pending operation for package %s.") % pack.name, } + def no_putaway_destination_available(self): + return { + "message_type": "error", + "body": _("No putaway destination is available."), + } + def unrecoverable_error(self): return { "message_type": "error", diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 9429a8c142..c2a351282c 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -18,6 +18,11 @@ class ShopfloorMenu(models.Model): "location_content_transfer", ) + _scenario_allowing_ignore_no_putaway_available = ( + "single_pack_transfer", + "location_content_transfer", + ) + name = fields.Char(translate=True) sequence = fields.Integer() profile_ids = fields.Many2many( @@ -48,6 +53,16 @@ class ShopfloorMenu(models.Model): help="If you tick this box, this scenario will allow operator to move" " goods even if a reservation is made by a different operation type.", ) + ignore_no_putaway_available_is_possible = fields.Boolean( + compute="_compute_ignore_no_putaway_available_is_possible" + ) + ignore_no_putaway_available = fields.Boolean( + string="Ignore transfers when no put-away is available", + default=False, + help="If you tick this box, the transfer is reserved only " + "if the put-away can find a sublocation (when putaway destination " + "is different from the operation type's destination).", + ) active = fields.Boolean(default=True) def _selection_scenario(self): @@ -92,6 +107,30 @@ def _compute_unreserve_other_moves_is_possible(self): def onchange_unreserve_other_moves_is_possible(self): self.allow_unreserve_other_moves = self.unreserve_other_moves_is_possible + @api.depends("scenario", "picking_type_ids") + def _compute_ignore_no_putaway_available_is_possible(self): + for menu in self: + menu.ignore_no_putaway_available_is_possible = bool( + menu.scenario in self._scenario_allowing_ignore_no_putaway_available + ) + + @api.onchange("ignore_no_putaway_available_is_possible") + def onchange_ignore_no_putaway_available_is_possible(self): + self.ignore_no_putaway_available = self.ignore_no_putaway_available_is_possible + + @api.constrains("scenario", "picking_type_ids", "ignore_no_putaway_available") + def _check_ignore_no_putaway_available(self): + for menu in self: + if ( + menu.ignore_no_putaway_available + and not menu.ignore_no_putaway_available_is_possible + ): + raise exceptions.ValidationError( + _("Ignoring not found putaway is not allowed for menu {}.").format( + menu.name + ) + ) + @api.constrains("scenario", "picking_type_ids", "allow_unreserve_other_moves") def _check_allow_unreserve_other_moves(self): for menu in self: diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index 7777787659..1535799013 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -28,6 +28,11 @@ name="allow_unreserve_other_moves" attrs="{'invisible': [('unreserve_other_moves_is_possible', '=', False)]}" /> + + @@ -79,6 +84,16 @@ /> + + + + From 9a38be757eecbfb8c0c2a4293ea16497899d47ce Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 26 Oct 2020 15:26:59 +0100 Subject: [PATCH 418/940] single pack transfer: implement ignore no putaway available option When we start a single pack transfer, the moves (created or not by the menu) are assigned. When we use this menu with putaways, we can activate the option "ignore no putaway available". When no putaway location is available, the destination location remains the default one. If this option is active and the destination location didn't change: rollback the action_assign. This will prevent having move lines for things we don't know where to place. --- shopfloor/services/single_pack_transfer.py | 17 +++++ shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_single_pack_transfer.py | 2 +- .../test_single_pack_transfer_putaway.py | 76 +++++++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 shopfloor/tests/test_single_pack_transfer_putaway.py diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index e264be477a..4bddbd3bcc 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -133,6 +133,16 @@ def start(self, barcode, confirmation=False): return self._response_for_start( message=self.msg_store.no_pending_operation_for_pack(package) ) + if self.work.menu.ignore_no_putaway_available and self._no_putaway_available( + package_level + ): + # the putaway created a move line but no putaway was possible, so revert + # to the initial state + savepoint.rollback() + return self._response_for_start( + message=self.msg_store.no_putaway_destination_available() + ) + if package_level.is_done and not confirmation: return self._response_for_confirm_start( package_level, message=self.msg_store.already_running_ask_confirmation() @@ -146,6 +156,13 @@ def start(self, barcode, confirmation=False): return self._response_for_scan_location(package_level) + def _no_putaway_available(self, package_level): + move_lines = package_level.move_line_ids + base_locations = self.picking_types.default_location_dest_id + # when no putaway is found, the move line destination stays the + # default's of the picking type + return any(line.location_dest_id in base_locations for line in move_lines) + def _create_package_level(self, package): # this method can be called only if we have one picking type # (allow_move_create==True on menu) diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 9df6449500..415f01e90c 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -7,6 +7,7 @@ from . import test_actions_data from . import test_actions_data_detail from . import test_single_pack_transfer +from . import test_single_pack_transfer_putaway from . import test_cluster_picking_base from . import test_cluster_picking_batch from . import test_cluster_picking_select diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index e3d5916709..764e62f127 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -7,7 +7,7 @@ from .test_single_pack_transfer_base import SinglePackTransferCommonBase -class SinglePackTransferCase(SinglePackTransferCommonBase): +class TestSinglePackTransfer(SinglePackTransferCommonBase): @classmethod def setUpClassBaseData(cls, *args, **kwargs): super().setUpClassBaseData(*args, **kwargs) diff --git a/shopfloor/tests/test_single_pack_transfer_putaway.py b/shopfloor/tests/test_single_pack_transfer_putaway.py new file mode 100644 index 0000000000..aeb953a498 --- /dev/null +++ b/shopfloor/tests/test_single_pack_transfer_putaway.py @@ -0,0 +1,76 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_single_pack_transfer_base import SinglePackTransferCommonBase + + +class TestSinglePackTransferPutaway(SinglePackTransferCommonBase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.pallets_storage_type = cls.env.ref( + "stock_storage_type.package_storage_type_pallets" + ) + cls.main_pallets_location = cls.env.ref( + "stock_storage_type.stock_location_pallets" + ) + cls.reserve_pallets_locations = cls.env.ref( + "stock_storage_type.stock_location_pallets_reserve" + ) + cls.all_pallets_locations = ( + cls.main_pallets_location.leaf_location_ids + | cls.reserve_pallets_locations.leaf_location_ids + ) + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.package = cls.env["stock.quant.package"].create( + { + # this will parameterize the putaway to use pallet locations, + # and if not, it will stay on the picking type's default dest. + "package_storage_type_id": cls.pallets_storage_type.id, + } + ) + cls._update_qty_in_location(cls.shelf1, cls.product_a, 10, package=cls.package) + cls.menu.sudo().ignore_no_putaway_available = True + cls.menu.sudo().allow_move_create = True + + def test_normal_putaway(self): + """Ensure putaway is applied on moves""" + response = self.service.dispatch( + "start", params={"barcode": self.shelf1.barcode} + ) + self.assert_response( + response, next_state="scan_location", data=self.ANY, + ) + package_level_id = response["data"]["scan_location"]["id"] + package_level = self.env["stock.package_level"].browse(package_level_id) + self.assertIn(package_level.location_dest_id, self.all_pallets_locations) + + def test_ignore_no_putaway_available(self): + """Ignore no putaway available is activated on the menu + + In this case, when no putaway is possible, the changes + are rollbacked and an error is returned. + """ + for location in self.all_pallets_locations: + package = self.env["stock.quant.package"].create( + {"package_storage_type_id": self.pallets_storage_type.id} + ) + self._update_qty_in_location(location, self.product_a, 10, package=package) + + response = self.service.dispatch( + "start", params={"barcode": self.shelf1.barcode} + ) + self.assert_response( + response, + next_state="start", + message=self.service.msg_store.no_putaway_destination_available(), + ) + + package_levels = self.env["stock.package_level"].search( + [("package_id", "=", self.package.id)] + ) + # no package level created to move the package + self.assertFalse(package_levels) From fc36dcd9f1967d9c1d907bd4dc4cef2b8e825c73 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 26 Oct 2020 16:17:45 +0100 Subject: [PATCH 419/940] location transfer: implement ignore no putaway available option When we start a location content transfer, the moves (created or not by the menu) are assigned. When we use this menu with putaways, we can activate the option "ignore no putaway available". When no putaway location is available, the destination location remains the default one. If this option is active and the destination location didn't change: rollback the action_assign. This will prevent having move lines for things we don't know where to place. --- .../services/location_content_transfer.py | 16 +++ .../test_location_content_transfer_putaway.py | 104 ++++++++++++++++++ .../test_location_content_transfer_start.py | 2 +- 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 shopfloor/tests/test_location_content_transfer_putaway.py diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 35f8395fb4..66c30026ba 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -354,6 +354,16 @@ def scan_location(self, barcode): pickings = new_moves.mapped("picking_id") move_lines = new_moves.move_line_ids + if self.work.menu.ignore_no_putaway_available and self._no_putaway_available( + move_lines + ): + # the putaway created a move line but no putaway was possible, so revert + # to the initial state + savepoint.rollback() + return self._response_for_start( + message=self.msg_store.no_putaway_destination_available() + ) + if not pickings: return self._response_for_start( message=self.msg_store.location_empty(location) @@ -371,6 +381,12 @@ def scan_location(self, barcode): return self._router_single_or_all_destination(pickings) + def _no_putaway_available(self, move_lines): + base_locations = self.picking_types.default_location_dest_id + # when no putaway is found, the move line destination stays the + # default's of the picking type + return any(line.location_dest_id in base_locations for line in move_lines) + def _find_transfer_move_lines_domain(self, location): return [ ("location_id", "=", location.id), diff --git a/shopfloor/tests/test_location_content_transfer_putaway.py b/shopfloor/tests/test_location_content_transfer_putaway.py new file mode 100644 index 0000000000..5b82a61f41 --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_putaway.py @@ -0,0 +1,104 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_location_content_transfer_base import LocationContentTransferCommonCase + + +class TestLocationContentTransferPutaway(LocationContentTransferCommonCase): + """Tests with putaway when using option to ignore unavailable putaway locations + """ + + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.pallets_storage_type = cls.env.ref( + "stock_storage_type.package_storage_type_pallets" + ) + cls.main_pallets_location = cls.env.ref( + "stock_storage_type.stock_location_pallets" + ) + cls.reserve_pallets_locations = cls.env.ref( + "stock_storage_type.stock_location_pallets_reserve" + ) + cls.all_pallets_locations = ( + cls.main_pallets_location.leaf_location_ids + | cls.reserve_pallets_locations.leaf_location_ids + ) + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.package = cls.env["stock.quant.package"].create( + { + # this will parameterize the putaway to use pallet locations, + # and if not, it will stay on the picking type's default dest. + "package_storage_type_id": cls.pallets_storage_type.id, + } + ) + cls.package2 = cls.env["stock.quant.package"].create( + { + # this will parameterize the putaway to use pallet locations, + # and if not, it will stay on the picking type's default dest. + "package_storage_type_id": cls.pallets_storage_type.id, + } + ) + # create a location to be sure it's empty + cls.test_loc = ( + cls.env["stock.location"] + .sudo() + .create( + { + "location_id": cls.stock_location.id, + "name": "test", + "barcode": "test_loc", + } + ) + ) + cls._update_qty_in_location( + cls.test_loc, cls.product_a, 10, package=cls.package + ) + cls._update_qty_in_location( + cls.test_loc, cls.product_a, 10, package=cls.package2 + ) + cls.menu.sudo().allow_move_create = True + cls.menu.sudo().ignore_no_putaway_available = True + cls.menu.sudo().allow_unreserve_other_moves = True + + def test_normal_putaway(self): + """Ensure putaway is applied on moves""" + response = self.service.dispatch( + "scan_location", params={"barcode": self.test_loc.barcode} + ) + self.assert_response( + response, next_state="start_single", data=self.ANY, + ) + package_level_id = response["data"]["start_single"]["package_level"]["id"] + package_level = self.env["stock.package_level"].browse(package_level_id) + self.assertIn(package_level.location_dest_id, self.all_pallets_locations) + + def test_ignore_no_putaway_available(self): + """Ignore no putaway available is activated on the menu + + In this case, when no putaway is possible, the changes + are rollbacked and an error is returned. + """ + for location in self.all_pallets_locations: + package = self.env["stock.quant.package"].create( + {"package_storage_type_id": self.pallets_storage_type.id} + ) + self._update_qty_in_location(location, self.product_a, 10, package=package) + + response = self.service.dispatch( + "scan_location", params={"barcode": self.test_loc.barcode} + ) + self.assert_response( + response, + next_state="start", + message=self.service.msg_store.no_putaway_destination_available(), + ) + + package_levels = self.env["stock.package_level"].search( + [("package_id", "in", (self.package.id, self.package2.id))] + ) + # no package level created to move the package + self.assertFalse(package_levels) diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py index 29d0470d16..c193fd0316 100644 --- a/shopfloor/tests/test_location_content_transfer_start.py +++ b/shopfloor/tests/test_location_content_transfer_start.py @@ -4,7 +4,7 @@ from .test_location_content_transfer_base import LocationContentTransferCommonCase -class LocationContentTransferStartCase(LocationContentTransferCommonCase): +class TestLocationContentTransferStart(LocationContentTransferCommonCase): """Tests for start state and recover Endpoints: From ecd2168646cdead7c9711c139f6803d24fde5aab Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 27 Oct 2020 08:26:08 +0100 Subject: [PATCH 420/940] shopfloor menus: Remove editable list view The list view has now too many options, show them and edit only in form view. --- shopfloor/views/shopfloor_menu.xml | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index 1535799013..552c2e7193 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -4,7 +4,7 @@ shopfloor menu tree shopfloor.menu - + @@ -18,21 +18,6 @@ widget="many2many_tags" options="{'no_create': 1}" /> - - - - - - From 7b60c5839f0bcfa481c08fac44a08b2eb9e5bdc7 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 27 Oct 2020 11:32:47 +0100 Subject: [PATCH 421/940] shopfloor: bump 13.0.1.3.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index f7d6b4a123..0adc6d0d30 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "13.0.1.2.0", + "version": "13.0.1.3.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 79a5469ccee61ab159417b0c0b94c0ca51f14b5f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 30 Oct 2020 12:02:52 +0100 Subject: [PATCH 422/940] location transfer: allow to scan source package on lines When move lines have no package level (e.g. we use the "Open Package" button), they still have the package in "package_id". Users should be able to scan this package to identify that they want to take the products from there (they may have no label on products). --- shopfloor/services/location_content_transfer.py | 7 +++++++ shopfloor/tests/test_location_content_transfer_single.py | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 66c30026ba..c2a8dbe4c2 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -575,6 +575,13 @@ def scan_line(self, location_id, move_line_id, barcode): ) search = self.actions_for("search") + + package = search.package_from_scan(barcode) + if package and move_line.package_id == package: + # In case we have a source package but no package level because if + # we have a package level, we would use "scan_package". + return self._response_for_scan_destination(location, move_line) + product = search.product_from_scan(barcode) if product and product == move_line.product_id: if product.tracking in ("lot", "serial"): diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py index 74f4ecc1f5..1a4c2daeca 100644 --- a/shopfloor/tests/test_location_content_transfer_single.py +++ b/shopfloor/tests/test_location_content_transfer_single.py @@ -231,6 +231,11 @@ def _test_scan_line_ok(self, move_line, barcode): ) self.assert_response_scan_destination(response, move_line) + def test_scan_line_package_ok(self): + move_line = self.picking2.move_line_ids[0] + package = move_line.package_id = self.env["stock.quant.package"].create({}) + self._test_scan_line_ok(move_line, package.name) + def test_scan_line_product_ok(self): move_line = self.picking2.move_line_ids[0] # check we selected the good line From bcbf642bdb4e867ddf89ba8d0e3d6564a2583b21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 2 Nov 2020 10:27:04 +0100 Subject: [PATCH 423/940] shopfloor: fix singleton error --- shopfloor/models/stock_move_line.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py index bd8b2c3de3..f6105e4d55 100644 --- a/shopfloor/models/stock_move_line.py +++ b/shopfloor/models/stock_move_line.py @@ -89,14 +89,13 @@ def _split_pickings_from_source_location(self): "backorder_id": picking.id, } ) - pickings.message_post( - body=_( - 'The backorder %s has been created.' - ) - % (new_picking.id, new_picking.name) - ) + message = _( + 'The backorder %s has been created.' + ) % (new_picking.id, new_picking.name) + for pick in pickings: + pick.message_post(body=message) new_moves.write({"picking_id": new_picking.id}) new_moves.mapped("move_line_ids").write({"picking_id": new_picking.id}) new_moves.move_line_ids.package_level_id.write( From 9cb65291b94bd77204dabed69368d7ab3a5c9968 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Tue, 3 Nov 2020 13:45:02 +0000 Subject: [PATCH 424/940] [UPD] Update shopfloor.pot --- shopfloor/i18n/shopfloor.pot | 1422 ++++++++++++++++++++++++++++++++++ 1 file changed, 1422 insertions(+) create mode 100644 shopfloor/i18n/shopfloor.pot diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot new file mode 100644 index 0000000000..0ffe4b6c66 --- /dev/null +++ b/shopfloor/i18n/shopfloor.pot @@ -0,0 +1,1422 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 13.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: shopfloor +#: code:addons/shopfloor/services/forms/form_mixin.py:0 +#, python-format +msgid "%s updated." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "A destination package is required." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/change_package_lot.py:0 +#, python-format +msgid "A draft inventory has been created for control." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_zero_check +msgid "Activate Zero Check" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__active +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__active +msgid "Active" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_priority_postpone_mixin +msgid "Adds shopfloor priority/postpone fields" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "All packages processed." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_move_create +msgid "Allow Move Creation" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves +msgid "Allow to process reserved quantities" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_form_view +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_profile_form_view +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_profile_search_view +msgid "Archived" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Are you sure?" +msgstr "" + +#. module: shopfloor +#: model:ir.actions.server,name:shopfloor.ir_cron_autovacuum_shopfloor_log_ir_actions_server +#: model:ir.cron,cron_name:shopfloor.ir_cron_autovacuum_shopfloor_log +#: model:ir.cron,name:shopfloor.ir_cron_autovacuum_shopfloor_log +msgid "Auto-vacuum Shopfloor Logs" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Barcode does not match with {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Barcode not found" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking_batch +msgid "Batch Transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Batch Transfer complete" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Batch Transfer line done" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Bin %s doesn't exist" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Canceled, you can scan a new pack." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Cannot change to lot {} which is entirely picked." +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_checkout +#: model:stock.picking.type,name:shopfloor.picking_type_checkout_demo +msgid "Checkout" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_res_partner__shopfloor_packing_info +#: model:ir.model.fields,field_description:shopfloor.field_res_users__shopfloor_packing_info +msgid "Checkout Packing Information" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_cluster_picking +#: model:stock.picking.type,name:shopfloor.picking_type_cluster_picking_demo +msgid "Cluster Picking" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Confirm location change from %s to %s?" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_res_partner +msgid "Contact" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Content transfer to {} completed" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Content transferred from {} to {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Control stock issue in location {} for {}" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_uid +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__create_uid +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__create_uid +msgid "Created by" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move.py:0 +#, python-format +msgid "" +"Created from backorder %s." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_date +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__create_date +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__create_date +msgid "Created on" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Creation of moves is not allowed for menu {}." +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Date" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_delivery +#: model:stock.picking.type,name:shopfloor.picking_type_delivery_demo +msgid "Delivery" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__display_name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__display_name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__display_name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__display_name +msgid "Display Name" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__shopfloor_display_packing_info +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_display_packing_info +msgid "Display customer packing info" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__error +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view +msgid "Error" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__exception_name +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Exception" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__exception_message +msgid "Exception Message" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Exception message" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__state__failed +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Failed" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_zero_check +msgid "" +"For Shopfloor scenarios using it (Cluster Picking, Zone Picking, Discrete " +"order Picking), the zero check step will be activated when a location " +"becomes empty after a move." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__shopfloor_display_packing_info +#: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_display_packing_info +msgid "" +"For the Shopfloor Checkout/Packing scenarios to display the customer packing" +" info." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__location_id +msgid "From" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__functional +msgid "Functional" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Functional errors" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Group By" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__headers +msgid "Headers" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__id +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__id +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__id +msgid "ID" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__ignore_no_putaway_available +msgid "" +"If you tick this box, the transfer is reserved only if the put-away can find" +" a sublocation (when putaway destination is different from the operation " +"type's destination)." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves +msgid "" +"If you tick this box, this scenario will allow operator to move goods even " +"if a reservation is made by a different operation type." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available_is_possible +msgid "Ignore No Putaway Available Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available +msgid "Ignore transfers when no put-away is available" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Ignoring not found putaway is not allowed for menu {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_inventory +msgid "Inventory" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_location +msgid "Inventory Locations" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log____last_update +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu____last_update +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin____last_update +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile____last_update +msgid "Last Modified on" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_uid +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__write_uid +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_date +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__write_date +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__write_date +msgid "Last Updated on" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/completion_info.py:0 +#, python-format +msgid "" +"Last operation of transfer {}. Next operation ({}) is ready to proceed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Line cancelled" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lines have different destination location." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location %s doesn't contain any package." +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_location_content_transfer +#: model:stock.picking.type,name:shopfloor.picking_type_location_content_transfer_demo +msgid "Location Content Transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location not allowed here." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location {} empty" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Logs generated today" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Lot is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Lot {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lot {} is for another product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lot {} replaced by lot {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Lot: " +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_menu +msgid "Menu displayed in the scanner application" +msgstr "" + +#. module: shopfloor +#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_menu +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__menu_ids +#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_menu +msgid "Menus" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_profile__menu_ids +msgid "Menus visible for this profile" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__move_create_is_possible +msgid "Move Create Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__move_line_ids +msgid "Move Line" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__move_line_count +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__move_line_count +msgid "Move Line Count" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "Move lines processed have to share the same source location." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__name +msgid "Name" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Negative quantity not allowed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "New move lines cannot be assigned: canceled." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No lines to process." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No location found for this barcode." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No lot found among current transfers." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No lot found for {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "No more work to do, please create a new batch transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No operation type found for this menu and profile." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/service.py:0 +#, python-format +msgid "No operation types configured on menu {} for warehouse {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No package or lot found for barcode {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No pending operation for package %s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found among current transfers." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No putaway destination is available." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No quantity has been processed, unable to complete the transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "No valid package to select." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Not a valid destination package" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Not all lines have been processed with full quantity. Do you confirm partial" +" operation?" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "" +"Not allowed to pack more than the quantity, the value has been changed to " +"the maximum." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__picking_type_ids +msgid "Operation Types" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Operation already processed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Operation's already running. Would you like to take it over?" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Package cancelled" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package has been opened. You can move partial quantities." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "Package level has to be in draft" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_quant_package.py:0 +#, python-format +msgid "Package name must be unique!" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Package {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} cannot be picked, already moved by transfer {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} cannot be used: {} " +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "" +"Package {} does not contain available product {}, cannot replace package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} has a different content." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "Package {} has been partially picked in another location" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} is already used." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} is not available in transfer {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} is not empty." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Package {} is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} replaced by package {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_quant_package +msgid "Packages" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Packaging changed on package {}" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__shopfloor_packing_info +msgid "Packing information" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view +msgid "Parameters" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__params +msgid "Params" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/change_package_lot.py:0 +#, python-format +msgid "Pick: stock issue on lot: {} found in {}" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__picking_count +msgid "Picking Count" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking_type +msgid "Picking Type" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Picking has already been started in this location in transfer(s): {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Picking type {} complete." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__planned_move_line_ids +msgid "Planned Move Line" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Processing reserved quantities is not allowed for menu {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_move_line +msgid "Product Moves (Stock Move Line)" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Product is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Product tracked by lot, please scan one." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Product {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Product(s) packed in {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Product(s) processed as raw product(s)" +msgstr "" + +#. module: shopfloor +#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_profile +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__profile_ids +#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_profile +msgid "Profiles" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_quant +msgid "Quants" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/scan_anything.py:0 +#, python-format +msgid "" +"Record not found.\n" +"We've tried with the following types: {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Recovered previous session." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Remaining raw product not packed, proceed anyway?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__request_method +msgid "Request Method" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__request_url +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Request URL" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__reserved_move_line_ids +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__reserved_move_line_ids +msgid "Reserved Move Line" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Restart the operation, someone has canceled it." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__result +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view +msgid "Result" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF Priority" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF checkout done" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF unloaded" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Scan the destination location" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "Scan the package" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scenario +msgid "Scenario" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_form_view +msgid "Scenario Options" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "" +"Scenario `{}` require(s) 'Move Entire Packages' to be enabled.\n" +"These type(s) do not satisfy this constraint: \n" +"{}.\n" +"Please, adjust your configuration." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__sequence +msgid "Sequence" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several lines found in %s, please scan one." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several lots found in %s, please scan a lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several operation types found for this menu and profile." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several packages found in %s, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several products found in %s, please scan a product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "" +"Several transfers found, please scan a package or select a transfer " +"manually." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__severe +msgid "Severe" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Severe errors" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__severity +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Severity" +msgstr "" + +#. module: shopfloor +#: model:ir.ui.menu,name:shopfloor.menu_shopfloor_settings +#: model_terms:ir.ui.view,arch_db:shopfloor.res_partner_shopfloor_form +#: model_terms:ir.ui.view,arch_db:shopfloor.view_location_form +#: model_terms:ir.ui.view,arch_db:shopfloor.view_picking_type_form +msgid "Shopfloor" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_checkout_done +msgid "Shopfloor Checkout Done" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_log +msgid "Shopfloor Logging" +msgstr "" + +#. module: shopfloor +#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_log +#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_log +msgid "Shopfloor Logs" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_menu_ids +msgid "Shopfloor Menus" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__shopfloor_picking_sequence +msgid "Shopfloor Picking Sequence" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_postponed +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_postponed +msgid "Shopfloor Postponed" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_priority +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_priority +msgid "Shopfloor Priority" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_unloaded +msgid "Shopfloor Unloaded" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_user_id +msgid "Shopfloor User" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_profile +msgid "Shopfloor profile settings" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_single_pallet_transfer +#: model:stock.picking.type,name:shopfloor.picking_type_single_pallet_transfer_demo +msgid "Single Pallet Transfer" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_move_create +msgid "" +"Some scenario may create move(s) when a product or package is scanned and no" +" move already exists. Any new move is created in the selected operation " +"type, so it can be active only when one type is selected." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__source_move_line_ids +msgid "Source Move Line" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__package_id +msgid "Source Package" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__state +msgid "State" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Status" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_move +msgid "Stock Move" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_package_level +msgid "Stock Package Level" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__picking_id +msgid "Stock Picking" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__state__success +msgid "Success" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_postponed +#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_postponed +msgid "" +"Technical field. Indicates if the operation has been postponed in a barcode " +"scenario." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__move_line_count +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__move_line_count +msgid "Technical field. Indicates number of move lines included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__picking_count +msgid "Technical field. Indicates number of transfers included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__total_weight +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__total_weight +msgid "Technical field. Indicates total weight of transfers included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__planned_move_line_ids +msgid "Technical field. Move lines for which destination is this package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__move_line_ids +msgid "Technical field. Move lines moving this package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_priority +#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_priority +msgid "Technical field. Overrides operation priority in barcode scenario." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "" +"The backorder %s has been created." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "The destination bin {} is not empty, please take another." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The pack has been moved, you can scan a new pack." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s doesn't exist" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_location__shopfloor_picking_sequence +msgid "" +"The picking done in Shopfloor scenarios will respect this order. The " +"sequence is a char so it can be composed of fields such as 'corridor-rack-" +"side-level'. Pay attention to the padding ('09' is before '19', '9' is not)." +" It is recommended to use an Export then an Import to populate this field " +"using a spreadsheet." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/service.py:0 +#, python-format +msgid "The record %s %s does not exist" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The record you were working on does not exist anymore." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__picking_id +msgid "The stock operation where the packing has been made" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "This batch cannot be selected." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This line has a package, please select the package instead." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This line is not available in transfer {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "This location content can't be moved at once." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "This location content can't be moved using this menu." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This lot does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This lot is part of a package with other products, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This lot is part of multiple packages, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This operation does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This package does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This product is part of a package with other products, please scan a " +"package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product is part of multiple packages, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This transfer does not exist or is not available anymore." +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Today" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__total_weight +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__total_weight +msgid "Total Weight" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking +msgid "Transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} complete" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} done" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} is not available." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Units replaced by package {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Unrecoverable error, please restart." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unreserve_other_moves_is_possible +msgid "Unreserve Other Moves Is Possible" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "User" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__profile_ids +msgid "Visible for these profiles" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__warehouse_id +msgid "Warehouse" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__warning +msgid "Warning" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Warning errors" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "Wrong bin" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot move this using this menu." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot place it here" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot work on a package (%s) outside of locations: %s" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You must not pick more than {} units." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Zero check issue on location {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Zero check issue on location {} ({})" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_zone_picking +#: model:stock.picking.type,name:shopfloor.picking_type_zone_picking_demo +msgid "Zone Picking" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "" +"{picking.name} stock correction in location {location.name} for " +"{product_desc}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "{} {} put in {}" +msgstr "" From 46950f8ea1f58b1b6962c0ae8ceba664645d95eb Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 3 Nov 2020 14:23:23 +0000 Subject: [PATCH 425/940] [ADD] icon.png --- shopfloor/static/description/icon.png | Bin 0 -> 9455 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 shopfloor/static/description/icon.png diff --git a/shopfloor/static/description/icon.png b/shopfloor/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 From 963bff9650983e8910f25b37219a26f5d4fea817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 4 Nov 2020 09:07:11 +0100 Subject: [PATCH 426/940] shopfloor, zone picking: add a test to unload partial moves --- shopfloor/tests/test_zone_picking_base.py | 76 +++++++++++++++---- shopfloor/tests/test_zone_picking_start.py | 15 +++- .../tests/test_zone_picking_unload_all.py | 67 ++++++++++++++++ ...est_zone_picking_unload_set_destination.py | 26 +++---- 4 files changed, 157 insertions(+), 27 deletions(-) diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index e6fdd4ec0c..2d4eb6904e 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -75,6 +75,17 @@ def setUpClassBaseData(cls, *args, **kwargs): } ) ) + cls.zone_sublocation5 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location 5", + "location_id": cls.zone_location.id, + "barcode": "ZONE_SUBLOCATION_5", + } + ) + ) cls.packing_sublocation_a = ( cls.env["stock.location"] .sudo() @@ -123,6 +134,32 @@ def setUpClassBaseData(cls, *args, **kwargs): } ) ) + cls.product_g = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product G", + "type": "product", + "default_code": "G", + "barcode": "G", + "weight": 3, + } + ) + ) + cls.product_h = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product H", + "type": "product", + "default_code": "H", + "barcode": "H", + "weight": 3, + } + ) + ) products = ( cls.product_a + cls.product_b @@ -130,6 +167,8 @@ def setUpClassBaseData(cls, *args, **kwargs): + cls.product_d + cls.product_e + cls.product_f + + cls.product_g + + cls.product_h ) for product in products: cls.env["stock.putaway.rule"].sudo().create( @@ -140,31 +179,42 @@ def setUpClassBaseData(cls, *args, **kwargs): } ) + # 1 product in a package available in zone_sublocation1 cls.picking1 = picking1 = cls._create_picking(lines=[(cls.product_a, 10)]) - cls.picking2 = picking2 = cls._create_picking( - lines=[(cls.product_b, 10), (cls.product_c, 10)] - ) - cls.picking3 = picking3 = cls._create_picking(lines=[(cls.product_d, 10)]) - cls.picking4 = picking4 = cls._create_picking(lines=[(cls.product_e, 10)]) - cls.picking5 = picking5 = cls._create_picking( - lines=[(cls.product_b, 10), (cls.product_f, 10)] - ) - cls.pickings = picking1 | picking2 | picking3 | picking4 | picking5 cls._fill_stock_for_moves( picking1.move_lines, in_package=True, location=cls.zone_sublocation1 ) + # 2 products with lots available in zone_sublocation2 + cls.picking2 = picking2 = cls._create_picking( + lines=[(cls.product_b, 10), (cls.product_c, 10)] + ) cls._fill_stock_for_moves( picking2.move_lines, in_lot=True, location=cls.zone_sublocation2 ) + # 1 product (no package, no lot) available in zone_sublocation3 + cls.picking3 = picking3 = cls._create_picking(lines=[(cls.product_d, 10)]) cls._fill_stock_for_moves(picking3.move_lines, location=cls.zone_sublocation3) - cls._fill_stock_for_moves( - picking5.move_lines, in_package=True, location=cls.zone_sublocation4 - ) + # 1 product, available in zone_sublocation3 and zone_sublocation4 # Put product_e quantities in two different source locations to get # two stock move lines (6 and 4 to satisfy 10 qties) + cls.picking4 = picking4 = cls._create_picking(lines=[(cls.product_e, 10)]) cls._update_qty_in_location(cls.zone_sublocation3, cls.product_e, 6) cls._update_qty_in_location(cls.zone_sublocation4, cls.product_e, 4) - # cls._fill_stock_for_moves(picking4.move_lines, location=cls.zone_sublocation3) + # 2 products in a package available in zone_sublocation4 + cls.picking5 = picking5 = cls._create_picking( + lines=[(cls.product_b, 10), (cls.product_f, 10)] + ) + cls._fill_stock_for_moves( + picking5.move_lines, in_package=True, location=cls.zone_sublocation4 + ) + # 2 products available in zone_sublocation5, but one is partially available + cls.picking6 = picking6 = cls._create_picking( + lines=[(cls.product_g, 6), (cls.product_h, 6)] + ) + cls._update_qty_in_location(cls.zone_sublocation5, cls.product_g, 6) + cls._update_qty_in_location(cls.zone_sublocation5, cls.product_h, 3) + + cls.pickings = picking1 | picking2 | picking3 | picking4 | picking5 | picking6 cls.pickings.action_assign() # Some records not related at all to the processed move lines cls.free_package = cls.env["stock.quant.package"].create( diff --git a/shopfloor/tests/test_zone_picking_start.py b/shopfloor/tests/test_zone_picking_start.py index 2e1ecdc242..2019e9969b 100644 --- a/shopfloor/tests/test_zone_picking_start.py +++ b/shopfloor/tests/test_zone_picking_start.py @@ -83,8 +83,21 @@ def test_data_for_zone(self): ) ], ) + expected_sub5 = dict( + self.data.location(self.zone_sublocation5), + operation_types=[ + dict( + op_type_data, + lines_count=2, + picking_count=1, + priority_lines_count=0, + priority_picking_count=0, + ) + ], + ) self.assertEqual( - zones_data, [expected_sub1, expected_sub2, expected_sub3, expected_sub4] + zones_data, + [expected_sub1, expected_sub2, expected_sub3, expected_sub4, expected_sub5], ) def test_select_zone(self): diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py index 38e0632339..23ec7800e8 100644 --- a/shopfloor/tests/test_zone_picking_unload_all.py +++ b/shopfloor/tests/test_zone_picking_unload_all.py @@ -220,6 +220,73 @@ def test_set_destination_all_ok(self): message=self.service.msg_store.buffer_complete(), ) + def test_set_destination_all_partial_qty_done_ok(self): + zone_location = self.zone_location + picking_type = self.picking6.picking_type_id + move_g = self.picking6.move_lines.filtered( + lambda m: m.product_id == self.product_g + ) + move_h = self.picking6.move_lines.filtered( + lambda m: m.product_id == self.product_h + ) + self.assertEqual(move_g.state, "assigned") + self.assertEqual(move_h.state, "partially_available") + move_line_g = move_g.move_line_ids + move_line_h = move_h.move_line_ids + another_package = self.env["stock.quant.package"].create( + {"name": "ANOTHER_PACKAGE"} + ) + # set the destination package on lines + self.service._set_destination_package( + zone_location, + picking_type, + move_line_g, + move_line_g.product_uom_qty, + self.free_package, + ) + self.service._set_destination_package( + zone_location, + picking_type, + move_line_h, + move_line_h.product_uom_qty, # partial qty + another_package, + ) + # set destination location for all lines in the buffer + response = self.service.dispatch( + "set_destination_all", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": self.packing_location.barcode, + }, + ) + # check data + # picking validated + picking_validated = self.picking6.backorder_ids + self.assertEqual(picking_validated.state, "done") + self.assertEqual(picking_validated.move_line_ids, move_line_g | move_line_h) + self.assertEqual(move_line_g.state, "done") + self.assertEqual(move_line_g.qty_done, 6) + self.assertEqual(move_line_h.state, "done") + self.assertEqual(move_line_h.qty_done, 3) + # current picking (backorder) + self.assertEqual(self.picking6.state, "confirmed") + self.assertEqual(self.picking6.move_lines.product_id, self.product_h) + self.assertEqual(self.picking6.move_lines.product_uom_qty, 3) + self.assertFalse(self.picking6.move_line_ids) + # buffer should be empty + buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) + self.assertFalse(buffer_lines) + # check response + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.buffer_complete(), + ) + def test_set_destination_all_location_not_allowed(self): zone_location = self.zone_location picking_type = self.picking1.picking_type_id diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py index 0e85617345..6108454386 100644 --- a/shopfloor/tests/test_zone_picking_unload_set_destination.py +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -13,21 +13,21 @@ class ZonePickingUnloadSetDestinationCase(ZonePickingCommonCase): @classmethod def setUpClassBaseData(cls, *args, **kwargs): super().setUpClassBaseData(*args, **kwargs) - cls.product_g = ( + cls.product_z = ( cls.env["product.product"] .sudo() .create( { - "name": "Product G", + "name": "Product Z", "type": "product", - "default_code": "G", - "barcode": "G", + "default_code": "Z", + "barcode": "Z", "weight": 7, } ) ) - cls.picking_g = cls._create_picking(lines=[(cls.product_g, 40)]) - cls._update_qty_in_location(cls.zone_sublocation1, cls.product_g, 32) + cls.picking_z = cls._create_picking(lines=[(cls.product_z, 40)]) + cls._update_qty_in_location(cls.zone_sublocation1, cls.product_z, 32) def test_unload_set_destination_wrong_parameters(self): zone_location = self.zone_location @@ -322,10 +322,10 @@ def test_unload_set_destination_ok_buffer_not_empty(self): def test_unload_set_destination_partially_available_backorder(self): zone_location = self.zone_location - picking_type = self.picking_g.picking_type_id - self.assertEqual(self.picking_g.move_lines[0].product_uom_qty, 40) - self.picking_g.action_assign() - move_line = self.picking_g.move_line_ids + picking_type = self.picking_z.picking_type_id + self.assertEqual(self.picking_z.move_lines[0].product_uom_qty, 40) + self.picking_z.action_assign() + move_line = self.picking_z.move_line_ids self.assertEqual(move_line.product_uom_qty, 32) self.assertEqual(move_line.move_id.state, "partially_available") packing_sublocation = ( @@ -359,11 +359,11 @@ def test_unload_set_destination_partially_available_backorder(self): ) # check data # move line has been moved to a new picking - self.assertEqual(move_line.move_id.picking_id, self.picking_g.backorder_ids[0]) + self.assertEqual(move_line.move_id.picking_id, self.picking_z.backorder_ids[0]) # the old picking contains a new line w/ the rest of the qty # that couldn't be processed - self.assertEqual(self.picking_g.move_lines[0].product_uom_qty, 8) - self.assertEqual(self.picking_g.state, "confirmed") + self.assertEqual(self.picking_z.move_lines[0].product_uom_qty, 8) + self.assertEqual(self.picking_z.state, "confirmed") # the line has been processed self.assertEqual(move_line.location_dest_id, packing_sublocation) self.assertEqual(move_line.move_id.state, "done") From 98a228d2cf508fd8e524ed79d0f9fa1fd8e6bf48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 4 Nov 2020 12:19:06 +0100 Subject: [PATCH 427/940] shopfloor, zone picking: be able to unload partial moves --- shopfloor/models/stock_move.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py index 6c8f2abb4d..20473b6f26 100644 --- a/shopfloor/models/stock_move.py +++ b/shopfloor/models/stock_move.py @@ -44,6 +44,12 @@ def extract_and_action_done(self): to first extract some move lines in a separate move, then validate it with this method. """ + # Put remaining qty to process from partially available moves + # in their own move (which will be then 'confirmed') + partial_moves = self.filtered(lambda m: m.state == "partially_available") + for partial_move in partial_moves: + partial_move.split_other_move_lines(partial_move.move_line_ids) + # Process assigned moves moves = self.filtered(lambda m: m.state == "assigned") if not moves: return False From dd5ec332660ecd799aa6594151d129988f070d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 9 Nov 2020 17:07:00 +0100 Subject: [PATCH 428/940] [FIX] shopfloor, zone picking: do not ask the user to choose among lines When scanning a source location to find the corresponding move line, if the system find several corresponding lines (same product/package/lot), no need to ask the user to choose the line among them: just take the first (according to the current order). --- shopfloor/services/zone_picking.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index e61992cf2b..b7fa75166b 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -420,7 +420,9 @@ def list_move_lines(self, zone_location_id, picking_type_id, order="priority"): ) return self._response_for_select_line(zone_location, picking_type, move_lines) - def _scan_source_location(self, zone_location, picking_type, location): + def _scan_source_location( + self, zone_location, picking_type, location, order="priority" + ): """Return the move line related to the scanned `location`. The method tries to identify unambiguously a move line in the location @@ -439,9 +441,10 @@ def _scan_source_location(self, zone_location, picking_type, location): domain.append(("lot_id", "=", lot.id)) if package: domain.append(("package_id", "=", package.id)) - move_line = self.env["stock.move.line"].search(domain) - if len(move_line) == 1: - return move_line + move_lines = self.env["stock.move.line"].search(domain) + sort_keys_func, reverse = self._sort_key_move_lines(order) + move_lines = move_lines.sorted(sort_keys_func, reverse=reverse) + return first(move_lines) return False def _scan_source_package(self, zone_location, picking_type, package, order): @@ -498,7 +501,7 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit message=self.msg_store.location_not_allowed() ) move_line = self._scan_source_location( - zone_location, picking_type, location + zone_location, picking_type, location, order=order ) # if no move line, narrow the list of move lines on the scanned location if not move_line: From aa013dc1890f25acd131d14310caba16e84c86e1 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 10 Nov 2020 08:43:18 +0000 Subject: [PATCH 429/940] shopfloor 13.0.1.3.1 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 0adc6d0d30..0e5181d14e 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "13.0.1.3.0", + "version": "13.0.1.3.1", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From f958551f24c8ad5c5588a79ba74ab1527965e0ee Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 10 Nov 2020 09:25:45 +0000 Subject: [PATCH 430/940] shopfloor 13.0.1.3.2 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 0e5181d14e..2e74a0ffc1 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "13.0.1.3.1", + "version": "13.0.1.3.2", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 353761aacf760ec8e9d01a326fe22b103f40805c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 10 Nov 2020 16:55:19 +0100 Subject: [PATCH 431/940] [FIX] shopfloor, zone picking: do not process line processed by someone else First user scans the source location 'Zone sub-location 1' containing only one move line, then processes the next step 'set_line_destination'. The second user scans the same source location, and should not find any line. + since 9021c85c7085f883756ff64b797950a2192c3a5a has been merged, if no move line is found when scanning the source location, the message returned has to say that the location is empty. --- shopfloor/actions/message.py | 6 --- shopfloor/services/zone_picking.py | 9 +++- shopfloor/tests/test_zone_picking_base.py | 14 ++++++ .../tests/test_zone_picking_select_line.py | 43 +++++++++++++++++-- 4 files changed, 61 insertions(+), 11 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index db9850e8ad..90cdf2f174 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -175,12 +175,6 @@ def no_pack_in_location(self, location): "body": _("Location %s doesn't contain any package.") % location.name, } - def several_lines_in_location(self, location): - return { - "message_type": "warning", - "body": _("Several lines found in %s, please scan one.") % location.name, - } - def several_packs_in_location(self, location): return { "message_type": "warning", diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index b7fa75166b..e7183208b4 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -434,7 +434,12 @@ def _scan_source_location( package = quants.package_id if len(product) > 1 or len(lot) > 1 or len(package) > 1: return False - domain = [("location_id", "=", location.id)] + domain = [ + ("location_id", "=", location.id), + "|", + ("shopfloor_user_id", "=", False), + ("shopfloor_user_id", "=", self.env.uid), + ] if product: domain.append(("product_id", "=", product.id)) if lot: @@ -508,7 +513,7 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit response = self.list_move_lines(location.id, picking_type.id) return self._response( base_response=response, - message=self.msg_store.several_lines_in_location(location), + message=self.msg_store.location_empty(location), ) package = search.package_from_scan(barcode) if package: diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 2d4eb6904e..2f7661ba24 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -12,6 +12,20 @@ def setUpClassVars(cls, *args, **kwargs): cls.wh = cls.profile.warehouse_id cls.picking_type = cls.menu.picking_type_ids + @classmethod + def setUpClassUsers(cls): + super().setUpClassUsers() + Users = cls.env["res.users"].sudo().with_context(no_reset_password=True) + cls.stock_user2 = Users.create( + { + "name": "Paul Posichon", + "login": "paulposichon", + "email": "paul.posichon@example.com", + "notification_type": "inbox", + "groups_id": [(6, 0, [cls.env.ref("stock.group_stock_user").id])], + } + ) + @classmethod def setUpClassBaseData(cls, *args, **kwargs): super().setUpClassBaseData(*args, **kwargs) diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py index 30818b6c34..20851d25e0 100644 --- a/shopfloor/tests/test_zone_picking_select_line.py +++ b/shopfloor/tests/test_zone_picking_select_line.py @@ -118,9 +118,7 @@ def test_scan_source_barcode_location_several_move_lines(self): zone_location=self.zone_sublocation2, picking_type=self.picking_type, move_lines=move_lines, - message=self.service.msg_store.several_lines_in_location( - self.zone_sublocation2 - ), + message=self.service.msg_store.location_empty(self.zone_sublocation2), ) def test_scan_source_barcode_package(self): @@ -293,6 +291,45 @@ def test_scan_source_barcode_not_found(self): message=self.service.msg_store.barcode_not_found(), ) + def test_scan_source_multi_users(self): + """First user scans the source location 'Zone sub-location 1' containing + only one move line, then processes the next step 'set_line_destination'. + + The second user scans the same source location, and should not find any line. + """ + # The first user starts to process the only line available + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + # - scan source + response = self.service.scan_source( + zone_location.id, picking_type.id, self.zone_sublocation1.barcode, + ) + move_line = self.picking1.move_line_ids + self.assertEqual(response["next_state"], "set_line_destination") + # - set destination + self.service.set_destination( + zone_location.id, + picking_type.id, + move_line.id, + self.free_package.name, + move_line.product_uom_qty, + ) + self.assertEqual(move_line.shopfloor_user_id, self.env.user) + # The second user scans the same source location + env = self.env(user=self.stock_user2) + with self.work_on_services( + env=env, menu=self.menu, profile=self.profile + ) as work: + service = work.component(usage="zone_picking") + response = service.scan_source( + zone_location.id, picking_type.id, self.zone_sublocation1.barcode, + ) + self.assertEqual(response["next_state"], "select_line") + self.assertEqual( + response["message"], + self.service.msg_store.location_empty(self.zone_sublocation1), + ) + def test_prepare_unload_wrong_parameters(self): zone_location = self.zone_location picking_type = self.picking1.picking_type_id From 65280beb39397b63a9afeef15edc2680ff2fec79 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Thu, 12 Nov 2020 12:57:32 +0000 Subject: [PATCH 432/940] [UPD] Update shopfloor.pot --- shopfloor/i18n/shopfloor.pot | 6 ------ 1 file changed, 6 deletions(-) diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot index 0ffe4b6c66..1a4809fa37 100644 --- a/shopfloor/i18n/shopfloor.pot +++ b/shopfloor/i18n/shopfloor.pot @@ -929,12 +929,6 @@ msgstr "" msgid "Sequence" msgstr "" -#. module: shopfloor -#: code:addons/shopfloor/actions/message.py:0 -#, python-format -msgid "Several lines found in %s, please scan one." -msgstr "" - #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format From c44da4b131659c6c1dd26140282d2ffe16298181 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 12 Nov 2020 13:22:38 +0000 Subject: [PATCH 433/940] shopfloor 13.0.1.3.3 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 2e74a0ffc1..d71334a4a3 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "13.0.1.3.2", + "version": "13.0.1.3.3", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 3a8c7c754a417c7e7bd062de7800f3fc76045555 Mon Sep 17 00:00:00 2001 From: hparfr Date: Tue, 13 Oct 2020 12:28:20 +0200 Subject: [PATCH 434/940] shopfloor: api docs fix api_key_demo not found When the key is not found, it should not fail. --- shopfloor/services/service.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 5c39cf446c..a5252f2239 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -265,9 +265,10 @@ def _get_openapi_default_parameters(self): defaults = super()._get_openapi_default_parameters() # Normal users can't read an API key, ignore it using sudo() only # because it's a demo key. - demo_api_key = self.env.ref( - "shopfloor.api_key_demo", raise_if_not_found=False - ).sudo() + demo_api_key = self.env.ref("shopfloor.api_key_demo", raise_if_not_found=False) + if demo_api_key: + demo_api_key = demo_api_key.sudo() + service_params = [ { "name": "API-KEY", From 2536fb5605be10a50a42fd7f97205c8b49c570d8 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 13 Nov 2020 11:31:29 +0000 Subject: [PATCH 435/940] shopfloor 13.0.1.3.4 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index d71334a4a3..8c428c7dad 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "13.0.1.3.3", + "version": "13.0.1.3.4", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 8d7061e2fcc61ba9fa33e6ce6278eb517c341d25 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 13 Nov 2020 16:42:23 +0100 Subject: [PATCH 436/940] shopfloor: zone_picking fix group zone lines Zone lines counters should be grouped by operation type. itertools.groupby was failing somehow. In any case the new version is more eficient as we loop less on lines. --- shopfloor/services/zone_picking.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index e7183208b4..3475518b58 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -1,7 +1,7 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import functools -from itertools import groupby +from collections import defaultdict from odoo.fields import first from odoo.tools.float_utils import float_compare, float_is_zero @@ -265,19 +265,19 @@ def _data_for_select_zone(self, zones): res = [] for zone in zones: zone_data = self.data.location(zone) - zone_lines = self._zone_lines(zone).sorted( - key=lambda x: x.picking_id.picking_type_id - ) + zone_lines = self._zone_lines(zone) if not zone_lines: continue + lines_by_op_type = defaultdict(list) + for line in zone_lines: + lines_by_op_type[line.picking_id.picking_type_id].append(line) + zone_data["operation_types"] = [] - for picking_type, lines in groupby( - zone_lines, lambda line: line.picking_id.picking_type_id - ): - op_type = self.data.picking_type(picking_type) - op_type.update(self._counters_for_zone_lines(list(lines))) - zone_data["operation_types"].append(op_type) + for picking_type, lines in lines_by_op_type.items(): + op_type_data = self.data.picking_type(picking_type) + op_type_data.update(self._counters_for_zone_lines(lines)) + zone_data["operation_types"].append(op_type_data) res.append(zone_data) return res From 616575a2445dc3793cda0b18574ddf5ad5e717ab Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 17 Nov 2020 14:04:50 +0000 Subject: [PATCH 437/940] shopfloor 13.0.1.3.5 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 8c428c7dad..32de430245 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "13.0.1.3.4", + "version": "13.0.1.3.5", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From de7a8fb455dc4b47c0c555624482c65514f80926 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 17 Nov 2020 17:06:56 +0100 Subject: [PATCH 438/940] zone_picking: fix line sorting by schedule date --- shopfloor/services/zone_picking.py | 27 ++++--- .../tests/test_zone_picking_select_line.py | 79 ++++++++++++++++++- 2 files changed, 91 insertions(+), 15 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 3475518b58..ff0cece34b 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -317,22 +317,25 @@ def _find_location_move_lines( locations, picking_type, package, product, lot ) ) - sort_keys_func, reverse = self._sort_key_move_lines(order) - move_lines = move_lines.sorted(sort_keys_func, reverse=reverse) + sort_keys_func = self._sort_key_move_lines(order) + move_lines = move_lines.sorted(sort_keys_func) return move_lines @staticmethod def _sort_key_move_lines(order): - """Return a `(sort_keys_func, reverse)` tuple for move lines.""" + """Return a sorting function to order lines.""" + if order == "priority": - return lambda line: line.move_id.priority or "", True + # make prority negative to keep sorting ascending + return lambda line: ( + -int(line.move_id.priority or "0"), + line.move_id.date_expected, + ) elif order == "location": - return ( - lambda line: ( - line.location_id.shopfloor_picking_sequence or "", - line.location_id.name, - ), - False, + return lambda line: ( + line.location_id.shopfloor_picking_sequence or "", + line.location_id.name, + line.move_id.date_expected, ) def _find_buffer_move_lines_domain( @@ -447,8 +450,8 @@ def _scan_source_location( if package: domain.append(("package_id", "=", package.id)) move_lines = self.env["stock.move.line"].search(domain) - sort_keys_func, reverse = self._sort_key_move_lines(order) - move_lines = move_lines.sorted(sort_keys_func, reverse=reverse) + sort_keys_func = self._sort_key_move_lines(order) + move_lines = move_lines.sorted(sort_keys_func) return first(move_lines) return False diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py index 20851d25e0..bdc44beb5a 100644 --- a/shopfloor/tests/test_zone_picking_select_line.py +++ b/shopfloor/tests/test_zone_picking_select_line.py @@ -1,5 +1,7 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields + from .test_zone_picking_base import ZonePickingCommonCase @@ -12,12 +14,65 @@ class ZonePickingSelectLineCase(ZonePickingCommonCase): """ + def test_list_move_lines_order(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + self.zone_sublocation2.name = "AAA " + self.zone_sublocation2.name + + # Test by location + today = fields.Datetime.today() + future = fields.Datetime.add( + fields.Datetime.end_of(fields.Datetime.today(), "day"), days=2 + ) + # change date to lines in the same location + move1 = self.picking2.move_lines[0] + move1.write({"date_expected": today}) + move1_line = move1.move_line_ids[0] + move2 = self.picking2.move_lines[1] + move2.write({"date_expected": future}) + move2_line = move2.move_line_ids[0] + + move_lines = self.service._find_location_move_lines( + zone_location, picking_type, order="location" + ) + order_mapping = {line: i for i, line in enumerate(move_lines)} + self.assertTrue(order_mapping[move1_line] < order_mapping[move2_line]) + + # swap dates + move2.write({"date_expected": today}) + move1.write({"date_expected": future}) + move_lines = self.service._find_location_move_lines( + zone_location, picking_type, order="location" + ) + order_mapping = {line: i for i, line in enumerate(move_lines)} + self.assertTrue(order_mapping[move1_line] > order_mapping[move2_line]) + + # Test by priority + self.picking2.move_lines.write({"priority": "0"}) + (self.pickings - self.picking2).move_lines.write({"priority": "2"}) + move_lines = self.service._find_location_move_lines( + zone_location, picking_type, order="priority" + ) + order_mapping = {line: i for i, line in enumerate(move_lines)} + # picking2 lines stay at the end as they are low priority + # but move1_line comes before the other + self.assertTrue(order_mapping[move1_line] > len(move_lines) - 4) + self.assertTrue(order_mapping[move2_line] > len(move_lines) - 3) + # swap dates again + move2.write({"date_expected": future}) + move1.write({"date_expected": today}) + # and increase priority + self.picking2.move_lines.write({"priority": "3"}) + move_lines = self.service._find_location_move_lines( + zone_location, picking_type, order="priority" + ) + order_mapping = {line: i for i, line in enumerate(move_lines)} + self.assertEqual(order_mapping[move1_line], 0) + self.assertEqual(order_mapping[move2_line], 1) + def test_list_move_lines_order_by_location(self): zone_location = self.zone_location picking_type = self.picking1.picking_type_id - # Ensure that the second location is ordered before the first one - # to avoid "false-positive" checks - self.zone_sublocation2.name = "a " + self.zone_sublocation2.name response = self.service.dispatch( "list_move_lines", params={ @@ -33,6 +88,24 @@ def test_list_move_lines_order_by_location(self): response, zone_location, picking_type, move_lines, ) + def test_list_move_lines_order_by_priority(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "list_move_lines", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "order": "priority", + }, + ) + move_lines = self.service._find_location_move_lines( + zone_location, picking_type, order="priority" + ) + self.assert_response_select_line( + response, zone_location, picking_type, move_lines, + ) + def test_scan_source_wrong_parameters(self): zone_location = self.zone_location picking_type = self.picking1.picking_type_id From 5beeedef483eb94ea9b0e2a6899ede9168d92d16 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 17 Nov 2020 17:58:42 +0100 Subject: [PATCH 439/940] cluster_picking: fix line sorting by schedule date --- shopfloor/services/cluster_picking.py | 1 + .../tests/test_cluster_picking_select.py | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 33c3da1d3e..9e91d91df0 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -324,6 +324,7 @@ def _sort_key_lines(line): line.location_id.shopfloor_picking_sequence or "", line.location_id.name, -int(line.move_id.priority or 1), + line.move_id.date_expected, line.move_id.sequence, line.move_id.id, line.id, diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py index 48c6d9f60e..e1d7dc983d 100644 --- a/shopfloor/tests/test_cluster_picking_select.py +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -1,6 +1,8 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields + from .test_cluster_picking_base import ClusterPickingCommonCase @@ -263,6 +265,42 @@ def setUpClassBaseData(cls, *args, **kwargs): [[cls.BatchProduct(product=cls.product_a, quantity=1)]] ) cls._simulate_batch_selected(cls.batch, in_package=True) + cls.batch2 = cls._create_picking_batch( + [ + [cls.BatchProduct(product=cls.product_a, quantity=1)], + [cls.BatchProduct(product=cls.product_a, quantity=1)], + [cls.BatchProduct(product=cls.product_b, quantity=1)], + [cls.BatchProduct(product=cls.product_b, quantity=1)], + ] + ) + cls._simulate_batch_selected(cls.batch2, in_package=True) + + def test_lines_order(self): + batch = self.batch2 + picking1 = batch.picking_ids[0] + today = fields.Datetime.today() + future = fields.Datetime.add( + fields.Datetime.end_of(fields.Datetime.today(), "day"), days=2 + ) + # Change dates + move1 = picking1.move_lines[0] + move1_line = move1.move_line_ids[0] + move1.write({"date_expected": today}) + (batch.picking_ids.move_lines - move1).write({"date_expected": future}) + + move_lines = self.service._lines_for_picking_batch(batch) + order_mapping = {line: i for i, line in enumerate(move_lines)} + + # Today line comes first + self.assertEqual(order_mapping[move1_line], 0) + # swap dates + move1.write({"date_expected": future}) + (batch.picking_ids.move_lines - move1).write({"date_expected": today}) + + move_lines = self.service._lines_for_picking_batch(batch) + order_mapping = {line: i for i, line in enumerate(move_lines)} + self.assertEqual(order_mapping[move1_line], len(move_lines) - 1) + # TODO: we should test all the combo of keys affecting sorting. def test_confirm_start_ok(self): """User confirms she starts the selected picking batch (happy path)""" From 2fc798e66ccaa288b3d8567d65aba38372429e26 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 19 Nov 2020 11:04:08 +0000 Subject: [PATCH 440/940] shopfloor 13.0.1.3.6 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 32de430245..395cffed92 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "13.0.1.3.5", + "version": "13.0.1.3.6", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From ade45552b0c9791075c5cbf72ed7fa550b154316 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 19 Nov 2020 11:22:52 +0100 Subject: [PATCH 441/940] shopfloor: zone_picking test multiple line on scan source loc --- shopfloor/tests/common.py | 5 +- .../tests/test_zone_picking_select_line.py | 49 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index e3c7a056f6..d298be103e 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -302,7 +302,10 @@ def _fill_stock_for_moves( product_locations = {} package = None if in_package: - package = cls.env["stock.quant.package"].create({}) + if isinstance(in_package, models.BaseModel): + package = in_package + else: + package = cls.env["stock.quant.package"].create({}) for move in moves: key = (move.product_id, location or move.location_id) product_locations.setdefault(key, 0) diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py index bdc44beb5a..7c5eadeddd 100644 --- a/shopfloor/tests/test_zone_picking_select_line.py +++ b/shopfloor/tests/test_zone_picking_select_line.py @@ -170,6 +170,55 @@ def test_scan_source_barcode_location_one_move_line(self): move_line=move_line, ) + def test_scan_source_barcode_location_two_move_lines_same_product(self): + """Scan source: scanned location 'Zone sub-location 1' contains two lines. + + Lines have the same product/package/lot, + they get processed one after the other, + next step 'set_line_destination' expected. + """ + package = self.picking1.move_line_ids.mapped("package_id")[0] + new_picking = self._create_picking(lines=[(self.product_a, 20)]) + self._fill_stock_for_moves( + new_picking.move_lines, in_package=package, location=self.zone_sublocation1 + ) + new_picking.action_assign() + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "scan_source", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": self.zone_sublocation1.barcode, + }, + ) + move_line = self.picking1.move_line_ids + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + ) + # first line done + move_line.qty_done = move_line.product_uom_qty + # get the next one + response = self.service.dispatch( + "scan_source", + params={ + "zone_location_id": zone_location.id, + "picking_type_id": picking_type.id, + "barcode": self.zone_sublocation1.barcode, + }, + ) + move_line = new_picking.move_line_ids + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + ) + def test_scan_source_barcode_location_several_move_lines(self): """Scan source: scanned location 'Zone sub-location 2' contains two move lines, next step 'select_line' expected with the list of these From 0856268628c8781745518c1ff828b21a1badb51d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 19 Nov 2020 11:26:36 +0100 Subject: [PATCH 442/940] shopfloor: fix zone_picking._scan_source_location Lines from source location should respect the same domain as the one used to retrieve all available lines (eg: not done lines). --- shopfloor/services/zone_picking.py | 41 +++++++++++++++++------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index ff0cece34b..5ee98f00a2 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -282,7 +282,13 @@ def _data_for_select_zone(self, zones): return res def _find_location_move_lines_domain( - self, locations, picking_type=None, package=None, product=None, lot=None + self, + locations, + picking_type=None, + package=None, + product=None, + lot=None, + match_user=False, ): domain = [ ("location_id", "child_of", locations.ids), @@ -300,6 +306,12 @@ def _find_location_move_lines_domain( domain += [("product_id", "=", product.id)] if lot: domain += [("lot_id", "=", lot.id)] + if match_user: + domain += [ + "|", + ("shopfloor_user_id", "=", False), + ("shopfloor_user_id", "=", self.env.uid), + ] return domain def _find_location_move_lines( @@ -310,11 +322,12 @@ def _find_location_move_lines( product=None, lot=None, order="priority", + match_user=False, ): """Find lines that potentially need work in given locations.""" move_lines = self.env["stock.move.line"].search( self._find_location_move_lines_domain( - locations, picking_type, package, product, lot + locations, picking_type, package, product, lot, match_user=match_user ) ) sort_keys_func = self._sort_key_move_lines(order) @@ -437,21 +450,15 @@ def _scan_source_location( package = quants.package_id if len(product) > 1 or len(lot) > 1 or len(package) > 1: return False - domain = [ - ("location_id", "=", location.id), - "|", - ("shopfloor_user_id", "=", False), - ("shopfloor_user_id", "=", self.env.uid), - ] - if product: - domain.append(("product_id", "=", product.id)) - if lot: - domain.append(("lot_id", "=", lot.id)) - if package: - domain.append(("package_id", "=", package.id)) - move_lines = self.env["stock.move.line"].search(domain) - sort_keys_func = self._sort_key_move_lines(order) - move_lines = move_lines.sorted(sort_keys_func) + move_lines = self._find_location_move_lines( + location, + picking_type=picking_type, + product=product, + package=package, + lot=lot, + match_user=True, + ) + if move_lines: return first(move_lines) return False From 9bcd3f635f3a58efd88092c33ffa75fc766bf898 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Sat, 21 Nov 2020 11:12:50 +0100 Subject: [PATCH 443/940] shopfloor 13.0.1.3.7 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 395cffed92..79026c348b 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "13.0.1.3.6", + "version": "13.0.1.3.7", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 6404e60ffc43e3089e29314116ea4e4944213264 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 25 Nov 2020 10:43:03 +0100 Subject: [PATCH 444/940] shopfloor: add service options via menu conf --- shopfloor/__manifest__.py | 3 ++- shopfloor/models/shopfloor_menu.py | 13 +++++++++++++ shopfloor/services/service.py | 18 ++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 79026c348b..4f6dfa30d2 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -17,8 +17,9 @@ "depends": [ "stock", "stock_picking_batch", - "base_rest", "base_jsonify", + "base_rest", + "base_sparse_field", "auth_api_key", # OCA / stock-logistics-warehouse "stock_picking_completion_info", diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index c2a351282c..e398fe32cf 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -2,6 +2,8 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _, api, exceptions, fields, models +from odoo.addons.base_sparse_field.models.fields import Serialized + class ShopfloorMenu(models.Model): _name = "shopfloor.menu" @@ -33,6 +35,17 @@ class ShopfloorMenu(models.Model): ) scenario = fields.Selection(selection="_selection_scenario", required=True) + # TODO: `options` field allows to provide custom options for the scenario, + # (or for any other kind of service). + # Developers should probably have a way to register scenario and their options + # which will be computed in this field at the end. + # This would allow to get rid of hardcoded settings like + # `_scenario_allowing_create_moves` or `_scenario_allowing_unreserve_other_moves`. + # For now is not included in any view as it should be customizable by scenario. + # Maybe we can have a wizard accessible via a button on the menu tree view. + # There's no automation here. Developers are responsible for their usage + # and/or their exposure to the scenario api. + options = Serialized(default={}) move_create_is_possible = fields.Boolean(compute="_compute_move_create_is_possible") # only available for some scenarios, move_create_is_possible defines if the option diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index a5252f2239..603c55d90c 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -414,6 +414,24 @@ def _work_ctx_get_menu_id(self, rec_id): def _work_ctx_get_profile_id(self, rec_id): return "profile", self.env["shopfloor.profile"].browse(rec_id).exists() + _options = {} + + @property + def options(self): + """Compute options for current service. + + If the service has a menu, options coming from the menu are injected. + """ + if self._options: + return self._options + + options = {} + if self._requires_header_menu and getattr(self.work, "menu", None): + options = self.work.menu.options or {} + options.update(getattr(self.work, "options", {})) + self._options = options + return self._options + class BaseShopfloorProcess(AbstractComponent): """Base class for process rest service""" From eeb39b931274349b10b0d61b6df3c3a7019b5f93 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 25 Nov 2020 10:44:24 +0100 Subject: [PATCH 445/940] shopfloor: checkout option to turn off 'no_package' --- shopfloor/services/checkout.py | 13 +++++ shopfloor/tests/test_checkout_no_package.py | 47 +++++++++++++------ shopfloor/tests/test_checkout_select_line.py | 12 +++++ .../test_checkout_select_package_base.py | 16 ++++--- 4 files changed, 67 insertions(+), 21 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 88ff30d3c9..fe45bd74a1 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -1,5 +1,8 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from werkzeug.exceptions import BadRequest + from odoo import _ from odoo.addons.base_rest.components.service import to_int @@ -73,6 +76,9 @@ def _response_for_select_package(self, picking, lines, message=None): "selected_move_lines": self._data_for_move_lines(lines.sorted()), "picking": self.data.picking(picking), "packing_info": self._data_for_packing_info(picking), + "no_package_enabled": not self.options.get( + "checkout:disable_no_package" + ), }, message=message, ) @@ -758,6 +764,8 @@ def no_package(self, picking_id, selected_line_ids): Transitions: * select_line: goes back to selection of lines to work on next lines """ + if self.options.get("checkout:disable_no_package"): + raise BadRequest("`checkout.no_package` endpoint is not enabled") picking = self.env["stock.picking"].browse(picking_id) message = self._check_picking_status(picking) if message: @@ -1180,6 +1188,11 @@ def _states(self): "select_package": dict( self._schema_selected_lines, packing_info={"type": "string", "nullable": True}, + no_package_enabled={ + "type": "boolean", + "nullable": True, + "required": False, + }, ), "change_quantity": self._schema_selected_lines, "select_dest_package": self._schema_select_package, diff --git a/shopfloor/tests/test_checkout_no_package.py b/shopfloor/tests/test_checkout_no_package.py index 7242077825..e60a67ecfc 100644 --- a/shopfloor/tests/test_checkout_no_package.py +++ b/shopfloor/tests/test_checkout_no_package.py @@ -1,37 +1,44 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import werkzeug + from .test_checkout_base import CheckoutCommonCase from .test_checkout_select_package_base import CheckoutSelectPackageMixin class CheckoutNoPackageCase(CheckoutCommonCase, CheckoutSelectPackageMixin): - def test_no_package_ok(self): - picking = self._create_picking( + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = picking = cls._create_picking( lines=[ - (self.product_a, 10), - (self.product_b, 10), - (self.product_c, 10), - (self.product_d, 10), + (cls.product_a, 10), + (cls.product_b, 10), + (cls.product_c, 10), + (cls.product_d, 10), ] ) - pack1_moves = picking.move_lines[:3] - pack2_moves = picking.move_lines[3:] + cls.pack1_moves = pack1_moves = picking.move_lines[:3] + cls.pack2_moves = pack2_moves = picking.move_lines[3:] # put in 2 packs, for this test, we'll work on pack1 - self._fill_stock_for_moves(pack1_moves) - self._fill_stock_for_moves(pack2_moves) + cls._fill_stock_for_moves(pack1_moves) + cls._fill_stock_for_moves(pack2_moves) picking.action_assign() - move_line1, move_line2, move_line3 = pack1_moves.move_line_ids + def test_no_package_ok(self): + move_line1, move_line2, move_line3 = self.pack1_moves.move_line_ids selected_lines = move_line1 + move_line2 # we'll put only the first 2 lines (product A and B) w/ no package move_line1.qty_done = move_line1.product_uom_qty move_line2.qty_done = move_line2.product_uom_qty move_line3.qty_done = 0 - response = self.service.dispatch( "no_package", - params={"picking_id": picking.id, "selected_line_ids": selected_lines.ids}, + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + }, ) self.assertRecordValues( @@ -48,9 +55,21 @@ def test_no_package_ok(self): response, # go pack to the screen to select lines to put in packages next_state="select_line", - data={"picking": self._stock_picking_data(picking)}, + data={"picking": self._stock_picking_data(self.picking)}, message={ "message_type": "success", "body": "Product(s) processed as raw product(s)", }, ) + + def test_no_package_disabled(self): + self.service.work.options = {"checkout:disable_no_package": True} + with self.assertRaises(werkzeug.exceptions.BadRequest) as err: + self.service.dispatch( + "no_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": self.pack1_moves.move_line_ids.ids, + }, + ) + self.assertEqual(err.name, "`checkout.no_package` endpoint is not enabled") diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py index 477fed1f3f..2218060026 100644 --- a/shopfloor/tests/test_checkout_select_line.py +++ b/shopfloor/tests/test_checkout_select_line.py @@ -31,6 +31,18 @@ def test_select_line_package_ok(self): ) self._assert_selected(response, selected_lines) + def test_select_line_no_package_disabled(self): + selected_lines = self.moves_pack.move_line_ids + self.service.work.options = {"checkout:disable_no_package": True} + response = self.service.dispatch( + "select_line", + params={ + "picking_id": self.picking.id, + "package_id": selected_lines.package_id.id, + }, + ) + self._assert_selected(response, selected_lines, no_package_enabled=False) + def test_select_line_move_line_package_ok(self): selected_lines = self.moves_pack.move_line_ids # When we select a single line but the line is part of a package, diff --git a/shopfloor/tests/test_checkout_select_package_base.py b/shopfloor/tests/test_checkout_select_package_base.py index 151beb76e2..f361b26fd8 100644 --- a/shopfloor/tests/test_checkout_select_package_base.py +++ b/shopfloor/tests/test_checkout_select_package_base.py @@ -4,7 +4,12 @@ class CheckoutSelectPackageMixin: def _assert_selected_response( - self, response, selected_lines, message=None, packing_info=False + self, + response, + selected_lines, + message=None, + packing_info=False, + no_package_enabled=True, ): picking = selected_lines.mapped("picking_id") self.assert_response( @@ -16,6 +21,7 @@ def _assert_selected_response( ], "picking": self._picking_summary_data(picking), "packing_info": picking.shopfloor_packing_info if packing_info else "", + "no_package_enabled": no_package_enabled, }, message=message, ) @@ -41,9 +47,7 @@ def _assert_selected_qties( response, selected_lines, message=message, packing_info=packing_info ) - def _assert_selected( - self, response, selected_lines, message=None, packing_info=False - ): + def _assert_selected(self, response, selected_lines, message=None, **kw): picking = selected_lines.mapped("picking_id") unselected_lines = picking.move_line_ids - selected_lines for line in selected_lines: @@ -54,6 +58,4 @@ def _assert_selected( ) for line in unselected_lines: self.assertEqual(line.qty_done, 0) - self._assert_selected_response( - response, selected_lines, message=message, packing_info=packing_info - ) + self._assert_selected_response(response, selected_lines, message=message, **kw) From d6244d5176abc63444f829456b6dc26a0e99a7c8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 20 Nov 2020 14:22:39 +0100 Subject: [PATCH 446/940] shopfloor: zone_picking add counters per zone --- shopfloor/services/zone_picking.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 5ee98f00a2..979232c8b3 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -197,7 +197,8 @@ def _data_for_select_picking_type(self, zone_location, picking_types): for datum in data["picking_types"]: picking_type = self.env["stock.picking.type"].browse(datum["id"]) zone_lines = self._picking_type_zone_lines(zone_location, picking_type) - datum.update(self._counters_for_zone_lines(zone_lines)) + counters = self._counters_for_zone_lines(zone_lines) + datum.update(counters) return data def _counters_for_zone_lines(self, zone_lines): @@ -273,11 +274,15 @@ def _data_for_select_zone(self, zones): lines_by_op_type[line.picking_id.picking_type_id].append(line) zone_data["operation_types"] = [] - + zone_counters = defaultdict(int) for picking_type, lines in lines_by_op_type.items(): op_type_data = self.data.picking_type(picking_type) - op_type_data.update(self._counters_for_zone_lines(lines)) + counters = self._counters_for_zone_lines(lines) + op_type_data.update(counters) zone_data["operation_types"].append(op_type_data) + for k, v in counters.items(): + zone_counters[k] += v + zone_data.update(zone_counters) res.append(zone_data) return res @@ -1597,6 +1602,7 @@ def _schema_for_select_zone(self): zone_schema["operation_types"] = self.schemas._schema_list_of( picking_type_schema ) + zone_schema.update(self._schema_for_zone_line_counters) zone_schema = { "zones": self.schemas._schema_list_of(zone_schema), } From 4eac48a230293cc0edecd7e469c6e47b7fc1b1c7 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 20 Nov 2020 14:25:31 +0100 Subject: [PATCH 447/940] shopfloor_mobile: zone_picking add counters per zone --- shopfloor/tests/test_zone_picking_start.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/shopfloor/tests/test_zone_picking_start.py b/shopfloor/tests/test_zone_picking_start.py index 2019e9969b..6f20c4eb95 100644 --- a/shopfloor/tests/test_zone_picking_start.py +++ b/shopfloor/tests/test_zone_picking_start.py @@ -37,6 +37,10 @@ def test_data_for_zone(self): zones_data = self.service._response_for_start()["data"]["start"]["zones"] expected_sub1 = dict( self.data.location(self.zone_sublocation1), + lines_count=1, + picking_count=1, + priority_lines_count=0, + priority_picking_count=0, operation_types=[ dict( op_type_data, @@ -49,6 +53,10 @@ def test_data_for_zone(self): ) expected_sub2 = dict( self.data.location(self.zone_sublocation2), + lines_count=2, + picking_count=1, + priority_lines_count=0, + priority_picking_count=0, operation_types=[ dict( op_type_data, @@ -61,6 +69,10 @@ def test_data_for_zone(self): ) expected_sub3 = dict( self.data.location(self.zone_sublocation3), + lines_count=2, + picking_count=2, + priority_lines_count=0, + priority_picking_count=0, operation_types=[ dict( op_type_data, @@ -73,6 +85,10 @@ def test_data_for_zone(self): ) expected_sub4 = dict( self.data.location(self.zone_sublocation4), + lines_count=3, + picking_count=2, + priority_lines_count=0, + priority_picking_count=0, operation_types=[ dict( op_type_data, @@ -85,6 +101,10 @@ def test_data_for_zone(self): ) expected_sub5 = dict( self.data.location(self.zone_sublocation5), + lines_count=2, + picking_count=1, + priority_lines_count=0, + priority_picking_count=0, operation_types=[ dict( op_type_data, From 370a648b1676d52c47b0f3a89133eccdda7d1ab8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 23 Nov 2020 10:33:23 +0100 Subject: [PATCH 448/940] shopfloor: add common search_move_line actions --- shopfloor/actions/__init__.py | 1 + shopfloor/actions/move_line_search.py | 105 ++++++++++++++++++++++++++ shopfloor/services/service.py | 26 +++++-- 3 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 shopfloor/actions/move_line_search.py diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index 464f279228..788ec5e8a9 100644 --- a/shopfloor/actions/__init__.py +++ b/shopfloor/actions/__init__.py @@ -26,3 +26,4 @@ from . import search from . import inventory from . import savepoint +from . import move_line_search diff --git a/shopfloor/actions/move_line_search.py b/shopfloor/actions/move_line_search.py new file mode 100644 index 0000000000..21d11c23bc --- /dev/null +++ b/shopfloor/actions/move_line_search.py @@ -0,0 +1,105 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.component.core import Component + + +class MoveLineSearch(Component): + """Provide methods to search move line records. + + The methods should be used in Service Components, so a search will always + have the same result in all scenarios. + """ + + _name = "shopfloor.search.move.line" + _inherit = "shopfloor.process.action" + _usage = "search_move_line" + + @property + def picking_types(self): + return getattr( + self.work, "picking_types", self.env["stock.picking.type"].browse() + ) + + def _search_move_lines_by_location_domain( + self, + locations, + picking_type=None, + package=None, + product=None, + lot=None, + match_user=False, + ): + domain = [ + ("location_id", "child_of", locations.ids), + ("qty_done", "=", 0), + ("state", "in", ("assigned", "partially_available")), + ] + if picking_type: + # auto_join in place for this field + domain += [("picking_id.picking_type_id", "=", picking_type.id)] + elif self.picking_types: + domain += [("picking_id.picking_type_id", "in", self.picking_types.ids)] + if package: + domain += [("package_id", "=", package.id)] + if product: + domain += [("product_id", "=", product.id)] + if lot: + domain += [("lot_id", "=", lot.id)] + if match_user: + domain += [ + "|", + ("shopfloor_user_id", "=", False), + ("shopfloor_user_id", "=", self.env.uid), + ] + return domain + + def search_move_lines_by_location( + self, + locations, + picking_type=None, + package=None, + product=None, + lot=None, + order="priority", + match_user=False, + sort_keys_func=None, + ): + """Find lines that potentially need work in given locations.""" + move_lines = self.env["stock.move.line"].search( + self._search_move_lines_by_location_domain( + locations, picking_type, package, product, lot, match_user=match_user + ) + ) + sort_keys_func = sort_keys_func or self._sort_key_move_lines(order) + move_lines = move_lines.sorted(sort_keys_func) + return move_lines + + @staticmethod + def _sort_key_move_lines(order): + """Return a sorting function to order lines.""" + + if order == "priority": + # make prority negative to keep sorting ascending + return lambda line: ( + -int(line.move_id.priority or "0"), + line.move_id.date_expected, + ) + elif order == "location": + return lambda line: ( + line.location_id.shopfloor_picking_sequence or "", + line.location_id.name, + line.move_id.date_expected, + ) + return lambda line: line + + def counters_for_lines(self, lines, priority_selection=("2", "3")): + # Not using mapped/filtered to support simple lists and generators + priority_lines = [ + x for x in lines if x.picking_id.priority in priority_selection + ] + return { + "lines_count": len(lines), + "picking_count": len({x.picking_id.id for x in lines}), + "priority_lines_count": len(priority_lines), + "priority_picking_count": len({x.picking_id.id for x in priority_lines}), + } diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 603c55d90c..d228a55822 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -320,17 +320,18 @@ def _get_openapi_default_parameters(self): def actions_collection(self): return _PseudoCollection(self._actions_collection_name, self.env) - def actions_for(self, usage): + def actions_for(self, usage, propagate_kwargs=None): """Return an Action Component for a usage Action Components are the components supporting the business logic of the processes, so we can limit the code in Services to the minimum and share methods. """ + propagate_kwargs = self.work._propagate_kwargs[:] + (propagate_kwargs or []) # propagate custom arguments (such as menu ID/profile ID) kwargs = { attr_name: getattr(self.work, attr_name) - for attr_name in self.work._propagate_kwargs + for attr_name in propagate_kwargs if attr_name not in ("collection", "components_registry") } work = WorkContext(collection=self.actions_collection, **kwargs) @@ -355,6 +356,11 @@ def data_detail(self): def msg_store(self): return self.actions_for("message") + @property + def search_move_line(self): + # TODO: propagating `picking_types` should probably be default + return self.actions_for("search_move_line", propagate_kwargs=["picking_types"]) + # TODO: maybe to be proposed to base_rest # TODO: add tests def _validate_headers_update_work_context(self, request, method_name): @@ -442,8 +448,12 @@ class BaseShopfloorProcess(AbstractComponent): _requires_header_menu = True _requires_header_profile = True - @property - def picking_types(self): + def __init__(self, work_context): + super().__init__(work_context) + if not hasattr(self.work, "picking_types"): + self.work.picking_types = self._get_process_picking_types() + + def _get_process_picking_types(self): """Return picking types for the menu and profile""" # TODO make this a lazy property or computed field avoid running the # filter every time? @@ -451,13 +461,17 @@ def picking_types(self): lambda pt: not pt.warehouse_id or pt.warehouse_id == self.work.profile.warehouse_id ) - if not picking_types: + return picking_types + + @property + def picking_types(self): + if not self.work.picking_types: raise exceptions.UserError( _("No operation types configured on menu {} for warehouse {}.").format( self.work.menu.name, self.work.profile.warehouse_id.display_name ) ) - return picking_types + return self.work.picking_types def _check_picking_status(self, pickings): """Check if given pickings can be processed. From deb5e460c1ae44f244956e189dbf0fa886b95d8f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 23 Nov 2020 10:34:06 +0100 Subject: [PATCH 449/940] shopfloor: zone_picking use common search_move_line actions --- shopfloor/services/zone_picking.py | 81 ++++-------------------------- 1 file changed, 11 insertions(+), 70 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 979232c8b3..010fd3c49b 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -202,20 +202,12 @@ def _data_for_select_picking_type(self, zone_location, picking_types): return data def _counters_for_zone_lines(self, zone_lines): - # Not using mapped/filtered to support simple lists and generators - priority_lines = [x for x in zone_lines if x.picking_id.priority in ("2", "3")] - return { - "lines_count": len(zone_lines), - "picking_count": len({x.picking_id.id for x in zone_lines}), - "priority_lines_count": len(priority_lines), - "priority_picking_count": len({x.picking_id.id for x in priority_lines}), - } + return self.search_move_line.counters_for_lines(zone_lines) def _picking_type_zone_lines(self, zone_location, picking_type): - domain = self._find_location_move_lines_domain( + return self.search_move_line.search_move_lines_by_location( zone_location, picking_type=picking_type ) - return self.env["stock.move.line"].search(domain) def _data_for_move_line(self, zone_location, picking_type, move_line): return { @@ -250,9 +242,7 @@ def _data_for_location(self, zone_location, picking_type, location): } def _zone_lines(self, zones): - return self.env["stock.move.line"].search( - self._find_location_move_lines_domain(zones) - ) + return self._find_location_move_lines(zones) def _data_for_select_zone(self, zones): """Retrieve detailed info for each zone. @@ -286,39 +276,6 @@ def _data_for_select_zone(self, zones): res.append(zone_data) return res - def _find_location_move_lines_domain( - self, - locations, - picking_type=None, - package=None, - product=None, - lot=None, - match_user=False, - ): - domain = [ - ("location_id", "child_of", locations.ids), - ("qty_done", "=", 0), - ("state", "in", ("assigned", "partially_available")), - ] - if picking_type: - # auto_join in place for this field - domain += [("picking_id.picking_type_id", "=", picking_type.id)] - else: - domain += [("picking_id.picking_type_id", "in", self.picking_types.ids)] - if package: - domain += [("package_id", "=", package.id)] - if product: - domain += [("product_id", "=", product.id)] - if lot: - domain += [("lot_id", "=", lot.id)] - if match_user: - domain += [ - "|", - ("shopfloor_user_id", "=", False), - ("shopfloor_user_id", "=", self.env.uid), - ] - return domain - def _find_location_move_lines( self, locations, @@ -330,31 +287,15 @@ def _find_location_move_lines( match_user=False, ): """Find lines that potentially need work in given locations.""" - move_lines = self.env["stock.move.line"].search( - self._find_location_move_lines_domain( - locations, picking_type, package, product, lot, match_user=match_user - ) + return self.search_move_line.search_move_lines_by_location( + locations, + picking_type=picking_type, + package=package, + product=product, + lot=lot, + order=order, + match_user=match_user, ) - sort_keys_func = self._sort_key_move_lines(order) - move_lines = move_lines.sorted(sort_keys_func) - return move_lines - - @staticmethod - def _sort_key_move_lines(order): - """Return a sorting function to order lines.""" - - if order == "priority": - # make prority negative to keep sorting ascending - return lambda line: ( - -int(line.move_id.priority or "0"), - line.move_id.date_expected, - ) - elif order == "location": - return lambda line: ( - line.location_id.shopfloor_picking_sequence or "", - line.location_id.name, - line.move_id.date_expected, - ) def _find_buffer_move_lines_domain( self, zone_location, picking_type, dest_package=None From 2a77041b35a00ef14f57b014ddea9bb92ebedfce Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 23 Nov 2020 13:08:22 +0100 Subject: [PATCH 450/940] shopfloor: service.action_for allow custom work ctx args --- shopfloor/services/service.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index d228a55822..e2ca971ca3 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -320,7 +320,7 @@ def _get_openapi_default_parameters(self): def actions_collection(self): return _PseudoCollection(self._actions_collection_name, self.env) - def actions_for(self, usage, propagate_kwargs=None): + def actions_for(self, usage, propagate_kwargs=None, **kw): """Return an Action Component for a usage Action Components are the components supporting the business logic of @@ -333,7 +333,9 @@ def actions_for(self, usage, propagate_kwargs=None): attr_name: getattr(self.work, attr_name) for attr_name in propagate_kwargs if attr_name not in ("collection", "components_registry") + and hasattr(self.work, attr_name) } + kwargs.update(kw) work = WorkContext(collection=self.actions_collection, **kwargs) return work.component(usage=usage) @@ -448,11 +450,6 @@ class BaseShopfloorProcess(AbstractComponent): _requires_header_menu = True _requires_header_profile = True - def __init__(self, work_context): - super().__init__(work_context) - if not hasattr(self.work, "picking_types"): - self.work.picking_types = self._get_process_picking_types() - def _get_process_picking_types(self): """Return picking types for the menu and profile""" # TODO make this a lazy property or computed field avoid running the @@ -465,6 +462,8 @@ def _get_process_picking_types(self): @property def picking_types(self): + if not hasattr(self.work, "picking_types"): + self.work.picking_types = self._get_process_picking_types() if not self.work.picking_types: raise exceptions.UserError( _("No operation types configured on menu {} for warehouse {}.").format( From 4ddbf4c7aa9d06db331ab56f9a7b2b8e8249c252 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 23 Nov 2020 13:09:11 +0100 Subject: [PATCH 451/940] shopfloor: make move lines counters schema common --- shopfloor/services/schema.py | 8 ++++++++ shopfloor/services/zone_picking.py | 7 +------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index bb9071e9d7..c86e11360c 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -186,3 +186,11 @@ def picking_type(self): "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, } + + def move_lines_counters(self): + return { + "lines_count": {"type": "float", "required": True}, + "picking_count": {"type": "float", "required": True}, + "priority_lines_count": {"type": "float", "required": True}, + "priority_picking_count": {"type": "float", "required": True}, + } diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 010fd3c49b..2ef04204a6 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -1551,12 +1551,7 @@ def _schema_for_select_zone(self): @property def _schema_for_zone_line_counters(self): - return { - "lines_count": {"type": "float", "required": True}, - "picking_count": {"type": "float", "required": True}, - "priority_lines_count": {"type": "float", "required": True}, - "priority_picking_count": {"type": "float", "required": True}, - } + return self.schemas.move_lines_counters() @property def _schema_for_select_picking_type(self): From 2e65882066bbfb99481e5242e53e4a2bca468cdf Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 23 Nov 2020 16:00:11 +0100 Subject: [PATCH 452/940] shopfloor: add menu item counters Provide counters on work to be done per each menu item. --- shopfloor/services/menu.py | 51 ++++-- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_menu.py | 34 +--- shopfloor/tests/test_menu_base.py | 253 ++++++++++++++++++++++++++ shopfloor/tests/test_menu_counters.py | 24 +++ 5 files changed, 311 insertions(+), 52 deletions(-) create mode 100644 shopfloor/tests/test_menu_base.py create mode 100644 shopfloor/tests/test_menu_counters.py diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index 3fce4f2182..cb82e53517 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -59,16 +59,31 @@ def search(self, name_fragment=None): ) def _convert_one_record(self, record): - # TODO: use `jsonify` - return { - "id": record.id, - "name": record.name, - "scenario": record.scenario, - "picking_types": [ - {"id": picking_type.id, "name": picking_type.name} - for picking_type in record.picking_type_ids - ], - } + values = record.jsonify(self._one_record_parser, one=True) + counters = self._get_move_line_counters(record) + values.update(counters) + return values + + def _get_move_line_counters(self, record): + """Lookup for all lines per menu item and compute counters. + """ + # TODO: maybe to be improved w/ raw SQL as this run for each menu item + # and it's called every time the menu is opened/gets refreshed + move_line_search = self.actions_for( + "search_move_line", picking_types=record.picking_type_ids + ) + locations = record.picking_type_ids.mapped("default_location_src_id") + lines_per_menu = move_line_search.search_move_lines_by_location(locations) + return move_line_search.counters_for_lines(lines_per_menu) + + @property + def _one_record_parser(self): + return [ + "id", + "name", + "scenario", + ("picking_type_ids:picking_types", ["id", "name"]), + ] class ShopfloorMenuValidator(Component): @@ -92,28 +107,24 @@ class ShopfloorMenuValidatorResponse(Component): _usage = "menu.validator.response" def return_search(self): + record_schema = self._record_schema return self._response_schema( { "size": {"coerce": to_int, "required": True, "type": "integer"}, - "records": { - "type": "list", - "required": True, - "schema": {"type": "dict", "schema": self._record_schema}, - }, + "records": self.schemas._schema_list_of(record_schema), } ) @property def _record_schema(self): - return { + schema = { "id": {"coerce": to_int, "required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, "scenario": {"type": "string", "nullable": False, "required": True}, - "picking_types": { - "type": "list", - "schema": {"type": "dict", "schema": self._picking_type_schema}, - }, + "picking_types": self.schemas._schema_list_of(self._picking_type_schema), } + schema.update(self.schemas.move_lines_counters()) + return schema @property def _picking_type_schema(self): diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 415f01e90c..c15f412658 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -1,6 +1,7 @@ from . import test_app from . import test_user from . import test_menu +from . import test_menu_counters from . import test_openapi from . import test_profile from . import test_actions_change_package_lot diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py index 5ec40e40d7..c27b937bde 100644 --- a/shopfloor/tests/test_menu.py +++ b/shopfloor/tests/test_menu.py @@ -1,40 +1,10 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from .common import CommonCase +from .test_menu_base import CommonMenuCase -class MenuCase(CommonCase): - @classmethod - def setUpClassVars(cls, *args, **kwargs): - super().setUpClassVars(*args, **kwargs) - cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") - - def setUp(self): - super().setUp() - with self.work_on_services(profile=self.profile) as work: - self.service = work.component(usage="menu") - - def _assert_menu_response(self, response, menus): - self.assert_response( - response, - data={ - "size": len(menus), - "records": [ - { - "id": menu.id, - "name": menu.name, - "scenario": menu.scenario, - "picking_types": [ - {"id": picking_type.id, "name": picking_type.name} - for picking_type in menu.picking_type_ids - ], - } - for menu in menus - ], - }, - ) - +class MenuCase(CommonMenuCase): def test_menu_search(self): """Request /menu/search""" # Simulate the client searching menus diff --git a/shopfloor/tests/test_menu_base.py b/shopfloor/tests/test_menu_base.py new file mode 100644 index 0000000000..a85d407677 --- /dev/null +++ b/shopfloor/tests/test_menu_base.py @@ -0,0 +1,253 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .common import CommonCase + + +class CommonMenuCase(CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + + def setUp(self): + super().setUp() + with self.work_on_services(profile=self.profile) as work: + self.service = work.component(usage="menu") + + def _assert_menu_response(self, response, menus, expected_counters=None): + self.assert_response( + response, + data={ + "size": len(menus), + "records": [ + self._data_for_menu_item(menu, expected_counters=expected_counters) + for menu in menus + ], + }, + ) + + def _data_for_menu_item(self, menu, expected_counters=None): + expected_counters = expected_counters or {} + data = { + "id": menu.id, + "name": menu.name, + "scenario": menu.scenario, + "picking_types": [ + {"id": picking_type.id, "name": picking_type.name} + for picking_type in menu.picking_type_ids + ], + } + counters = expected_counters.get( + menu.id, + { + "lines_count": 0, + "picking_count": 0, + "priority_lines_count": 0, + "priority_picking_count": 0, + }, + ) + data.update(counters) + return data + + +class MenuCountersCommonCase(CommonMenuCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu1 = cls.env.ref("shopfloor.shopfloor_menu_zone_picking") + cls.menu2 = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking") + cls.menu1_picking_type = cls.menu1.picking_type_ids[0] + cls.menu2_picking_type = cls.menu2.picking_type_ids[0] + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.packing_location.sudo().active = True + # We want to limit the tests to a dedicated location in Stock/ to not + # be bothered with pickings brought by demo data + cls.zone_location1 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone location 1", + "location_id": cls.stock_location.id, + "barcode": "ZONE_LOCATION_1", + } + ) + ) + cls.zone_location2 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone location 2", + "location_id": cls.stock_location.id, + "barcode": "ZONE_LOCATION_2", + } + ) + ) + # Set default location for our picking types + cls.menu1_picking_type.sudo().default_location_src_id = cls.zone_location1 + cls.menu2_picking_type.sudo().default_location_src_id = cls.zone_location2 + cls.zone_sublocation1 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location 1", + "location_id": cls.zone_location1.id, + "barcode": "ZONE_SUBLOCATION_1", + } + ) + ) + cls.zone_sublocation2 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location 2", + "location_id": cls.zone_location2.id, + "barcode": "ZONE_SUBLOCATION_2", + } + ) + ) + cls.zone_sublocation3 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location 3", + "location_id": cls.zone_location2.id, + "barcode": "ZONE_SUBLOCATION_3", + } + ) + ) + cls.zone_sublocation4 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location 4", + "location_id": cls.zone_location2.id, + "barcode": "ZONE_SUBLOCATION_4", + } + ) + ) + cls.product_e = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product E", + "type": "product", + "default_code": "E", + "barcode": "E", + "weight": 3, + } + ) + ) + cls.product_f = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product F", + "type": "product", + "default_code": "F", + "barcode": "F", + "weight": 3, + } + ) + ) + cls.product_g = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product G", + "type": "product", + "default_code": "G", + "barcode": "G", + "weight": 3, + } + ) + ) + cls.product_h = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product H", + "type": "product", + "default_code": "H", + "barcode": "H", + "weight": 3, + } + ) + ) + products = ( + cls.product_a + + cls.product_b + + cls.product_c + + cls.product_d + + cls.product_e + + cls.product_f + + cls.product_g + + cls.product_h + ) + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.picking1 = picking1 = cls._create_picking( + picking_type=cls.menu1_picking_type, lines=[(cls.product_a, 10)] + ) + picking1.priority = "2" + cls._fill_stock_for_moves( + picking1.move_lines, in_package=True, location=cls.zone_sublocation1 + ) + + cls.picking2 = picking2 = cls._create_picking( + picking_type=cls.menu1_picking_type, + lines=[(cls.product_b, 10), (cls.product_c, 10)], + ) + picking2.priority = "3" + cls._fill_stock_for_moves( + picking2.move_lines, in_lot=True, location=cls.zone_sublocation2 + ) + + cls.picking3 = picking3 = cls._create_picking( + picking_type=cls.menu1_picking_type, lines=[(cls.product_d, 10)] + ) + picking3.priority = "2" + cls._fill_stock_for_moves(picking3.move_lines, location=cls.zone_sublocation1) + + cls.picking4 = picking4 = cls._create_picking( + picking_type=cls.menu2_picking_type, lines=[(cls.product_e, 10)] + ) + cls._update_qty_in_location(cls.zone_sublocation3, cls.product_e, 6) + cls._update_qty_in_location(cls.zone_sublocation4, cls.product_e, 4) + + cls.picking5 = picking5 = cls._create_picking( + picking_type=cls.menu2_picking_type, + lines=[(cls.product_b, 10), (cls.product_f, 10)], + ) + cls._fill_stock_for_moves( + picking5.move_lines, in_package=True, location=cls.zone_sublocation2 + ) + cls.picking6 = picking6 = cls._create_picking( + picking_type=cls.menu2_picking_type, + lines=[(cls.product_g, 6), (cls.product_h, 6)], + ) + cls._update_qty_in_location(cls.zone_sublocation2, cls.product_g, 6) + cls._update_qty_in_location(cls.zone_sublocation2, cls.product_h, 3) + + cls.pickings = picking1 | picking2 | picking3 | picking4 | picking5 | picking6 + cls.pickings.action_assign() diff --git a/shopfloor/tests/test_menu_counters.py b/shopfloor/tests/test_menu_counters.py new file mode 100644 index 0000000000..8cec852e08 --- /dev/null +++ b/shopfloor/tests/test_menu_counters.py @@ -0,0 +1,24 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_menu_base import MenuCountersCommonCase + + +class TestMenuCountersCommonCase(MenuCountersCommonCase): + def test_menu_search(self): + expected_counters = { + self.menu1.id: { + "lines_count": 2, + "picking_count": 2, + "priority_lines_count": 2, + "priority_picking_count": 2, + }, + self.menu2.id: { + "lines_count": 6, + "picking_count": 3, + "priority_lines_count": 0, + "priority_picking_count": 0, + }, + } + response = self.service.dispatch("search") + menus = self.env["shopfloor.menu"].search([]) + self._assert_menu_response(response, menus, expected_counters=expected_counters) From 09f8c9e1a86271d70789f2486e0989ae3308ee5d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 23 Nov 2020 16:01:34 +0100 Subject: [PATCH 453/940] shopfloor: action search always look for 1 record --- shopfloor/actions/search.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py index 922df08040..4592653ab8 100644 --- a/shopfloor/actions/search.py +++ b/shopfloor/actions/search.py @@ -17,27 +17,31 @@ class SearchAction(Component): def location_from_scan(self, barcode): if not barcode: return self.env["stock.location"].browse() - return self.env["stock.location"].search([("barcode", "=", barcode)]) + return self.env["stock.location"].search([("barcode", "=", barcode)], limit=1) def package_from_scan(self, barcode): - return self.env["stock.quant.package"].search([("name", "=", barcode)]) + return self.env["stock.quant.package"].search([("name", "=", barcode)], limit=1) def picking_from_scan(self, barcode): - return self.env["stock.picking"].search([("name", "=", barcode)]) + return self.env["stock.picking"].search([("name", "=", barcode)], limit=1) def product_from_scan(self, barcode): - product = self.env["product.product"].search([("barcode", "=", barcode)]) + product = self.env["product.product"].search( + [("barcode", "=", barcode)], limit=1 + ) if not product: packaging = self.env["product.packaging"].search( - [("product_id", "!=", False), ("barcode", "=", barcode)] + [("product_id", "!=", False), ("barcode", "=", barcode)], limit=1 ) product = packaging.product_id return product def lot_from_scan(self, barcode): - return self.env["stock.production.lot"].search([("name", "=", barcode)]) + return self.env["stock.production.lot"].search( + [("name", "=", barcode)], limit=1 + ) def generic_packaging_from_scan(self, barcode): return self.env["product.packaging"].search( - [("barcode", "=", barcode), ("product_id", "=", False)] + [("barcode", "=", barcode), ("product_id", "=", False)], limit=1 ) From 2bc77b8759275e90ae7f23468b7fefefdc333aa6 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 26 Nov 2020 15:04:22 +0100 Subject: [PATCH 454/940] shopfloor: fix db logging tests --- shopfloor/models/shopfloor_log.py | 2 +- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_db_logging.py | 18 +++++++++++++----- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/shopfloor/models/shopfloor_log.py b/shopfloor/models/shopfloor_log.py index 62bb26519b..110f04cf08 100644 --- a/shopfloor/models/shopfloor_log.py +++ b/shopfloor/models/shopfloor_log.py @@ -85,7 +85,7 @@ def _get_exception_severity_mapping(self): if not exc_name or not severity: raise ValueError except ValueError: - _logger.exception( + _logger.info( "Could not convert System Parameter" " 'shopfloor.log.severity.exception.mapping' to mapping." " The following rule will be ignored: %s", diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index c15f412658..3146498a5a 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -63,3 +63,4 @@ from . import test_scan_anything from . import test_stock_split from . import test_picking_form +from . import test_db_logging diff --git a/shopfloor/tests/test_db_logging.py b/shopfloor/tests/test_db_logging.py index 6795327292..7a982b9169 100644 --- a/shopfloor/tests/test_db_logging.py +++ b/shopfloor/tests/test_db_logging.py @@ -5,6 +5,7 @@ import json from odoo import exceptions +from odoo.tools import mute_logger from odoo.addons.website.tools import MockRequest @@ -101,17 +102,23 @@ def test_log_entry_values_success(self): expected = { "request_url": httprequest["url"], "request_method": httprequest["method"], - "params": json.dumps(dict(params, _id=_id)), - "headers": json.dumps( - {"Cookie": "", "Api-Key": "", "KEEP-ME": "FOO"} - ), "state": "success", - "result": json.dumps({"data": "worked!"}), "error": False, "exception_name": False, "severity": False, } self.assertRecordValues(entry, [expected]) + expected_json = { + "result": {"data": "worked!"}, + "params": dict(params, _id=_id), + "headers": { + "Cookie": "", + "Api-Key": "", + "KEEP-ME": "FOO", + }, + } + for k, v in expected_json.items(): + self.assertEqual(json.loads(entry[k]), v) def test_log_entry_values_failed(self): _id = "whatever-id" @@ -203,6 +210,7 @@ def test_log_entry_severity_mapping_param(self): self.assertEqual(mapping["odoo.exceptions.UserError"], "severe") self._test_log_entry_values_failed_with_exception_default("warning") + @mute_logger("odoo.addons.shopfloor.models.shopfloor_log") def test_log_entry_severity_mapping_param_bad_values(self): # bad values are discarded value = """ From 61b154e820c3f2b06f1f0b6bbb04afe4fc3f57dd Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 26 Nov 2020 15:04:47 +0100 Subject: [PATCH 455/940] shopfloor: fix user service tests --- shopfloor/tests/test_user.py | 34 ++++------------------------------ 1 file changed, 4 insertions(+), 30 deletions(-) diff --git a/shopfloor/tests/test_user.py b/shopfloor/tests/test_user.py index 066a3f2b3b..e1175ae8bb 100644 --- a/shopfloor/tests/test_user.py +++ b/shopfloor/tests/test_user.py @@ -1,9 +1,9 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from .common import CommonCase +from .test_menu_base import CommonMenuCase -class UserCase(CommonCase): +class UserCase(CommonMenuCase): @classmethod def setUpClassVars(cls, *args, **kwargs): super().setUpClassVars(*args, **kwargs) @@ -22,20 +22,7 @@ def test_menu_no_profile(self): menus = self.env["shopfloor.menu"].search([]) self.assert_response( response, - data={ - "menus": [ - { - "id": menu.id, - "name": menu.name, - "scenario": menu.scenario, - "picking_types": [ - {"id": picking_type.id, "name": picking_type.name} - for picking_type in menu.picking_type_ids - ], - } - for menu in menus - ] - }, + data={"menus": [self._data_for_menu_item(menu) for menu in menus]}, ) def test_menu_by_profile(self): @@ -48,18 +35,5 @@ def test_menu_by_profile(self): response = self.service.dispatch("menu") self.assert_response( - response, - data={ - "menus": [ - { - "id": menu.id, - "name": menu.name, - "scenario": menu.scenario, - "picking_types": [ - {"id": picking_type.id, "name": picking_type.name} - for picking_type in menu.picking_type_ids - ], - } - ] - }, + response, data={"menus": [self._data_for_menu_item(menu)]}, ) From e14e2e57f191f2a37383dcc131859024334797e0 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 26 Nov 2020 15:38:05 +0100 Subject: [PATCH 456/940] shopfloor: fix picking types propagation for scenario --- shopfloor/services/service.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index e2ca971ca3..dcfe75f6dc 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -472,6 +472,14 @@ def picking_types(self): ) return self.work.picking_types + @property + def search_move_line(self): + # TODO: picking types should be set somehow straight in the work context + # by `_validate_headers_update_work_context` in this way + # we can remove this override and the need to call `_get_process_picking_types` + # every time. + return self.actions_for("search_move_line", picking_types=self.picking_types) + def _check_picking_status(self, pickings): """Check if given pickings can be processed. From a7df6f4c0b51bc3665bee00ffb50c59ed5214bd5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 24 Nov 2020 09:41:35 +0100 Subject: [PATCH 457/940] shopfloor bump 13.0.1.4.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 4f6dfa30d2..fe42dc3e39 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "13.0.1.3.7", + "version": "13.0.1.4.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From eaeadbf5cd91c61051b0cc0723c1aca832fd9cc3 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 1 Dec 2020 16:16:08 +0000 Subject: [PATCH 458/940] shopfloor 13.0.1.5.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index fe42dc3e39..4ed966e3a5 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "13.0.1.4.0", + "version": "13.0.1.5.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 3a2d463eb64737ecf034714e7683986ab45d0ba3 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 1 Dec 2020 10:56:13 +0100 Subject: [PATCH 459/940] shopfloor: fix change package w/ same record Prevent errors like odoo.exceptions.MissingError: Record does not exist or has been deleted. (Record: stock.move.line(3557,), User: 605) --- shopfloor/actions/change_package_lot.py | 7 +++++++ shopfloor/actions/message.py | 6 ++++++ .../tests/test_actions_change_package_lot.py | 20 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/shopfloor/actions/change_package_lot.py b/shopfloor/actions/change_package_lot.py index c21e8e92e8..82876f1d04 100644 --- a/shopfloor/actions/change_package_lot.py +++ b/shopfloor/actions/change_package_lot.py @@ -175,6 +175,13 @@ def _package_content_replacement_allowed(self, package, move_line): return move_line.product_id in package.quant_ids.product_id def change_package(self, move_line, package, response_ok_func, response_error_func): + # Prevent change if package is already set and it's the same + if move_line.package_id == package: + return response_error_func( + move_line, + message=self.msg_store.package_change_error_same_package(package), + ) + # prevent to replace a package by a package that would not satisfy the # move (different product) content_replacement_allowed = self._package_content_replacement_allowed( diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 90cdf2f174..a5e6c924af 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -239,6 +239,12 @@ def package_different_content(self, package): "body": _("Package {} has a different content.").format(package.name), } + def package_change_error_same_package(self, package): + return { + "message_type": "error", + "body": _("Same package {} is already assigned.").format(package.name), + } + def x_units_put_in_package(self, qty, product, package): return { "message_type": "success", diff --git a/shopfloor/tests/test_actions_change_package_lot.py b/shopfloor/tests/test_actions_change_package_lot.py index 967729f044..03678c0b56 100644 --- a/shopfloor/tests/test_actions_change_package_lot.py +++ b/shopfloor/tests/test_actions_change_package_lot.py @@ -1167,3 +1167,23 @@ def test_package_2_lines_1_move(self): self.assertEqual(package1.quant_ids.reserved_quantity, 4) self.assertEqual(package2.quant_ids.reserved_quantity, 0) self.assertEqual(package3.quant_ids.reserved_quantity, 6) + + def test_change_pack_same(self): + initial_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 100, lot=None)] + ) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.assertEqual(line.package_id, initial_package) + self.change_package_lot.change_package( + line, + initial_package, + # success callback + self.unreachable_func, + # failure callback + lambda move_line, message=None: self.assertEqual( + message, + self.msg_store.package_change_error_same_package(initial_package), + ), + ) From 1dded5dfcd004b6d32875b3222429c261808e248 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 1 Dec 2020 12:04:05 +0100 Subject: [PATCH 460/940] shopfloor: cluster picking split scan tests --- shopfloor/tests/__init__.py | 4 +- .../tests/test_cluster_picking_is_zero.py | 100 +++ shopfloor/tests/test_cluster_picking_scan.py | 704 ------------------ .../test_cluster_picking_scan_destination.py | 336 +++++++++ .../tests/test_cluster_picking_scan_line.py | 273 +++++++ 5 files changed, 712 insertions(+), 705 deletions(-) create mode 100644 shopfloor/tests/test_cluster_picking_is_zero.py delete mode 100644 shopfloor/tests/test_cluster_picking_scan.py create mode 100644 shopfloor/tests/test_cluster_picking_scan_destination.py create mode 100644 shopfloor/tests/test_cluster_picking_scan_line.py diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 3146498a5a..6013d93885 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -12,7 +12,9 @@ from . import test_cluster_picking_base from . import test_cluster_picking_batch from . import test_cluster_picking_select -from . import test_cluster_picking_scan +from . import test_cluster_picking_scan_line +from . import test_cluster_picking_scan_destination +from . import test_cluster_picking_is_zero from . import test_cluster_picking_skip from . import test_cluster_picking_stock_issue from . import test_cluster_picking_change_pack_lot diff --git a/shopfloor/tests/test_cluster_picking_is_zero.py b/shopfloor/tests/test_cluster_picking_is_zero.py new file mode 100644 index 0000000000..470cf8cba0 --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_is_zero.py @@ -0,0 +1,100 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_cluster_picking_base import ClusterPickingCommonCase + + +class ClusterPickingIsZeroCase(ClusterPickingCommonCase): + """Tests covering the /is_zero endpoint + + After a line has been scanned, if the location is empty, the + client application is redirected to the "zero_check" state, + where the user has to confirm or not that the location is empty. + When the location is empty, there is nothing to do, but when it + in fact not empty, a draft inventory must be created for the + product so someone can verify. + """ + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.batch = cls._create_picking_batch( + [ + [ + cls.BatchProduct(product=cls.product_a, quantity=10), + cls.BatchProduct(product=cls.product_b, quantity=10), + ] + ] + ) + cls.picking = cls.batch.picking_ids + cls._simulate_batch_selected(cls.batch) + + cls.line = cls.picking.move_line_ids[0] + cls.next_line = cls.picking.move_line_ids[1] + cls.bin1 = cls.env["stock.quant.package"].create({}) + cls._update_qty_in_location( + cls.line.location_id, cls.line.product_id, cls.line.product_uom_qty + ) + # we already scan and put the first line in bin1, at this point the + # system see the location is empty and reach "zero_check" + cls._set_dest_package_and_done(cls.line, cls.bin1) + + def test_is_zero_is_empty(self): + """call /is_zero confirming it's empty""" + response = self.service.dispatch( + "is_zero", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": self.line.id, + "zero": True, + }, + ) + self.assert_response( + response, + next_state="start_line", + data=self._line_data(self.next_line), + message={ + "message_type": "success", + "body": "{} {} put in {}".format( + self.line.qty_done, + self.line.product_id.display_name, + self.bin1.name, + ), + }, + ) + + def test_is_zero_is_not_empty(self): + """call /is_zero not confirming it's empty""" + response = self.service.dispatch( + "is_zero", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": self.line.id, + "zero": False, + }, + ) + inventory = self.env["stock.inventory"].search( + [ + ("location_ids", "in", self.line.location_id.id), + ("product_ids", "in", self.line.product_id.id), + ("state", "=", "draft"), + ] + ) + self.assertTrue(inventory) + self.assertEqual( + inventory.name, + "Zero check issue on location Stock ({})".format(self.picking.name), + ) + self.assert_response( + response, + next_state="start_line", + data=self._line_data(self.next_line), + message={ + "message_type": "success", + "body": "{} {} put in {}".format( + self.line.qty_done, + self.line.product_id.display_name, + self.bin1.name, + ), + }, + ) diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py deleted file mode 100644 index 49326a0f2b..0000000000 --- a/shopfloor/tests/test_cluster_picking_scan.py +++ /dev/null @@ -1,704 +0,0 @@ -# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). - -from .test_cluster_picking_base import ( - ClusterPickingCommonCase, - ClusterPickingLineCommonCase, -) - - -class ClusterPickingScanLineCase(ClusterPickingLineCommonCase): - """Tests covering the /scan_line endpoint - - After a batch has been selected and the user confirmed they are - working on it. - - User scans something and the scan_line endpoints validates they - scanned the proper thing to pick. - """ - - def _scan_line_ok(self, line, scanned): - batch = line.picking_id.batch_id - response = self.service.dispatch( - "scan_line", - params={ - "picking_batch_id": batch.id, - "move_line_id": line.id, - "barcode": scanned, - }, - ) - self.assert_response( - response, next_state="scan_destination", data=self._line_data(line) - ) - - def _scan_line_error(self, line, scanned, message): - batch = line.picking_id.batch_id - response = self.service.dispatch( - "scan_line", - params={ - "picking_batch_id": batch.id, - "move_line_id": line.id, - "barcode": scanned, - }, - ) - self.assert_response( - response, - next_state="start_line", - data=self._line_data(line), - message=message, - ) - - def test_scan_line_pack_ok(self): - """Scan to check if user picks the correct pack for current line""" - self._simulate_batch_selected(self.batch, in_package=True) - line = self.batch.picking_ids.move_line_ids - self._scan_line_ok(line, line.package_id.name) - - def test_scan_line_product_ok(self): - """Scan to check if user picks the correct product for current line""" - self._simulate_batch_selected(self.batch) - line = self.batch.picking_ids.move_line_ids - self._scan_line_ok(line, line.product_id.barcode) - - def test_scan_line_lot_ok(self): - """Scan to check if user picks the correct lot for current line""" - self.product_a.tracking = "lot" - self._simulate_batch_selected(self.batch, in_lot=True) - line = self.batch.picking_ids.move_line_ids - self._scan_line_ok(line, line.lot_id.name) - - def test_scan_line_serial_ok(self): - """Scan to check if user picks the correct serial for current line""" - self.product_a.tracking = "serial" - self._simulate_batch_selected(self.batch, in_lot=True) - line = self.batch.picking_ids.move_line_ids - self._scan_line_ok(line, line.lot_id.name) - - def test_scan_line_error_product_tracked(self): - """Scan a product tracked by lot, must scan the lot""" - self.product_a.tracking = "lot" - self._simulate_batch_selected(self.batch, in_lot=True) - line = self.batch.picking_ids.move_line_ids - self._scan_line_error( - line, - line.product_id.barcode, - { - "message_type": "warning", - "body": "Product tracked by lot, please scan one.", - }, - ) - - def test_scan_line_product_error_several_packages(self): - """When we scan a product which is in more than one package, error""" - self._simulate_batch_selected(self.batch, in_package=True) - line = self.batch.picking_ids.move_line_ids - # create a second move line for the same product in a different - # package - move = line.move_id.copy() - self._fill_stock_for_moves(move, in_package=True) - move._action_confirm(merge=False) - move._action_assign() - - self._scan_line_error( - line, - move.product_id.barcode, - { - "message_type": "warning", - "body": "This product is part of multiple" - " packages, please scan a package.", - }, - ) - - def test_scan_line_product_error_in_one_package_and_unit(self): - """When we scan a product which is in a package and as raw, error""" - self._simulate_batch_selected(self.batch, in_package=True) - line = self.batch.picking_ids.move_line_ids - # create a second move line for the same product in a different - # package - move = line.move_id.copy() - self._fill_stock_for_moves(move) - move._action_confirm(merge=False) - move._action_assign() - - self._scan_line_error( - line, - move.product_id.barcode, - { - "message_type": "warning", - "body": "This product is part of multiple" - " packages, please scan a package.", - }, - ) - - def test_scan_line_lot_error_several_packages(self): - """When we scan a lot which is in more than one package, error""" - self._simulate_batch_selected(self.batch, in_package=True, in_lot=True) - line = self.batch.picking_ids.move_line_ids - # create a second move line for the same product in a different - # package - move = line.move_id.copy() - self._fill_stock_for_moves(move, in_lot=line.lot_id) - move._action_confirm(merge=False) - move._action_assign() - - self._scan_line_error( - line, - line.lot_id.name, - { - "message_type": "warning", - "body": "This lot is part of multiple" - " packages, please scan a package.", - }, - ) - - def test_scan_line_lot_error_in_one_package_and_unit(self): - """When we scan a lot which is in a package and as raw, error""" - self._simulate_batch_selected(self.batch, in_package=True, in_lot=True) - line = self.batch.picking_ids.move_line_ids - # create a second move line for the same product in a different - # package - move = line.move_id.copy() - self._fill_stock_for_moves(move, in_lot=line.lot_id) - move._action_confirm(merge=False) - move._action_assign() - self._scan_line_error( - line, - line.lot_id.name, - { - "message_type": "warning", - "body": "This lot is part of multiple" - " packages, please scan a package.", - }, - ) - - def test_scan_line_location_ok_single_package(self): - """Scan to check if user scans a correct location for current line - - If there is only one single package in the location, there is no - ambiguity so we can use it. - """ - self._simulate_batch_selected(self.batch, in_package=True) - line = self.batch.picking_ids.move_line_ids - self._scan_line_ok(line, line.location_id.barcode) - - def test_scan_line_location_ok_single_product(self): - """Scan to check if user scans a correct location for current line - - If there is only one single product in the location, there is no - ambiguity so we can use it. - """ - self._simulate_batch_selected(self.batch) - line = self.batch.picking_ids.move_line_ids - self._scan_line_ok(line, line.location_id.barcode) - - def test_scan_line_location_ok_single_lot(self): - """Scan to check if user scans a correct location for current line - - If there is only one single lot in the location, there is no - ambiguity so we can use it. - """ - self._simulate_batch_selected(self.batch, in_lot=True) - line = self.batch.picking_ids.move_line_ids - self._scan_line_ok(line, line.location_id.barcode) - - def test_scan_line_location_error_several_package(self): - """Scan to check if user scans a correct location for current line - - If there are several packages in the location, user has to scan one. - """ - self._simulate_batch_selected(self.batch, in_package=True) - line = self.batch.picking_ids.move_line_ids - location = line.location_id - # add a second package in the location - self._update_qty_in_location( - location, - self.product_b, - 10, - package=self.env["stock.quant.package"].create({}), - ) - self._scan_line_error( - line, - location.barcode, - { - "message_type": "warning", - "body": "Several packages found in Stock, please scan a package.", - }, - ) - - def test_scan_line_location_error_several_products(self): - """Scan to check if user scans a correct location for current line - - If there are several products in the location, user has to scan one. - """ - self._simulate_batch_selected(self.batch) - line = self.batch.picking_ids.move_line_ids - location = line.location_id - # add a second product in the location - self._update_qty_in_location(location, self.product_b, 10) - self._scan_line_error( - line, - location.barcode, - { - "message_type": "warning", - "body": "Several products found in Stock, please scan a product.", - }, - ) - - def test_scan_line_location_error_several_lots(self): - """Scan to check if user scans a correct location for current line - - If there are several lots in the location, user has to scan one. - """ - self._simulate_batch_selected(self.batch, in_lot=True) - line = self.batch.picking_ids.move_line_ids - location = line.location_id - lot = self.env["stock.production.lot"].create( - {"product_id": self.product_a.id, "company_id": self.env.company.id} - ) - # add a second lot in the location - self._update_qty_in_location(location, self.product_a, 10, lot=lot) - self._scan_line_error( - line, - location.barcode, - { - "message_type": "warning", - "body": "Several lots found in Stock, please scan a lot.", - }, - ) - - def test_scan_line_error_not_found(self): - """Nothing found for the barcode""" - self._simulate_batch_selected(self.batch, in_package=True) - self._scan_line_error( - self.batch.picking_ids.move_line_ids, - "NO_EXISTING_BARCODE", - {"message_type": "error", "body": "Barcode not found"}, - ) - - -class ClusterPickingScanDestinationPackCase(ClusterPickingCommonCase): - """Tests covering the /scan_destination_pack endpoint - - After a batch has been selected and the user confirmed they are - working on it, user picked the good, now they scan the location - destination. - """ - - @classmethod - def setUpClassBaseData(cls, *args, **kwargs): - super().setUpClassBaseData(*args, **kwargs) - cls.batch = cls._create_picking_batch( - [ - [ - cls.BatchProduct(product=cls.product_a, quantity=10), - cls.BatchProduct(product=cls.product_b, quantity=10), - ], - [cls.BatchProduct(product=cls.product_a, quantity=10)], - ] - ) - cls.one_line_picking = cls.batch.picking_ids.filtered( - lambda picking: len(picking.move_lines) == 1 - ) - cls.two_lines_picking = cls.batch.picking_ids.filtered( - lambda picking: len(picking.move_lines) == 2 - ) - - cls.bin1 = cls.env["stock.quant.package"].create({}) - cls.bin2 = cls.env["stock.quant.package"].create({}) - - cls._simulate_batch_selected(cls.batch) - - def test_scan_destination_pack_ok(self): - """Happy path for scan destination package - - It sets the line in the pack for the full qty - """ - line = self.batch.picking_ids.move_line_ids[0] - next_line = self.batch.picking_ids.move_line_ids[1] - qty_done = line.product_uom_qty - response = self.service.dispatch( - "scan_destination_pack", - params={ - "picking_batch_id": self.batch.id, - "move_line_id": line.id, - "barcode": self.bin1.name, - "quantity": qty_done, - }, - ) - self.assertRecordValues( - line, [{"qty_done": qty_done, "result_package_id": self.bin1.id}] - ) - self.assert_response( - response, - next_state="start_line", - data=self._line_data(next_line), - message={ - "message_type": "success", - "body": "{} {} put in {}".format( - line.qty_done, line.product_id.display_name, self.bin1.name - ), - }, - ) - - def test_scan_destination_pack_ok_last_line(self): - """Happy path for scan destination package - - It sets the line in the pack for the full qty - """ - self._set_dest_package_and_done(self.one_line_picking.move_line_ids, self.bin1) - self._set_dest_package_and_done( - self.two_lines_picking.move_line_ids[0], self.bin2 - ) - # this is the only remaining line to pick - line = self.two_lines_picking.move_line_ids[1] - qty_done = line.product_uom_qty - response = self.service.dispatch( - "scan_destination_pack", - params={ - "picking_batch_id": self.batch.id, - "move_line_id": line.id, - "barcode": self.bin2.name, - "quantity": qty_done, - }, - ) - self.assertRecordValues( - line, [{"qty_done": qty_done, "result_package_id": self.bin2.id}] - ) - data = self._data_for_batch(self.batch, self.packing_location) - self.assert_response( - response, - # they reach the same destination so next state unload_all - next_state="unload_all", - data=data, - ) - - def test_scan_destination_pack_not_empty_same_picking(self): - """Scan a destination package with move lines of same picking""" - line1 = self.two_lines_picking.move_line_ids[0] - line2 = self.two_lines_picking.move_line_ids[1] - # we already scan and put the first line in bin1 - self._set_dest_package_and_done(line1, self.bin1) - response = self.service.dispatch( - "scan_destination_pack", - params={ - "picking_batch_id": self.batch.id, - "move_line_id": line2.id, - # this bin is used for the same picking, should be allowed - "barcode": self.bin1.name, - "quantity": line2.product_uom_qty, - }, - ) - self.assert_response( - response, - next_state="start_line", - # we did not pick this line, so it should go there - data=self._line_data(self.one_line_picking.move_line_ids), - message=self.ANY, - ) - - def test_scan_destination_pack_not_empty_different_picking(self): - """Scan a destination package with move lines of other picking""" - # do as if the user already picked the first good (for another picking) - # and put it in bin1 - self._set_dest_package_and_done(self.one_line_picking.move_line_ids, self.bin1) - line = self.two_lines_picking.move_line_ids[0] - response = self.service.dispatch( - "scan_destination_pack", - params={ - "picking_batch_id": self.batch.id, - "move_line_id": line.id, - # this bin is used for the other picking - "barcode": self.bin1.name, - "quantity": line.product_uom_qty, - }, - ) - self.assertRecordValues(line, [{"qty_done": 0, "result_package_id": False}]) - self.assert_response( - response, - next_state="scan_destination", - data=self._line_data(line), - message={ - "message_type": "error", - "body": "The destination bin {} is not empty," - " please take another.".format(self.bin1.name), - }, - ) - - def test_scan_destination_pack_bin_not_found(self): - """Scan a destination package that do not exist""" - line = self.one_line_picking.move_line_ids - response = self.service.dispatch( - "scan_destination_pack", - params={ - "picking_batch_id": self.batch.id, - "move_line_id": line.id, - # this bin is used for the other picking - "barcode": "⌿", - "quantity": line.product_uom_qty, - }, - ) - self.assert_response( - response, - next_state="scan_destination", - data=self._line_data(line), - message={ - "message_type": "error", - "body": "Bin {} doesn't exist".format("⌿"), - }, - ) - - def test_scan_destination_pack_quantity_more(self): - """Pick more units than expected""" - line = self.one_line_picking.move_line_ids - response = self.service.dispatch( - "scan_destination_pack", - params={ - "picking_batch_id": self.batch.id, - "move_line_id": line.id, - "barcode": self.bin1.name, - "quantity": line.product_uom_qty + 1, - }, - ) - self.assert_response( - response, - next_state="scan_destination", - data=self._line_data(line), - message={ - "message_type": "error", - "body": "You must not pick more than {} units.".format( - line.product_uom_qty - ), - }, - ) - - def test_scan_destination_pack_quantity_less(self): - """Pick less units than expected""" - line = self.one_line_picking.move_line_ids - quant = self.env["stock.quant"].search( - [ - ("location_id", "=", line.location_id.id), - ("product_id", "=", line.product_id.id), - ] - ) - quant.ensure_one() - self.assertRecordValues(quant, [{"quantity": 40.0, "reserved_quantity": 20.0}]) - - # when we pick less quantity than expected, the line is split - # and the user is proposed to pick the next line for the remaining - # quantity - response = self.service.dispatch( - "scan_destination_pack", - params={ - "picking_batch_id": self.batch.id, - "move_line_id": line.id, - "barcode": self.bin1.name, - "quantity": line.product_uom_qty - 3, - }, - ) - new_line = self.one_line_picking.move_line_ids - line - - self.assert_response( - response, - next_state="start_line", - data=self._line_data(new_line), - message={ - "message_type": "success", - "body": "{} {} put in {}".format( - line.qty_done, line.product_id.display_name, self.bin1.name - ), - }, - ) - - self.assertRecordValues( - line, - [{"qty_done": 7, "result_package_id": self.bin1.id, "product_uom_qty": 7}], - ) - self.assertRecordValues( - new_line, - [{"qty_done": 0, "result_package_id": False, "product_uom_qty": 3}], - ) - # the reserved quantity on the quant must stay the same - self.assertRecordValues(quant, [{"quantity": 40.0, "reserved_quantity": 20.0}]) - - def test_scan_destination_pack_zero_check_activated(self): - """Location will be emptied, have to go to zero check""" - # ensure that the location used for the test will contain only what we want - self.zero_check_location = ( - self.env["stock.location"] - .sudo() - .create( - { - "name": "ZeroCheck", - "location_id": self.stock_location.id, - "barcode": "ZEROCHECK", - } - ) - ) - line = self.one_line_picking.move_line_ids - location, product, qty = ( - self.zero_check_location, - line.product_id, - line.product_uom_qty, - ) - self.one_line_picking.do_unreserve() - - # ensure we have activated the zero check - self.one_line_picking.picking_type_id.sudo().shopfloor_zero_check = True - # Update the quantity in the location to be equal to the line's - # so when scan_destination_pack sets the qty_done, the planned - # qty should be zero and trigger a zero check - self._update_qty_in_location(location, product, qty) - # Reserve goods (now the move line has the expected source location) - self.one_line_picking.move_lines.location_id = location - self.one_line_picking.action_assign() - line = self.one_line_picking.move_line_ids - response = self.service.dispatch( - "scan_destination_pack", - params={ - "picking_batch_id": self.batch.id, - "move_line_id": line.id, - "barcode": self.bin1.name, - "quantity": line.product_uom_qty, - }, - ) - - self.assert_response( - response, - next_state="zero_check", - data={ - "id": line.id, - "location_src": self.data.location(line.location_id), - "batch": self.data.picking_batch(self.batch), - }, - ) - - def test_scan_destination_pack_zero_check_disabled(self): - """Location will be emptied, no zero check, continue""" - line = self.one_line_picking.move_line_ids - # ensure we have deactivated the zero check - self.one_line_picking.picking_type_id.sudo().shopfloor_zero_check = False - # Update the quantity in the location to be equal to the line's - # so when scan_destination_pack sets the qty_done, the planned - # qty should be zero and trigger a zero check - self._update_qty_in_location( - line.location_id, line.product_id, line.product_uom_qty - ) - response = self.service.dispatch( - "scan_destination_pack", - params={ - "picking_batch_id": self.batch.id, - "move_line_id": line.id, - "barcode": self.bin1.name, - "quantity": line.product_uom_qty, - }, - ) - - next_line = self.two_lines_picking.move_line_ids[0] - # continue to the next one, no zero check - self.assert_response( - response, - next_state="start_line", - data=self._line_data(next_line), - message={ - "message_type": "success", - "body": "{} {} put in {}".format( - line.qty_done, line.product_id.display_name, self.bin1.name - ), - }, - ) - - -class ClusterPickingIsZeroCase(ClusterPickingCommonCase): - """Tests covering the /is_zero endpoint - - After a line has been scanned, if the location is empty, the - client application is redirected to the "zero_check" state, - where the user has to confirm or not that the location is empty. - When the location is empty, there is nothing to do, but when it - in fact not empty, a draft inventory must be created for the - product so someone can verify. - """ - - @classmethod - def setUpClassBaseData(cls, *args, **kwargs): - super().setUpClassBaseData(*args, **kwargs) - cls.batch = cls._create_picking_batch( - [ - [ - cls.BatchProduct(product=cls.product_a, quantity=10), - cls.BatchProduct(product=cls.product_b, quantity=10), - ] - ] - ) - cls.picking = cls.batch.picking_ids - cls._simulate_batch_selected(cls.batch) - - cls.line = cls.picking.move_line_ids[0] - cls.next_line = cls.picking.move_line_ids[1] - cls.bin1 = cls.env["stock.quant.package"].create({}) - cls._update_qty_in_location( - cls.line.location_id, cls.line.product_id, cls.line.product_uom_qty - ) - # we already scan and put the first line in bin1, at this point the - # system see the location is empty and reach "zero_check" - cls._set_dest_package_and_done(cls.line, cls.bin1) - - def test_is_zero_is_empty(self): - """call /is_zero confirming it's empty""" - response = self.service.dispatch( - "is_zero", - params={ - "picking_batch_id": self.batch.id, - "move_line_id": self.line.id, - "zero": True, - }, - ) - self.assert_response( - response, - next_state="start_line", - data=self._line_data(self.next_line), - message={ - "message_type": "success", - "body": "{} {} put in {}".format( - self.line.qty_done, - self.line.product_id.display_name, - self.bin1.name, - ), - }, - ) - - def test_is_zero_is_not_empty(self): - """call /is_zero not confirming it's empty""" - response = self.service.dispatch( - "is_zero", - params={ - "picking_batch_id": self.batch.id, - "move_line_id": self.line.id, - "zero": False, - }, - ) - inventory = self.env["stock.inventory"].search( - [ - ("location_ids", "in", self.line.location_id.id), - ("product_ids", "in", self.line.product_id.id), - ("state", "=", "draft"), - ] - ) - self.assertTrue(inventory) - self.assertEqual( - inventory.name, - "Zero check issue on location Stock ({})".format(self.picking.name), - ) - self.assert_response( - response, - next_state="start_line", - data=self._line_data(self.next_line), - message={ - "message_type": "success", - "body": "{} {} put in {}".format( - self.line.qty_done, - self.line.product_id.display_name, - self.bin1.name, - ), - }, - ) diff --git a/shopfloor/tests/test_cluster_picking_scan_destination.py b/shopfloor/tests/test_cluster_picking_scan_destination.py new file mode 100644 index 0000000000..4b7ab39305 --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_scan_destination.py @@ -0,0 +1,336 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_cluster_picking_base import ClusterPickingCommonCase + + +class ClusterPickingScanDestinationPackCase(ClusterPickingCommonCase): + """Tests covering the /scan_destination_pack endpoint + + After a batch has been selected and the user confirmed they are + working on it, user picked the good, now they scan the location + destination. + """ + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.batch = cls._create_picking_batch( + [ + [ + cls.BatchProduct(product=cls.product_a, quantity=10), + cls.BatchProduct(product=cls.product_b, quantity=10), + ], + [cls.BatchProduct(product=cls.product_a, quantity=10)], + ] + ) + cls.one_line_picking = cls.batch.picking_ids.filtered( + lambda picking: len(picking.move_lines) == 1 + ) + cls.two_lines_picking = cls.batch.picking_ids.filtered( + lambda picking: len(picking.move_lines) == 2 + ) + + cls.bin1 = cls.env["stock.quant.package"].create({}) + cls.bin2 = cls.env["stock.quant.package"].create({}) + + cls._simulate_batch_selected(cls.batch) + + def test_scan_destination_pack_ok(self): + """Happy path for scan destination package + + It sets the line in the pack for the full qty + """ + line = self.batch.picking_ids.move_line_ids[0] + next_line = self.batch.picking_ids.move_line_ids[1] + qty_done = line.product_uom_qty + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + "barcode": self.bin1.name, + "quantity": qty_done, + }, + ) + self.assertRecordValues( + line, [{"qty_done": qty_done, "result_package_id": self.bin1.id}] + ) + self.assert_response( + response, + next_state="start_line", + data=self._line_data(next_line), + message={ + "message_type": "success", + "body": "{} {} put in {}".format( + line.qty_done, line.product_id.display_name, self.bin1.name + ), + }, + ) + + def test_scan_destination_pack_ok_last_line(self): + """Happy path for scan destination package + + It sets the line in the pack for the full qty + """ + self._set_dest_package_and_done(self.one_line_picking.move_line_ids, self.bin1) + self._set_dest_package_and_done( + self.two_lines_picking.move_line_ids[0], self.bin2 + ) + # this is the only remaining line to pick + line = self.two_lines_picking.move_line_ids[1] + qty_done = line.product_uom_qty + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + "barcode": self.bin2.name, + "quantity": qty_done, + }, + ) + self.assertRecordValues( + line, [{"qty_done": qty_done, "result_package_id": self.bin2.id}] + ) + data = self._data_for_batch(self.batch, self.packing_location) + self.assert_response( + response, + # they reach the same destination so next state unload_all + next_state="unload_all", + data=data, + ) + + def test_scan_destination_pack_not_empty_same_picking(self): + """Scan a destination package with move lines of same picking""" + line1 = self.two_lines_picking.move_line_ids[0] + line2 = self.two_lines_picking.move_line_ids[1] + # we already scan and put the first line in bin1 + self._set_dest_package_and_done(line1, self.bin1) + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line2.id, + # this bin is used for the same picking, should be allowed + "barcode": self.bin1.name, + "quantity": line2.product_uom_qty, + }, + ) + self.assert_response( + response, + next_state="start_line", + # we did not pick this line, so it should go there + data=self._line_data(self.one_line_picking.move_line_ids), + message=self.ANY, + ) + + def test_scan_destination_pack_not_empty_different_picking(self): + """Scan a destination package with move lines of other picking""" + # do as if the user already picked the first good (for another picking) + # and put it in bin1 + self._set_dest_package_and_done(self.one_line_picking.move_line_ids, self.bin1) + line = self.two_lines_picking.move_line_ids[0] + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + # this bin is used for the other picking + "barcode": self.bin1.name, + "quantity": line.product_uom_qty, + }, + ) + self.assertRecordValues(line, [{"qty_done": 0, "result_package_id": False}]) + self.assert_response( + response, + next_state="scan_destination", + data=self._line_data(line), + message={ + "message_type": "error", + "body": "The destination bin {} is not empty," + " please take another.".format(self.bin1.name), + }, + ) + + def test_scan_destination_pack_bin_not_found(self): + """Scan a destination package that do not exist""" + line = self.one_line_picking.move_line_ids + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + # this bin is used for the other picking + "barcode": "⌿", + "quantity": line.product_uom_qty, + }, + ) + self.assert_response( + response, + next_state="scan_destination", + data=self._line_data(line), + message={ + "message_type": "error", + "body": "Bin {} doesn't exist".format("⌿"), + }, + ) + + def test_scan_destination_pack_quantity_more(self): + """Pick more units than expected""" + line = self.one_line_picking.move_line_ids + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + "barcode": self.bin1.name, + "quantity": line.product_uom_qty + 1, + }, + ) + self.assert_response( + response, + next_state="scan_destination", + data=self._line_data(line), + message={ + "message_type": "error", + "body": "You must not pick more than {} units.".format( + line.product_uom_qty + ), + }, + ) + + def test_scan_destination_pack_quantity_less(self): + """Pick less units than expected""" + line = self.one_line_picking.move_line_ids + quant = self.env["stock.quant"].search( + [ + ("location_id", "=", line.location_id.id), + ("product_id", "=", line.product_id.id), + ] + ) + quant.ensure_one() + self.assertRecordValues(quant, [{"quantity": 40.0, "reserved_quantity": 20.0}]) + + # when we pick less quantity than expected, the line is split + # and the user is proposed to pick the next line for the remaining + # quantity + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + "barcode": self.bin1.name, + "quantity": line.product_uom_qty - 3, + }, + ) + new_line = self.one_line_picking.move_line_ids - line + + self.assert_response( + response, + next_state="start_line", + data=self._line_data(new_line), + message={ + "message_type": "success", + "body": "{} {} put in {}".format( + line.qty_done, line.product_id.display_name, self.bin1.name + ), + }, + ) + + self.assertRecordValues( + line, + [{"qty_done": 7, "result_package_id": self.bin1.id, "product_uom_qty": 7}], + ) + self.assertRecordValues( + new_line, + [{"qty_done": 0, "result_package_id": False, "product_uom_qty": 3}], + ) + # the reserved quantity on the quant must stay the same + self.assertRecordValues(quant, [{"quantity": 40.0, "reserved_quantity": 20.0}]) + + def test_scan_destination_pack_zero_check_activated(self): + """Location will be emptied, have to go to zero check""" + # ensure that the location used for the test will contain only what we want + self.zero_check_location = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "ZeroCheck", + "location_id": self.stock_location.id, + "barcode": "ZEROCHECK", + } + ) + ) + line = self.one_line_picking.move_line_ids + location, product, qty = ( + self.zero_check_location, + line.product_id, + line.product_uom_qty, + ) + self.one_line_picking.do_unreserve() + + # ensure we have activated the zero check + self.one_line_picking.picking_type_id.sudo().shopfloor_zero_check = True + # Update the quantity in the location to be equal to the line's + # so when scan_destination_pack sets the qty_done, the planned + # qty should be zero and trigger a zero check + self._update_qty_in_location(location, product, qty) + # Reserve goods (now the move line has the expected source location) + self.one_line_picking.move_lines.location_id = location + self.one_line_picking.action_assign() + line = self.one_line_picking.move_line_ids + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + "barcode": self.bin1.name, + "quantity": line.product_uom_qty, + }, + ) + + self.assert_response( + response, + next_state="zero_check", + data={ + "id": line.id, + "location_src": self.data.location(line.location_id), + "batch": self.data.picking_batch(self.batch), + }, + ) + + def test_scan_destination_pack_zero_check_disabled(self): + """Location will be emptied, no zero check, continue""" + line = self.one_line_picking.move_line_ids + # ensure we have deactivated the zero check + self.one_line_picking.picking_type_id.sudo().shopfloor_zero_check = False + # Update the quantity in the location to be equal to the line's + # so when scan_destination_pack sets the qty_done, the planned + # qty should be zero and trigger a zero check + self._update_qty_in_location( + line.location_id, line.product_id, line.product_uom_qty + ) + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + "barcode": self.bin1.name, + "quantity": line.product_uom_qty, + }, + ) + + next_line = self.two_lines_picking.move_line_ids[0] + # continue to the next one, no zero check + self.assert_response( + response, + next_state="start_line", + data=self._line_data(next_line), + message={ + "message_type": "success", + "body": "{} {} put in {}".format( + line.qty_done, line.product_id.display_name, self.bin1.name + ), + }, + ) diff --git a/shopfloor/tests/test_cluster_picking_scan_line.py b/shopfloor/tests/test_cluster_picking_scan_line.py new file mode 100644 index 0000000000..67a6e33e67 --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_scan_line.py @@ -0,0 +1,273 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_cluster_picking_base import ClusterPickingLineCommonCase + + +class ClusterPickingScanLineCase(ClusterPickingLineCommonCase): + """Tests covering the /scan_line endpoint + + After a batch has been selected and the user confirmed they are + working on it. + + User scans something and the scan_line endpoints validates they + scanned the proper thing to pick. + """ + + def _scan_line_ok(self, line, scanned): + batch = line.picking_id.batch_id + response = self.service.dispatch( + "scan_line", + params={ + "picking_batch_id": batch.id, + "move_line_id": line.id, + "barcode": scanned, + }, + ) + self.assert_response( + response, next_state="scan_destination", data=self._line_data(line) + ) + + def _scan_line_error(self, line, scanned, message): + batch = line.picking_id.batch_id + response = self.service.dispatch( + "scan_line", + params={ + "picking_batch_id": batch.id, + "move_line_id": line.id, + "barcode": scanned, + }, + ) + self.assert_response( + response, + next_state="start_line", + data=self._line_data(line), + message=message, + ) + + def test_scan_line_pack_ok(self): + """Scan to check if user picks the correct pack for current line""" + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.package_id.name) + + def test_scan_line_product_ok(self): + """Scan to check if user picks the correct product for current line""" + self._simulate_batch_selected(self.batch) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.product_id.barcode) + + def test_scan_line_lot_ok(self): + """Scan to check if user picks the correct lot for current line""" + self.product_a.tracking = "lot" + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.lot_id.name) + + def test_scan_line_serial_ok(self): + """Scan to check if user picks the correct serial for current line""" + self.product_a.tracking = "serial" + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.lot_id.name) + + def test_scan_line_error_product_tracked(self): + """Scan a product tracked by lot, must scan the lot""" + self.product_a.tracking = "lot" + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_error( + line, + line.product_id.barcode, + { + "message_type": "warning", + "body": "Product tracked by lot, please scan one.", + }, + ) + + def test_scan_line_product_error_several_packages(self): + """When we scan a product which is in more than one package, error""" + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + # create a second move line for the same product in a different + # package + move = line.move_id.copy() + self._fill_stock_for_moves(move, in_package=True) + move._action_confirm(merge=False) + move._action_assign() + + self._scan_line_error( + line, + move.product_id.barcode, + { + "message_type": "warning", + "body": "This product is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_product_error_in_one_package_and_unit(self): + """When we scan a product which is in a package and as raw, error""" + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + # create a second move line for the same product in a different + # package + move = line.move_id.copy() + self._fill_stock_for_moves(move) + move._action_confirm(merge=False) + move._action_assign() + + self._scan_line_error( + line, + move.product_id.barcode, + { + "message_type": "warning", + "body": "This product is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_lot_error_several_packages(self): + """When we scan a lot which is in more than one package, error""" + self._simulate_batch_selected(self.batch, in_package=True, in_lot=True) + line = self.batch.picking_ids.move_line_ids + # create a second move line for the same product in a different + # package + move = line.move_id.copy() + self._fill_stock_for_moves(move, in_lot=line.lot_id) + move._action_confirm(merge=False) + move._action_assign() + + self._scan_line_error( + line, + line.lot_id.name, + { + "message_type": "warning", + "body": "This lot is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_lot_error_in_one_package_and_unit(self): + """When we scan a lot which is in a package and as raw, error""" + self._simulate_batch_selected(self.batch, in_package=True, in_lot=True) + line = self.batch.picking_ids.move_line_ids + # create a second move line for the same product in a different + # package + move = line.move_id.copy() + self._fill_stock_for_moves(move, in_lot=line.lot_id) + move._action_confirm(merge=False) + move._action_assign() + self._scan_line_error( + line, + line.lot_id.name, + { + "message_type": "warning", + "body": "This lot is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_location_ok_single_package(self): + """Scan to check if user scans a correct location for current line + + If there is only one single package in the location, there is no + ambiguity so we can use it. + """ + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.location_id.barcode) + + def test_scan_line_location_ok_single_product(self): + """Scan to check if user scans a correct location for current line + + If there is only one single product in the location, there is no + ambiguity so we can use it. + """ + self._simulate_batch_selected(self.batch) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.location_id.barcode) + + def test_scan_line_location_ok_single_lot(self): + """Scan to check if user scans a correct location for current line + + If there is only one single lot in the location, there is no + ambiguity so we can use it. + """ + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.location_id.barcode) + + def test_scan_line_location_error_several_package(self): + """Scan to check if user scans a correct location for current line + + If there are several packages in the location, user has to scan one. + """ + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + location = line.location_id + # add a second package in the location + self._update_qty_in_location( + location, + self.product_b, + 10, + package=self.env["stock.quant.package"].create({}), + ) + self._scan_line_error( + line, + location.barcode, + { + "message_type": "warning", + "body": "Several packages found in Stock, please scan a package.", + }, + ) + + def test_scan_line_location_error_several_products(self): + """Scan to check if user scans a correct location for current line + + If there are several products in the location, user has to scan one. + """ + self._simulate_batch_selected(self.batch) + line = self.batch.picking_ids.move_line_ids + location = line.location_id + # add a second product in the location + self._update_qty_in_location(location, self.product_b, 10) + self._scan_line_error( + line, + location.barcode, + { + "message_type": "warning", + "body": "Several products found in Stock, please scan a product.", + }, + ) + + def test_scan_line_location_error_several_lots(self): + """Scan to check if user scans a correct location for current line + + If there are several lots in the location, user has to scan one. + """ + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + location = line.location_id + lot = self.env["stock.production.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + # add a second lot in the location + self._update_qty_in_location(location, self.product_a, 10, lot=lot) + self._scan_line_error( + line, + location.barcode, + { + "message_type": "warning", + "body": "Several lots found in Stock, please scan a lot.", + }, + ) + + def test_scan_line_error_not_found(self): + """Nothing found for the barcode""" + self._simulate_batch_selected(self.batch, in_package=True) + self._scan_line_error( + self.batch.picking_ids.move_line_ids, + "NO_EXISTING_BARCODE", + {"message_type": "error", "body": "Barcode not found"}, + ) From db9ee5d27025795f5440385c6abe0c398486085a Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 1 Dec 2020 12:13:54 +0100 Subject: [PATCH 461/940] shopfloor: cluster picking fix scan product found multiple times When scanning a product barcode you could have the same product in different lines. Till this change an exception was raised and the user asked to scan a specific package or lot. This is not needed if the product is not the same location as the current move line. --- shopfloor/services/cluster_picking.py | 6 +++--- .../tests/test_cluster_picking_scan_line.py | 19 +++++++++++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 9e91d91df0..620751c6d5 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -468,10 +468,10 @@ def _scan_line_by_product(self, picking, move_line, product): move_line, message=self.msg_store.scan_lot_on_product_tracked_by_lot() ) - # if we scanned a product and it's part of several packages, we can't be - # sure the user scanned the correct one, in such case, ask to scan a package + # If scanned product is part of several packages in the same location, + # we can't be sure it's the correct one, in such case, ask to scan a package other_product_lines = picking.move_line_ids.filtered( - lambda l: l.product_id == product + lambda l: l.product_id == product and l.location_id == move_line.location_id ) packages = other_product_lines.mapped("package_id") # Do not use mapped here: we want to see if we have more than one package, diff --git a/shopfloor/tests/test_cluster_picking_scan_line.py b/shopfloor/tests/test_cluster_picking_scan_line.py index 67a6e33e67..626779a7f9 100644 --- a/shopfloor/tests/test_cluster_picking_scan_line.py +++ b/shopfloor/tests/test_cluster_picking_scan_line.py @@ -106,8 +106,8 @@ def test_scan_line_product_error_several_packages(self): }, ) - def test_scan_line_product_error_in_one_package_and_unit(self): - """When we scan a product which is in a package and as raw, error""" + def test_scan_line_product_error_in_one_package_and_raw_same_location(self): + """Scan product which is both in a package and as raw in same location""" self._simulate_batch_selected(self.batch, in_package=True) line = self.batch.picking_ids.move_line_ids # create a second move line for the same product in a different @@ -116,6 +116,7 @@ def test_scan_line_product_error_in_one_package_and_unit(self): self._fill_stock_for_moves(move) move._action_confirm(merge=False) move._action_assign() + move.move_line_ids[0].package_id = None self._scan_line_error( line, @@ -127,6 +128,20 @@ def test_scan_line_product_error_in_one_package_and_unit(self): }, ) + def test_scan_line_product_error_in_one_package_and_raw_different_location(self): + """Scan product which is both in a package and as raw in another location""" + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + # create a second move line for the same product in a different + # package + move = line.move_id.copy() + self._fill_stock_for_moves(move) + move._action_confirm(merge=False) + move._action_assign() + move.move_line_ids[0].package_id = None + move.move_line_ids[0].location_id = line.location_id.copy() + self._scan_line_ok(line, move.product_id.barcode) + def test_scan_line_lot_error_several_packages(self): """When we scan a lot which is in more than one package, error""" self._simulate_batch_selected(self.batch, in_package=True, in_lot=True) From 9f265320e1484bb271f17da444ce7bb3cc785b09 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 1 Dec 2020 14:49:19 +0100 Subject: [PATCH 462/940] shopfloor: fix counters consider ready pickings only --- shopfloor/actions/move_line_search.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/shopfloor/actions/move_line_search.py b/shopfloor/actions/move_line_search.py index 21d11c23bc..2b5adbba5c 100644 --- a/shopfloor/actions/move_line_search.py +++ b/shopfloor/actions/move_line_search.py @@ -28,6 +28,7 @@ def _search_move_lines_by_location_domain( product=None, lot=None, match_user=False, + picking_ready=True, ): domain = [ ("location_id", "child_of", locations.ids), @@ -51,6 +52,8 @@ def _search_move_lines_by_location_domain( ("shopfloor_user_id", "=", False), ("shopfloor_user_id", "=", self.env.uid), ] + if picking_ready: + domain += [("picking_id.state", "=", "assigned")] return domain def search_move_lines_by_location( @@ -63,11 +66,18 @@ def search_move_lines_by_location( order="priority", match_user=False, sort_keys_func=None, + picking_ready=True, ): """Find lines that potentially need work in given locations.""" move_lines = self.env["stock.move.line"].search( self._search_move_lines_by_location_domain( - locations, picking_type, package, product, lot, match_user=match_user + locations, + picking_type, + package, + product, + lot, + match_user=match_user, + picking_ready=picking_ready, ) ) sort_keys_func = sort_keys_func or self._sort_key_move_lines(order) From 39bd2a67c69114ec42915cf44e5c4183f7a97193 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 3 Dec 2020 12:53:22 +0100 Subject: [PATCH 463/940] shopfloor bump 13.0.1.5.1 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 4ed966e3a5..9735277ec7 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "13.0.1.5.0", + "version": "13.0.1.5.1", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From fe7eb5fcd7d7311ee53b11554a2b48e723333f36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Thu, 10 Dec 2020 11:15:40 +0100 Subject: [PATCH 464/940] [IMP] black, isort, prettier --- shopfloor/actions/data.py | 5 +- shopfloor/actions/data_detail.py | 3 +- shopfloor/models/shopfloor_log.py | 3 +- shopfloor/models/stock_location.py | 3 +- shopfloor/models/stock_picking.py | 3 +- shopfloor/models/stock_picking_type.py | 4 +- shopfloor/models/stock_quant.py | 2 +- shopfloor/models/stock_quant_package.py | 3 +- shopfloor/services/checkout.py | 4 +- shopfloor/services/cluster_picking.py | 3 +- shopfloor/services/delivery.py | 9 ++- shopfloor/services/forms/form_mixin.py | 3 +- shopfloor/services/menu.py | 3 +- shopfloor/services/single_pack_transfer.py | 12 +++- shopfloor/tests/test_checkout_cancel_line.py | 6 +- shopfloor/tests/test_checkout_no_package.py | 6 +- .../tests/test_cluster_picking_select.py | 28 ++++++-- shopfloor/tests/test_cluster_picking_skip.py | 3 +- .../tests/test_cluster_picking_stock_issue.py | 3 +- .../tests/test_cluster_picking_unload.py | 32 ++++++--- shopfloor/tests/test_delivery_done.py | 9 ++- .../tests/test_delivery_list_stock_picking.py | 9 ++- shopfloor/tests/test_delivery_scan_deliver.py | 18 +++-- .../test_location_content_transfer_base.py | 5 +- .../test_location_content_transfer_mix.py | 6 +- .../test_location_content_transfer_putaway.py | 7 +- ...ransfer_set_destination_package_or_line.py | 18 +++-- .../test_location_content_transfer_single.py | 34 ++++++---- .../test_location_content_transfer_start.py | 7 +- shopfloor/tests/test_scan_anything.py | 3 +- .../test_single_pack_transfer_putaway.py | 4 +- shopfloor/tests/test_user.py | 3 +- shopfloor/tests/test_zone_picking_base.py | 46 +++++++++++-- .../test_zone_picking_change_pack_lot.py | 9 ++- .../tests/test_zone_picking_select_line.py | 65 ++++++++++++++----- .../test_zone_picking_select_picking_type.py | 6 +- .../test_zone_picking_set_line_destination.py | 19 ++++-- shopfloor/tests/test_zone_picking_start.py | 28 +++++--- .../tests/test_zone_picking_stock_issue.py | 29 +++++++-- .../tests/test_zone_picking_unload_all.py | 14 ++-- ...est_zone_picking_unload_set_destination.py | 6 +- .../tests/test_zone_picking_unload_single.py | 11 +++- .../tests/test_zone_picking_zero_check.py | 22 +++++-- 43 files changed, 370 insertions(+), 146 deletions(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 5301f90245..f1d3bbb644 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -160,7 +160,10 @@ def _move_line_parser(self): ("lot_id:lot", self._lot_parser), ("location_id:location_src", self._location_parser), ("location_dest_id:location_dest", self._location_parser), - ("move_id:priority", lambda rec, fname: rec.move_id.priority or "",), + ( + "move_id:priority", + lambda rec, fname: rec.move_id.priority or "", + ), ] def package_level(self, record, **kw): diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py index e3024da604..ddd7527802 100644 --- a/shopfloor/actions/data_detail.py +++ b/shopfloor/actions/data_detail.py @@ -6,8 +6,7 @@ class DataDetailAction(Component): - """Provide extra data on top of data action. - """ + """Provide extra data on top of data action.""" _name = "shopfloor.data.detail.action" _inherit = "shopfloor.data.action" diff --git a/shopfloor/models/shopfloor_log.py b/shopfloor/models/shopfloor_log.py index 110f04cf08..9e94415f01 100644 --- a/shopfloor/models/shopfloor_log.py +++ b/shopfloor/models/shopfloor_log.py @@ -33,7 +33,8 @@ class ShopfloorLog(models.Model): exception_name = fields.Char(readonly=True, string="Exception") exception_message = fields.Text(readonly=True) state = fields.Selection( - selection=[("success", "Success"), ("failed", "Failed")], readonly=True, + selection=[("success", "Success"), ("failed", "Failed")], + readonly=True, ) severity = fields.Selection( selection=[ diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py index b66146f7ae..6b8c3cd921 100644 --- a/shopfloor/models/stock_location.py +++ b/shopfloor/models/stock_location.py @@ -19,7 +19,8 @@ class StockLocation(models.Model): comodel_name="stock.move.line", inverse_name="location_id", readonly=True ) reserved_move_line_ids = fields.One2many( - comodel_name="stock.move.line", compute="_compute_reserved_move_lines", + comodel_name="stock.move.line", + compute="_compute_reserved_move_lines", ) def is_sublocation_of(self, others, func=any): diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py index 35cb047cc9..e37e3ff67d 100644 --- a/shopfloor/models/stock_picking.py +++ b/shopfloor/models/stock_picking.py @@ -18,7 +18,8 @@ class StockPicking(models.Model): related="picking_type_id.shopfloor_display_packing_info", ) shopfloor_packing_info = fields.Text( - string="Packing information", related="partner_id.shopfloor_packing_info", + string="Packing information", + related="partner_id.shopfloor_packing_info", ) @api.depends( diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py index 2525db4aaa..984a69cb8c 100644 --- a/shopfloor/models/stock_picking_type.py +++ b/shopfloor/models/stock_picking_type.py @@ -7,7 +7,9 @@ class StockPickingType(models.Model): _inherit = "stock.picking.type" shopfloor_menu_ids = fields.Many2many( - comodel_name="shopfloor.menu", string="Shopfloor Menus", readonly=True, + comodel_name="shopfloor.menu", + string="Shopfloor Menus", + readonly=True, ) shopfloor_zero_check = fields.Boolean( string="Activate Zero Check", diff --git a/shopfloor/models/stock_quant.py b/shopfloor/models/stock_quant.py index d20af1278e..bc17dcd29b 100644 --- a/shopfloor/models/stock_quant.py +++ b/shopfloor/models/stock_quant.py @@ -7,7 +7,7 @@ class StockQuant(models.Model): _inherit = "stock.quant" def _is_inventory_mode(self): - """ Used to control whether a quant was written on or created during an + """Used to control whether a quant was written on or created during an "inventory session", meaning a mode where we need to create the stock.move record necessary to be consistent with the `inventory_quantity` field. """ diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py index 8f7ee23462..c155115bca 100644 --- a/shopfloor/models/stock_quant_package.py +++ b/shopfloor/models/stock_quant_package.py @@ -20,7 +20,8 @@ class StockQuantPackage(models.Model): ) # TODO: review other fields reserved_move_line_ids = fields.One2many( - comodel_name="stock.move.line", compute="_compute_reserved_move_lines", + comodel_name="stock.move.line", + compute="_compute_reserved_move_lines", ) def _get_reserved_move_lines(self): diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index fe45bd74a1..7a156ec2d7 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -513,7 +513,9 @@ def _change_line_qty( new_line = self.env["stock.move.line"] if qty_done > 0: new_line, qty_check = move_line._split_qty_to_be_done( - qty_done, split_partial=True, result_package_id=False, + qty_done, + split_partial=True, + result_package_id=False, ) if qty_check == "greater": qty_done = move_line.product_uom_qty diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 620751c6d5..e5db7fd007 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -885,7 +885,8 @@ def change_pack_lot(self, picking_batch_id, move_line_id, barcode): ) return self._response_for_change_pack_lot( - move_line, message=self.msg_store.no_package_or_lot_for_barcode(barcode), + move_line, + message=self.msg_store.no_package_or_lot_for_barcode(barcode), ) def set_destination_all(self, picking_batch_id, barcode, confirmation=False): diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 476d97073a..f8aea169ea 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -415,7 +415,8 @@ def set_qty_done_line(self, picking_id, move_line_id): ) return self._response_for_deliver(picking) return self._response_for_deliver( - picking=picking, message=self.msg_store.record_not_found(), + picking=picking, + message=self.msg_store.record_not_found(), ) def reset_qty_done_pack(self, picking_id, package_id): @@ -474,7 +475,8 @@ def reset_qty_done_line(self, picking_id, move_line_id): self._reset_lines(line) return self._response_for_deliver(picking) return self._response_for_deliver( - picking=picking, message=self.msg_store.record_not_found(), + picking=picking, + message=self.msg_store.record_not_found(), ) def done(self, picking_id, confirm=False): @@ -511,7 +513,8 @@ def done(self, picking_id, confirm=False): message=self.msg_store.transfer_complete(picking) ) return self._response_for_confirm_done( - picking, message=self.msg_store.transfer_confirm_done(), + picking, + message=self.msg_store.transfer_confirm_done(), ) diff --git a/shopfloor/services/forms/form_mixin.py b/shopfloor/services/forms/form_mixin.py index 1389082c80..f0d1d4aaa7 100644 --- a/shopfloor/services/forms/form_mixin.py +++ b/shopfloor/services/forms/form_mixin.py @@ -7,8 +7,7 @@ class ShopfloorFormMixin(AbstractComponent): - """Allow to edit records. - """ + """Allow to edit records.""" _inherit = "base.shopfloor.service" _name = "shopfloor.form.mixin" diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index cb82e53517..02a6a90ede 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -65,8 +65,7 @@ def _convert_one_record(self, record): return values def _get_move_line_counters(self, record): - """Lookup for all lines per menu item and compute counters. - """ + """Lookup for all lines per menu item and compute counters.""" # TODO: maybe to be improved w/ raw SQL as this run for each menu item # and it's called every time the menu is opened/gets refreshed move_line_search = self.actions_for( diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 4bddbd3bcc..b0c7a6faa2 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -34,14 +34,22 @@ def _response_for_start(self, message=None, popup=None): def _response_for_confirm_start(self, package_level, message=None): data = self._data_after_package_scanned(package_level) data["confirmation_required"] = True - return self._response(next_state="start", data=data, message=message,) + return self._response( + next_state="start", + data=data, + message=message, + ) def _response_for_scan_location( self, package_level, message=None, confirmation_required=False ): data = self._data_after_package_scanned(package_level) data["confirmation_required"] = confirmation_required - return self._response(next_state="scan_location", data=data, message=message,) + return self._response( + next_state="scan_location", + data=data, + message=message, + ) def _response_for_show_completion_info(self, message=None): return self._response(next_state="show_completion_info", message=message) diff --git a/shopfloor/tests/test_checkout_cancel_line.py b/shopfloor/tests/test_checkout_cancel_line.py index a18adba265..6a9d4e74a7 100644 --- a/shopfloor/tests/test_checkout_cancel_line.py +++ b/shopfloor/tests/test_checkout_cancel_line.py @@ -103,11 +103,13 @@ def test_cancel_line_ok(self): # and now, we want to drop the new_package response = self.service.dispatch( - "cancel_line", params={"picking_id": picking.id, "line_id": raw_line.id}, + "cancel_line", + params={"picking_id": picking.id, "line_id": raw_line.id}, ) self.assertRecordValues( - raw_line, [{"qty_done": 0, "shopfloor_checkout_done": False}], + raw_line, + [{"qty_done": 0, "shopfloor_checkout_done": False}], ) self.assert_response( diff --git a/shopfloor/tests/test_checkout_no_package.py b/shopfloor/tests/test_checkout_no_package.py index e60a67ecfc..07cb846c34 100644 --- a/shopfloor/tests/test_checkout_no_package.py +++ b/shopfloor/tests/test_checkout_no_package.py @@ -42,10 +42,12 @@ def test_no_package_ok(self): ) self.assertRecordValues( - move_line1, [{"result_package_id": False, "shopfloor_checkout_done": True}], + move_line1, + [{"result_package_id": False, "shopfloor_checkout_done": True}], ) self.assertRecordValues( - move_line2, [{"result_package_id": False, "shopfloor_checkout_done": True}], + move_line2, + [{"result_package_id": False, "shopfloor_checkout_done": True}], ) self.assertRecordValues( move_line3, diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py index e1d7dc983d..02f35414af 100644 --- a/shopfloor/tests/test_cluster_picking_select.py +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -58,7 +58,9 @@ def test_find_batch_in_progress_current_user(self): # user and in progress (first priority) data = self.data.picking_batch(self.batch3, with_pickings=True) self.assert_response( - response, next_state="confirm_start", data=data, + response, + next_state="confirm_start", + data=data, ) def test_find_batch_assigned(self): @@ -78,7 +80,9 @@ def test_find_batch_assigned(self): # we expect to find batch 2 as it's assigned to the current user data = self.data.picking_batch(self.batch2, with_pickings=True) self.assert_response( - response, next_state="confirm_start", data=data, + response, + next_state="confirm_start", + data=data, ) def test_find_batch_unassigned_draft(self): @@ -97,7 +101,9 @@ def test_find_batch_unassigned_draft(self): # available data = self.data.picking_batch(self.batch2, with_pickings=True) self.assert_response( - response, next_state="confirm_start", data=data, + response, + next_state="confirm_start", + data=data, ) def test_find_batch_not_found(self): @@ -153,7 +159,9 @@ def test_select_in_progress_assigned(self): # we don't care in these tests, 'find_batch' tests them already data["pickings"] = self.ANY self.assert_response( - response, next_state="confirm_start", data=data, + response, + next_state="confirm_start", + data=data, ) def test_select_draft_assigned(self): @@ -171,7 +179,9 @@ def test_select_draft_assigned(self): # we don't care in these tests, 'find_batch' tests them already data["pickings"] = self.ANY self.assert_response( - response, next_state="confirm_start", data=data, + response, + next_state="confirm_start", + data=data, ) def test_select_draft_unassigned(self): @@ -188,7 +198,9 @@ def test_select_draft_unassigned(self): # we don't care in these tests, 'find_batch' tests them already data["pickings"] = self.ANY self.assert_response( - response, next_state="confirm_start", data=data, + response, + next_state="confirm_start", + data=data, ) def test_select_not_exists(self): @@ -323,7 +335,9 @@ def test_confirm_start_ok(self): data["picking"] = self.data.picking(picking) data["batch"] = self.data.picking_batch(batch) self.assert_response( - response, data=data, next_state="start_line", + response, + data=data, + next_state="start_line", ) def test_confirm_start_not_exists(self): diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py index bb28881c70..03feec18fd 100644 --- a/shopfloor/tests/test_cluster_picking_skip.py +++ b/shopfloor/tests/test_cluster_picking_skip.py @@ -5,8 +5,7 @@ class ClusterPickingSkipLineCase(ClusterPickingCommonCase): - """Tests covering the /skip_line endpoint - """ + """Tests covering the /skip_line endpoint""" @classmethod def setUpClassBaseData(cls, *args, **kwargs): diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py index eb878075f5..ce486d3ee6 100644 --- a/shopfloor/tests/test_cluster_picking_stock_issue.py +++ b/shopfloor/tests/test_cluster_picking_stock_issue.py @@ -5,8 +5,7 @@ class ClusterPickingStockIssue(ClusterPickingCommonCase): - """Tests covering the /stock_issue endpoint - """ + """Tests covering the /stock_issue endpoint""" @classmethod def setUpClassBaseData(cls, *args, **kwargs): diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index 5d9ed6f0bb..b32dcf1938 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -93,7 +93,9 @@ def test_prepare_unload_all_same_dest(self): location = self.packing_location data = self._data_for_batch(self.batch, location) self.assert_response( - response, next_state="unload_all", data=data, + response, + next_state="unload_all", + data=data, ) def test_prepare_unload_different_dest(self): @@ -110,7 +112,9 @@ def test_prepare_unload_different_dest(self): location = first_line.location_dest_id data = self._data_for_batch(self.batch, location, pack=self.bin1) self.assert_response( - response, next_state="unload_single", data=data, + response, + next_state="unload_single", + data=data, ) @@ -252,7 +256,9 @@ def test_set_destination_all_but_different_dest(self): location = move_lines[0].location_dest_id data = self._data_for_batch(self.batch, location, pack=self.bin1) self.assert_response( - response, next_state="unload_single", data=data, + response, + next_state="unload_single", + data=data, ) def test_set_destination_all_error_location_not_found(self): @@ -346,7 +352,9 @@ def test_set_destination_all_need_confirmation(self): location = move_lines[0].location_dest_id data = self._data_for_batch(self.batch, location) self.assert_response( - response, next_state="confirm_unload_all", data=data, + response, + next_state="confirm_unload_all", + data=data, ) def test_set_destination_all_with_confirmation(self): @@ -439,7 +447,9 @@ def test_unload_scan_pack_ok(self): location = self.move_lines[0].location_dest_id data = self._data_for_batch(self.batch, location, pack=self.bin1) self.assert_response( - response, next_state="unload_set_destination", data=data, + response, + next_state="unload_set_destination", + data=data, ) def test_unload_scan_pack_wrong_barcode(self): @@ -543,7 +553,9 @@ def test_unload_scan_destination_ok(self): location = self.bin2_lines[0].location_dest_id data = self._data_for_batch(self.batch, location, pack=self.bin2) self.assert_response( - response, next_state="unload_single", data=data, + response, + next_state="unload_single", + data=data, ) def test_unload_scan_destination_one_line_of_picking_only(self): @@ -603,7 +615,9 @@ def test_unload_scan_destination_one_line_of_picking_only(self): location = bin3_line.location_dest_id data = self._data_for_batch(self.batch, location, pack=bin3) self.assert_response( - response, next_state="unload_single", data=data, + response, + next_state="unload_single", + data=data, ) def test_unload_scan_destination_last_line(self): @@ -735,7 +749,9 @@ def test_unload_scan_destination_need_confirmation(self): location = self.bin1_lines[0].location_dest_id data = self._data_for_batch(self.batch, location, pack=self.bin1) self.assert_response( - response, next_state="confirm_unload_set_destination", data=data, + response, + next_state="confirm_unload_set_destination", + data=data, ) def test_unload_scan_destination_with_confirmation(self): diff --git a/shopfloor/tests/test_delivery_done.py b/shopfloor/tests/test_delivery_done.py index 8c49038db3..1162aa1eb8 100644 --- a/shopfloor/tests/test_delivery_done.py +++ b/shopfloor/tests/test_delivery_done.py @@ -46,7 +46,8 @@ def test_done_all_qty_done(self): move_line.qty_done = move_line.product_uom_qty response = self.service.dispatch("done", params={"picking_id": self.picking.id}) self.assert_response_deliver( - response, message=self.service.msg_store.transfer_complete(self.picking), + response, + message=self.service.msg_store.transfer_complete(self.picking), ) self.assertEqual(self.picking.state, "done") @@ -79,7 +80,8 @@ def test_done_no_qty_done_confirm(self): "done", params={"picking_id": self.picking.id, "confirm": True} ) self.assert_response_deliver( - response, message=self.service.msg_store.transfer_no_qty_done(), + response, + message=self.service.msg_store.transfer_no_qty_done(), ) self.assertEqual(self.picking.state, "assigned") @@ -94,7 +96,8 @@ def test_done_some_qty_done_confirm(self): "done", params={"picking_id": self.picking.id, "confirm": True} ) self.assert_response_deliver( - response, message=self.service.msg_store.transfer_complete(self.picking), + response, + message=self.service.msg_store.transfer_complete(self.picking), ) self.assertEqual(self.picking.state, "done") self.assertEqual(self.picking.move_lines, self.raw_move) diff --git a/shopfloor/tests/test_delivery_list_stock_picking.py b/shopfloor/tests/test_delivery_list_stock_picking.py index 056557d407..ec82a350a5 100644 --- a/shopfloor/tests/test_delivery_list_stock_picking.py +++ b/shopfloor/tests/test_delivery_list_stock_picking.py @@ -21,7 +21,8 @@ def test_list_stock_picking_ko(self): """No picking is ready, no picking to list.""" response = self.service.dispatch("list_stock_picking", params={}) self.assert_response_manual_selection( - response, pickings=[], + response, + pickings=[], ) def test_list_stock_picking_ok(self): @@ -32,7 +33,8 @@ def test_list_stock_picking_ok(self): response = self.service.dispatch("list_stock_picking", params={}) # picking1 only available self.assert_response_manual_selection( - response, pickings=self.picking1, + response, + pickings=self.picking1, ) # prepare 2nd picking self._fill_stock_for_moves(self.picking2.move_lines) @@ -40,5 +42,6 @@ def test_list_stock_picking_ok(self): response = self.service.dispatch("list_stock_picking", params={}) # all pickings available self.assert_response_manual_selection( - response, pickings=self.picking1 + self.picking2, + response, + pickings=self.picking1 + self.picking2, ) diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py index c1aa38a00a..ae2cd598f6 100644 --- a/shopfloor/tests/test_delivery_scan_deliver.py +++ b/shopfloor/tests/test_delivery_scan_deliver.py @@ -81,7 +81,8 @@ def test_scan_deliver_error_barcode_not_found(self): "scan_deliver", params={"barcode": "NO VALID BARCODE", "picking_id": None} ) self.assert_response_deliver( - response, message=self.service.msg_store.barcode_not_found(), + response, + message=self.service.msg_store.barcode_not_found(), ) def test_scan_deliver_error_barcode_not_found_keep_picking(self): @@ -168,7 +169,8 @@ def test_scan_deliver_scan_product_not_found(self): "scan_deliver", params={"barcode": self.free_product.barcode} ) self.assert_response_deliver( - response, message=self.service.msg_store.product_not_found_in_pickings(), + response, + message=self.service.msg_store.product_not_found_in_pickings(), ) def test_scan_deliver_scan_lot_ok(self): @@ -179,7 +181,8 @@ def test_scan_deliver_scan_lot_ok(self): def test_scan_deliver_scan_lot_not_found(self): response = self.service.dispatch("scan_deliver", params={"barcode": "FREE_LOT"}) self.assert_response_deliver( - response, message=self.service.msg_store.lot_not_found_in_pickings(), + response, + message=self.service.msg_store.lot_not_found_in_pickings(), ) def test_scan_deliver_scan_lot_in_mixed_package(self): @@ -217,13 +220,15 @@ def test_scan_deliver_picking_done(self): self.assertEqual(self.picking.state, "assigned") lot = self.raw_lot_move.move_line_ids.lot_id response = self.service.dispatch( - "scan_deliver", params={"barcode": lot.name, "picking_id": self.picking.id}, + "scan_deliver", + params={"barcode": lot.name, "picking_id": self.picking.id}, ) self.assertEqual(self.picking.state, "assigned") packages_f = self.pack3_move.move_line_ids.mapped("package_id") # While all lines are not processed, response still returns the picking self.assert_response_deliver( - response, picking=self.picking, + response, + picking=self.picking, ) response = None # Once all lines are processed, the last response has no picking returned @@ -234,7 +239,8 @@ def test_scan_deliver_picking_done(self): ) self.assertEqual(self.picking.state, "done") self.assert_response_deliver( - response, message=self.service.msg_store.transfer_complete(self.picking), + response, + message=self.service.msg_store.transfer_complete(self.picking), ) diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py index f4076b1bfe..cbc1c2c852 100644 --- a/shopfloor/tests/test_location_content_transfer_base.py +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -105,7 +105,10 @@ def _assert_response_scan_destination( data = self.service._data_content_line_for_location(location, next_content) data["confirmation_required"] = confirmation_required self.assert_response( - response, next_state=state, data=data, message=message, + response, + next_state=state, + data=data, + message=message, ) def assert_response_scan_destination( diff --git a/shopfloor/tests/test_location_content_transfer_mix.py b/shopfloor/tests/test_location_content_transfer_mix.py index 2b440f5202..b7f6753cb5 100644 --- a/shopfloor/tests/test_location_content_transfer_mix.py +++ b/shopfloor/tests/test_location_content_transfer_mix.py @@ -158,7 +158,8 @@ def _location_content_transfer_process_line( self.assert_response_start( response, message=service.msg_store.location_content_transfer_complete( - pack_location, out_location, + pack_location, + out_location, ), ) self.assertEqual(move_line.state, "done") @@ -339,7 +340,8 @@ def test_with_zone_picking2(self): self.assert_response_start( response, message=self.service.msg_store.location_content_transfer_complete( - pack_first_pallet.location_id, pack_first_pallet.location_dest_id, + pack_first_pallet.location_id, + pack_first_pallet.location_dest_id, ), ) self.assertEqual(pack_first_pallet.qty_done, 4) diff --git a/shopfloor/tests/test_location_content_transfer_putaway.py b/shopfloor/tests/test_location_content_transfer_putaway.py index 5b82a61f41..6421f71bab 100644 --- a/shopfloor/tests/test_location_content_transfer_putaway.py +++ b/shopfloor/tests/test_location_content_transfer_putaway.py @@ -5,8 +5,7 @@ class TestLocationContentTransferPutaway(LocationContentTransferCommonCase): - """Tests with putaway when using option to ignore unavailable putaway locations - """ + """Tests with putaway when using option to ignore unavailable putaway locations""" @classmethod def setUpClassVars(cls, *args, **kwargs): @@ -70,7 +69,9 @@ def test_normal_putaway(self): "scan_location", params={"barcode": self.test_loc.barcode} ) self.assert_response( - response, next_state="start_single", data=self.ANY, + response, + next_state="start_single", + data=self.ANY, ) package_level_id = response["data"]["start_single"]["package_level"]["id"] package_level = self.env["stock.package_level"].browse(package_level_id) diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py index 6212307b49..1853edba35 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py @@ -77,7 +77,8 @@ def test_set_destination_package_wrong_parameters(self): ) move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( - response, move_lines.mapped("picking_id"), + response, + move_lines.mapped("picking_id"), ) def test_set_destination_package_dest_location_nok(self): @@ -93,7 +94,9 @@ def test_set_destination_package_dest_location_nok(self): }, ) self.assert_response_scan_destination( - response, package_level, message=self.service.msg_store.no_location_found(), + response, + package_level, + message=self.service.msg_store.no_location_found(), ) # Destination location not allowed customer_location = self.env.ref("stock.stock_location_customers") @@ -255,7 +258,8 @@ def test_set_destination_line_wrong_parameters(self): ) move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( - response, move_lines.mapped("picking_id"), + response, + move_lines.mapped("picking_id"), ) def test_set_destination_line_dest_location_nok(self): @@ -272,7 +276,9 @@ def test_set_destination_line_dest_location_nok(self): }, ) self.assert_response_scan_destination( - response, move_line, message=self.service.msg_store.no_location_found(), + response, + move_line, + message=self.service.msg_store.no_location_found(), ) # Destination location not allowed customer_location = self.env.ref("stock.stock_location_customers") @@ -552,7 +558,9 @@ def setUpClassBaseData(cls): ) cls.move_product_a.product_uom_qty = 15 cls._update_qty_in_location( - cls.picking.location_id, cls.product_a, 5, + cls.picking.location_id, + cls.product_a, + 5, ) # Put product_b quantities in two different source locations to get # two stock move lines (6 and 4 to satisfy 10 qties) diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py index 1a4c2daeca..ae416df886 100644 --- a/shopfloor/tests/test_location_content_transfer_single.py +++ b/shopfloor/tests/test_location_content_transfer_single.py @@ -321,7 +321,8 @@ def test_postpone_package_wrong_parameters(self): ) move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( - response, move_lines.mapped("picking_id"), + response, + move_lines.mapped("picking_id"), ) def test_postpone_package_ok(self): @@ -337,7 +338,8 @@ def test_postpone_package_ok(self): self.assertTrue(package_level.shopfloor_postponed) move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( - response, move_lines.mapped("picking_id"), + response, + move_lines.mapped("picking_id"), ) def test_postpone_sorter(self): @@ -379,7 +381,8 @@ def test_postpone_line_wrong_parameters(self): ) move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( - response, move_lines.mapped("picking_id"), + response, + move_lines.mapped("picking_id"), ) def test_postpone_line_ok(self): @@ -392,7 +395,8 @@ def test_postpone_line_ok(self): self.assertTrue(move_line.shopfloor_postponed) move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( - response, move_lines.mapped("picking_id"), + response, + move_lines.mapped("picking_id"), ) def test_stock_out_package_wrong_parameters(self): @@ -419,7 +423,8 @@ def test_stock_out_package_wrong_parameters(self): ) move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( - response, move_lines.mapped("picking_id"), + response, + move_lines.mapped("picking_id"), ) def test_stock_out_package_ok(self): @@ -434,7 +439,8 @@ def test_stock_out_package_ok(self): ) move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( - response, move_lines.mapped("picking_id"), + response, + move_lines.mapped("picking_id"), ) def test_stock_out_line_wrong_parameters(self): @@ -461,7 +467,8 @@ def test_stock_out_line_wrong_parameters(self): ) move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( - response, move_lines.mapped("picking_id"), + response, + move_lines.mapped("picking_id"), ) def test_dismiss_package_level_ok(self): @@ -507,7 +514,8 @@ def test_dismiss_package_level_error_no_location(self): params={"location_id": 0, "package_level_id": package_level.id}, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) @@ -550,7 +558,9 @@ def setUpClassBaseData(cls): ) cls.move_product_a.product_uom_qty = 15 cls._update_qty_in_location( - cls.content_loc, cls.product_a, 5, + cls.content_loc, + cls.product_a, + 5, ) # Put product_b quantities in two different source locations to get # two stock move lines (6 and 4 to satisfy 10 qties) @@ -611,7 +621,8 @@ def test_stock_out_package_split_move(self): # Check the response move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( - response, move_lines.mapped("picking_id"), + response, + move_lines.mapped("picking_id"), ) def test_stock_out_line_split_move(self): @@ -667,5 +678,6 @@ def test_stock_out_line_split_move(self): # Check the response move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( - response, move_lines.mapped("picking_id"), + response, + move_lines.mapped("picking_id"), ) diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py index c193fd0316..ebdfb1c7bf 100644 --- a/shopfloor/tests/test_location_content_transfer_start.py +++ b/shopfloor/tests/test_location_content_transfer_start.py @@ -211,7 +211,8 @@ def test_scan_location_wrong_picking_type_allow_unreserve_ok(self): self.assert_response_scan_destination_all(response, new_picking) self.assertRecordValues(new_picking, [{"user_id": self.env.uid}]) self.assertRecordValues( - new_picking.move_line_ids, [{"qty_done": 10.0}, {"qty_done": 10.0}], + new_picking.move_line_ids, + [{"qty_done": 10.0}, {"qty_done": 10.0}], ) self.assertRecordValues(new_picking.package_level_ids, [{"is_done": True}]) @@ -282,7 +283,9 @@ def test_scan_location_create_moves(self): picking_type = self.menu.picking_type_ids # product_a alone self.env["stock.quant"]._update_available_quantity( - self.product_a, self.content_loc, 10, + self.product_a, + self.content_loc, + 10, ) # product_b in a package package = self.env["stock.quant.package"].create({}) diff --git a/shopfloor/tests/test_scan_anything.py b/shopfloor/tests/test_scan_anything.py index e1d20ac132..3e49d472ed 100644 --- a/shopfloor/tests/test_scan_anything.py +++ b/shopfloor/tests/test_scan_anything.py @@ -14,7 +14,8 @@ def _test_response_ok(self, rec_type, data, identifier): params = {"identifier": identifier} response = self.service.dispatch("scan", params=params) self.assert_response( - response, data={"type": rec_type, "identifier": identifier, "record": data}, + response, + data={"type": rec_type, "identifier": identifier, "record": data}, ) def _test_response_ko(self, identifier, tried=None): diff --git a/shopfloor/tests/test_single_pack_transfer_putaway.py b/shopfloor/tests/test_single_pack_transfer_putaway.py index aeb953a498..586fb145a9 100644 --- a/shopfloor/tests/test_single_pack_transfer_putaway.py +++ b/shopfloor/tests/test_single_pack_transfer_putaway.py @@ -42,7 +42,9 @@ def test_normal_putaway(self): "start", params={"barcode": self.shelf1.barcode} ) self.assert_response( - response, next_state="scan_location", data=self.ANY, + response, + next_state="scan_location", + data=self.ANY, ) package_level_id = response["data"]["scan_location"]["id"] package_level = self.env["stock.package_level"].browse(package_level_id) diff --git a/shopfloor/tests/test_user.py b/shopfloor/tests/test_user.py index e1175ae8bb..fa873cb42c 100644 --- a/shopfloor/tests/test_user.py +++ b/shopfloor/tests/test_user.py @@ -35,5 +35,6 @@ def test_menu_by_profile(self): response = self.service.dispatch("menu") self.assert_response( - response, data={"menus": [self._data_for_menu_item(menu)]}, + response, + data={"menus": [self._data_for_menu_item(menu)]}, ) diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 2f7661ba24..69dbc125ba 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -255,7 +255,10 @@ def setUp(self): def _assert_response_select_zone(self, response, zone_locations, message=None): data = {"zones": self.service._data_for_select_zone(zone_locations)} self.assert_response( - response, next_state="start", data=data, message=message, + response, + next_state="start", + data=data, + message=message, ) def assert_response_start(self, response, zone_locations=None, message=None): @@ -268,7 +271,10 @@ def _assert_response_select_picking_type( ): data = self.service._data_for_select_picking_type(zone_location, picking_types) self.assert_response( - response, next_state=state, data=data, message=message, + response, + next_state=state, + data=data, + message=message, ) def assert_response_select_picking_type( @@ -303,7 +309,11 @@ def _assert_response_select_line( "location_will_be_empty" ] = move_line.location_id.planned_qty_in_location_is_empty(move_line) self.assert_response( - response, next_state=state, data=data, message=message, popup=popup, + response, + next_state=state, + data=data, + message=message, + popup=popup, ) def assert_response_select_line( @@ -367,7 +377,13 @@ def assert_response_set_line_destination( ) def _assert_response_zero_check( - self, state, response, zone_location, picking_type, location, message=None, + self, + state, + response, + zone_location, + picking_type, + location, + message=None, ): self.assert_response( response, @@ -381,7 +397,12 @@ def _assert_response_zero_check( ) def assert_response_zero_check( - self, response, zone_location, picking_type, location, message=None, + self, + response, + zone_location, + picking_type, + location, + message=None, ): self._assert_response_zero_check( "zero_check", @@ -393,7 +414,13 @@ def assert_response_zero_check( ) def _assert_response_change_pack_lot( - self, state, response, zone_location, picking_type, move_line, message=None, + self, + state, + response, + zone_location, + picking_type, + move_line, + message=None, ): self.assert_response( response, @@ -407,7 +434,12 @@ def _assert_response_change_pack_lot( ) def assert_response_change_pack_lot( - self, response, zone_location, picking_type, move_line, message=None, + self, + response, + zone_location, + picking_type, + move_line, + message=None, ): self._assert_response_change_pack_lot( "change_pack_lot", diff --git a/shopfloor/tests/test_zone_picking_change_pack_lot.py b/shopfloor/tests/test_zone_picking_change_pack_lot.py index bfcca57407..51985e041a 100644 --- a/shopfloor/tests/test_zone_picking_change_pack_lot.py +++ b/shopfloor/tests/test_zone_picking_change_pack_lot.py @@ -26,7 +26,8 @@ def test_change_pack_lot_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) response = self.service.dispatch( "change_pack_lot", @@ -38,7 +39,8 @@ def test_change_pack_lot_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) response = self.service.dispatch( "change_pack_lot", @@ -50,7 +52,8 @@ def test_change_pack_lot_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) def test_change_pack_lot_no_package_or_lot_for_barcode(self): diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py index 7c5eadeddd..35a0ab8e73 100644 --- a/shopfloor/tests/test_zone_picking_select_line.py +++ b/shopfloor/tests/test_zone_picking_select_line.py @@ -85,7 +85,10 @@ def test_list_move_lines_order_by_location(self): zone_location, picking_type, order="location" ) self.assert_response_select_line( - response, zone_location, picking_type, move_lines, + response, + zone_location, + picking_type, + move_lines, ) def test_list_move_lines_order_by_priority(self): @@ -103,7 +106,10 @@ def test_list_move_lines_order_by_priority(self): zone_location, picking_type, order="priority" ) self.assert_response_select_line( - response, zone_location, picking_type, move_lines, + response, + zone_location, + picking_type, + move_lines, ) def test_scan_source_wrong_parameters(self): @@ -118,7 +124,8 @@ def test_scan_source_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) response = self.service.dispatch( "scan_source", @@ -129,7 +136,8 @@ def test_scan_source_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) def test_scan_source_barcode_location_not_allowed(self): @@ -145,7 +153,8 @@ def test_scan_source_barcode_location_not_allowed(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.location_not_allowed(), + response, + message=self.service.msg_store.location_not_allowed(), ) def test_scan_source_barcode_location_one_move_line(self): @@ -259,7 +268,9 @@ def test_scan_source_barcode_package(self): }, ) move_lines = self.service._find_location_move_lines( - zone_location, picking_type, package=package, + zone_location, + picking_type, + package=package, ) move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) move_line = move_lines[0] @@ -309,7 +320,9 @@ def test_scan_source_barcode_product(self): }, ) move_line = self.service._find_location_move_lines( - zone_location, picking_type, product=self.product_a, + zone_location, + picking_type, + product=self.product_a, ) self.assert_response_set_line_destination( response, @@ -358,7 +371,9 @@ def test_scan_source_barcode_lot(self): }, ) move_lines = self.service._find_location_move_lines( - zone_location, picking_type, lot=lot, + zone_location, + picking_type, + lot=lot, ) move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) move_line = move_lines[0] @@ -424,7 +439,9 @@ def test_scan_source_multi_users(self): picking_type = self.picking1.picking_type_id # - scan source response = self.service.scan_source( - zone_location.id, picking_type.id, self.zone_sublocation1.barcode, + zone_location.id, + picking_type.id, + self.zone_sublocation1.barcode, ) move_line = self.picking1.move_line_ids self.assertEqual(response["next_state"], "set_line_destination") @@ -444,7 +461,9 @@ def test_scan_source_multi_users(self): ) as work: service = work.component(usage="zone_picking") response = service.scan_source( - zone_location.id, picking_type.id, self.zone_sublocation1.barcode, + zone_location.id, + picking_type.id, + self.zone_sublocation1.barcode, ) self.assertEqual(response["next_state"], "select_line") self.assertEqual( @@ -463,7 +482,8 @@ def test_prepare_unload_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) response = self.service.dispatch( "prepare_unload", @@ -473,7 +493,8 @@ def test_prepare_unload_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) def test_prepare_unload_buffer_empty(self): @@ -490,7 +511,10 @@ def test_prepare_unload_buffer_empty(self): # check response move_lines = self.service._find_location_move_lines(zone_location, picking_type) self.assert_response_select_line( - response, zone_location, picking_type, move_lines, + response, + zone_location, + picking_type, + move_lines, ) def test_prepare_unload_buffer_one_line(self): @@ -518,7 +542,10 @@ def test_prepare_unload_buffer_one_line(self): ) # check response self.assert_response_unload_set_destination( - response, zone_location, picking_type, move_line, + response, + zone_location, + picking_type, + move_line, ) def test_prepare_unload_buffer_multi_line_same_destination(self): @@ -555,7 +582,10 @@ def test_prepare_unload_buffer_multi_line_same_destination(self): ) # check response self.assert_response_unload_all( - response, zone_location, picking_type, self.picking5.move_line_ids, + response, + zone_location, + picking_type, + self.picking5.move_line_ids, ) def test_list_move_lines_empty_location(self): @@ -573,7 +603,10 @@ def test_list_move_lines_empty_location(self): zone_location, picking_type, order="location" ) self.assert_response_select_line( - response, zone_location, picking_type, move_lines, + response, + zone_location, + picking_type, + move_lines, ) data_move_lines = response["data"]["select_line"]["move_lines"] # Check that the move line in "Zone sub-location 1" is about to empty diff --git a/shopfloor/tests/test_zone_picking_select_picking_type.py b/shopfloor/tests/test_zone_picking_select_picking_type.py index 76b999d929..7638292fa2 100644 --- a/shopfloor/tests/test_zone_picking_select_picking_type.py +++ b/shopfloor/tests/test_zone_picking_select_picking_type.py @@ -21,7 +21,8 @@ def test_list_move_lines_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) response = self.service.dispatch( "list_move_lines", @@ -31,7 +32,8 @@ def test_list_move_lines_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) def test_list_move_lines_ok(self): diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py index 3099b76eb8..a4c1594b92 100644 --- a/shopfloor/tests/test_zone_picking_set_line_destination.py +++ b/shopfloor/tests/test_zone_picking_set_line_destination.py @@ -26,7 +26,8 @@ def test_set_destination_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) response = self.service.dispatch( "set_destination", @@ -40,7 +41,8 @@ def test_set_destination_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) response = self.service.dispatch( "set_destination", @@ -54,7 +56,8 @@ def test_set_destination_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) def test_set_destination_location_confirm(self): @@ -388,7 +391,10 @@ def test_set_destination_location_zero_check(self): self.assertTrue(location_is_empty()) # Check response self.assert_response_zero_check( - response, zone_location, picking_type, move_line.location_id, + response, + zone_location, + picking_type, + move_line.location_id, ) def test_set_destination_package_full_qty(self): @@ -544,5 +550,8 @@ def test_set_destination_package_zero_check(self): self.assertTrue(location_is_empty()) # Check response self.assert_response_zero_check( - response, zone_location, picking_type, move_line.location_id, + response, + zone_location, + picking_type, + move_line.location_id, ) diff --git a/shopfloor/tests/test_zone_picking_start.py b/shopfloor/tests/test_zone_picking_start.py index 6f20c4eb95..6bda88beaa 100644 --- a/shopfloor/tests/test_zone_picking_start.py +++ b/shopfloor/tests/test_zone_picking_start.py @@ -24,7 +24,8 @@ def setUpClassBaseData(cls, *args, **kwargs): } ) cls.extra_picking = extra_picking = cls._create_picking( - lines=[(cls.product_b, 10)], picking_type=bad_picking_type, + lines=[(cls.product_b, 10)], + picking_type=bad_picking_type, ) cls._fill_stock_for_moves( extra_picking.move_lines, in_package=True, location=cls.zone_sublocation1 @@ -128,10 +129,12 @@ def test_select_zone(self): def test_scan_location_wrong_barcode(self): """Scanned location invalid, no location found.""" response = self.service.dispatch( - "scan_location", params={"barcode": "UNKNOWN LOCATION"}, + "scan_location", + params={"barcode": "UNKNOWN LOCATION"}, ) self.assert_response_start( - response, message=self.service.msg_store.no_location_found(), + response, + message=self.service.msg_store.no_location_found(), ) def test_scan_location_not_allowed(self): @@ -139,10 +142,12 @@ def test_scan_location_not_allowed(self): types' source location. """ response = self.service.dispatch( - "scan_location", params={"barcode": self.customer_location.barcode}, + "scan_location", + params={"barcode": self.customer_location.barcode}, ) self.assert_response_start( - response, message=self.service.msg_store.location_not_allowed(), + response, + message=self.service.msg_store.location_not_allowed(), ) def test_scan_location_no_move_lines(self): @@ -151,17 +156,22 @@ def test_scan_location_no_move_lines(self): # no more lines available sub1_lines.picking_id.action_cancel() response = self.service.dispatch( - "scan_location", params={"barcode": self.zone_sublocation1.barcode}, + "scan_location", + params={"barcode": self.zone_sublocation1.barcode}, ) self.assert_response_start( - response, message=self.service.msg_store.no_lines_to_process(), + response, + message=self.service.msg_store.no_lines_to_process(), ) def test_scan_location_ok(self): """Scanned location valid, list of picking types of related move lines.""" response = self.service.dispatch( - "scan_location", params={"barcode": self.zone_location.barcode}, + "scan_location", + params={"barcode": self.zone_location.barcode}, ) self.assert_response_select_picking_type( - response, zone_location=self.zone_location, picking_types=self.picking_type, + response, + zone_location=self.zone_location, + picking_types=self.picking_type, ) diff --git a/shopfloor/tests/test_zone_picking_stock_issue.py b/shopfloor/tests/test_zone_picking_stock_issue.py index 88e66e8f2d..d755113e70 100644 --- a/shopfloor/tests/test_zone_picking_stock_issue.py +++ b/shopfloor/tests/test_zone_picking_stock_issue.py @@ -23,7 +23,8 @@ def test_stock_issue_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) response = self.service.dispatch( "stock_issue", @@ -34,7 +35,8 @@ def test_stock_issue_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) response = self.service.dispatch( "stock_issue", @@ -45,7 +47,8 @@ def test_stock_issue_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) def test_stock_issue_no_more_reservation(self): @@ -65,7 +68,10 @@ def test_stock_issue_no_more_reservation(self): self.assertFalse(move.move_line_ids) move_lines = self.service._find_location_move_lines(zone_location, picking_type) self.assert_response_select_line( - response, zone_location, picking_type, move_lines, + response, + zone_location, + picking_type, + move_lines, ) def test_stock_issue1(self): @@ -87,7 +93,10 @@ def test_stock_issue1(self): self.assertFalse(move.move_line_ids) move_lines = self.service._find_location_move_lines(zone_location, picking_type) self.assert_response_select_line( - response, zone_location, picking_type, move_lines, + response, + zone_location, + picking_type, + move_lines, ) # Check that the inventory exists inventory = self.env["stock.inventory"].search( @@ -129,7 +138,10 @@ def test_stock_issue2(self): self.assertTrue(move.move_line_ids) self.assertEqual(move.move_line_ids.location_id, location) self.assert_response_set_line_destination( - response, zone_location, picking_type, move.move_line_ids, + response, + zone_location, + picking_type, + move.move_line_ids, ) # Check the inventory inventory = self.env["stock.inventory"].search( @@ -173,7 +185,10 @@ def test_stock_issue3(self): self.assertTrue(move.move_line_ids) self.assertEqual(move.move_line_ids.location_id, self.zone_sublocation2) self.assert_response_set_line_destination( - response, zone_location, picking_type, move.move_line_ids, + response, + zone_location, + picking_type, + move.move_line_ids, ) # Check the inventory inventory = self.env["stock.inventory"].search( diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py index 23ec7800e8..33997cf93d 100644 --- a/shopfloor/tests/test_zone_picking_unload_all.py +++ b/shopfloor/tests/test_zone_picking_unload_all.py @@ -23,7 +23,8 @@ def test_set_destination_all_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) response = self.service.dispatch( "set_destination_all", @@ -34,7 +35,8 @@ def test_set_destination_all_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) def test_set_destination_all_different_destination(self): @@ -147,7 +149,8 @@ def test_set_destination_all_confirm_destination(self): picking_type, buffer_lines, message=self.service.msg_store.confirm_location_changed( - packing_sublocation1, packing_sublocation2, + packing_sublocation1, + packing_sublocation2, ), confirmation_required=True, ) @@ -389,7 +392,10 @@ def test_unload_split_buffer_one_line(self): # check response buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) self.assert_response_unload_set_destination( - response, zone_location, picking_type, buffer_lines, + response, + zone_location, + picking_type, + buffer_lines, ) def test_unload_split_buffer_multi_lines(self): diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py index 6108454386..5e6b935032 100644 --- a/shopfloor/tests/test_zone_picking_unload_set_destination.py +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -44,7 +44,8 @@ def test_unload_set_destination_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) response = self.service.dispatch( "unload_set_destination", @@ -56,7 +57,8 @@ def test_unload_set_destination_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) response = self.service.dispatch( "unload_set_destination", diff --git a/shopfloor/tests/test_zone_picking_unload_single.py b/shopfloor/tests/test_zone_picking_unload_single.py index 03f72b41a4..1db582c00c 100644 --- a/shopfloor/tests/test_zone_picking_unload_single.py +++ b/shopfloor/tests/test_zone_picking_unload_single.py @@ -25,7 +25,8 @@ def test_unload_scan_pack_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) response = self.service.dispatch( "unload_scan_pack", @@ -37,7 +38,8 @@ def test_unload_scan_pack_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) # wrong package ID, and there is still a move line to unload # => get back on 'unload_single' screen @@ -128,7 +130,10 @@ def test_unload_scan_pack_barcode_match(self): }, ) self.assert_response_unload_set_destination( - response, zone_location, picking_type, move_line, + response, + zone_location, + picking_type, + move_line, ) def test_unload_scan_pack_barcode_not_match(self): diff --git a/shopfloor/tests/test_zone_picking_zero_check.py b/shopfloor/tests/test_zone_picking_zero_check.py index 49738d8366..48ad12ab06 100644 --- a/shopfloor/tests/test_zone_picking_zero_check.py +++ b/shopfloor/tests/test_zone_picking_zero_check.py @@ -24,7 +24,8 @@ def test_is_zero_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) response = self.service.dispatch( "is_zero", @@ -36,7 +37,8 @@ def test_is_zero_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) response = self.service.dispatch( "is_zero", @@ -48,7 +50,8 @@ def test_is_zero_wrong_parameters(self): }, ) self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), + response, + message=self.service.msg_store.record_not_found(), ) def test_is_zero_is_empty(self): @@ -67,7 +70,10 @@ def test_is_zero_is_empty(self): ) move_lines = self.service._find_location_move_lines(zone_location, picking_type) self.assert_response_select_line( - response, zone_location, picking_type, move_lines, + response, + zone_location, + picking_type, + move_lines, ) def test_is_zero_is_not_empty(self): @@ -86,7 +92,10 @@ def test_is_zero_is_not_empty(self): ) move_lines = self.service._find_location_move_lines(zone_location, picking_type) self.assert_response_select_line( - response, zone_location, picking_type, move_lines, + response, + zone_location, + picking_type, + move_lines, ) inventory = self.env["stock.inventory"].search( [ @@ -100,6 +109,7 @@ def test_is_zero_is_not_empty(self): self.assertEqual( inventory.name, "Zero check issue on location {} ({})".format( - move_line.location_id.name, picking_type.name, + move_line.location_id.name, + picking_type.name, ), ) From f5bf25a438dc0c16ab1fc5341312c0ffcf7eb5bd Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 10 Dec 2020 19:14:44 +0000 Subject: [PATCH 465/940] [UPD] README.rst --- shopfloor/README.rst | 10 +++++----- shopfloor/static/description/index.html | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/shopfloor/README.rst b/shopfloor/README.rst index db94b7f8d3..b8a2a4657d 100644 --- a/shopfloor/README.rst +++ b/shopfloor/README.rst @@ -14,13 +14,13 @@ Shopfloor :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwms-lightgray.png?logo=github - :target: https://github.com/OCA/wms/tree/13.0/shopfloor + :target: https://github.com/OCA/wms/tree/14.0/shopfloor :alt: OCA/wms .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor + :target: https://translation.odoo-community.org/projects/wms-14-0/wms-14-0-shopfloor :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png - :target: https://runbot.odoo-community.org/runbot/285/13.0 + :target: https://runbot.odoo-community.org/runbot/285/14.0 :alt: Try me on Runbot |badge1| |badge2| |badge3| |badge4| |badge5| @@ -122,7 +122,7 @@ Bug Tracker Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us smashing it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -188,6 +188,6 @@ Current `maintainers `__: |maintainer-guewen| |maintainer-simahawk| |maintainer-sebalix| -This module is part of the `OCA/wms `_ project on GitHub. +This module is part of the `OCA/wms `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/shopfloor/static/description/index.html b/shopfloor/static/description/index.html index cc20fd6ecc..fc9823dfd8 100644 --- a/shopfloor/static/description/index.html +++ b/shopfloor/static/description/index.html @@ -367,7 +367,7 @@

Shopfloor

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

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 133f3caa21a9e986007e92e2c4ce2cfb96ae4a0b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 14 Dec 2020 16:19:22 +0100 Subject: [PATCH 466/940] pre-commit: ignore shopfloor* uninstallable modules --- shopfloor/__manifest__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 9735277ec7..a519a8cbc9 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -62,4 +62,5 @@ "demo/shopfloor_menu_demo.xml", "demo/shopfloor_profile_demo.xml", ], + "installable": False, } From b7de148842bfdff6fbda9927d460f6e0d2718857 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 20 Jan 2021 08:51:13 +0100 Subject: [PATCH 467/940] Adapt "dispatch" method to new base_rest API with *args "_id" has been replaced by *args --- shopfloor/services/service.py | 44 +++++++++++++++------------- shopfloor/tests/test_db_logging.py | 4 +-- shopfloor/tests/test_picking_form.py | 4 +-- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index dcfe75f6dc..60e2dfc226 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -56,11 +56,11 @@ class BaseShopfloorService(AbstractComponent): # can be overridden to disable logging of requests to DB _log_calls_in_db = True - def dispatch(self, method_name, _id=None, params=None): + def dispatch(self, method_name, *args, params=None): self._validate_headers_update_work_context(request, method_name) if not self._db_logging_active(): - return super().dispatch(method_name, _id=_id, params=params) - return self._dispatch_with_db_logging(method_name, _id=_id, params=params) + return super().dispatch(method_name, *args, params=params) + return self._dispatch_with_db_logging(method_name, *args, params=params) def _db_logging_active(self): return ( @@ -70,45 +70,47 @@ def _db_logging_active(self): ) # TODO logging to DB should be an extra module for base_rest - def _dispatch_with_db_logging(self, method_name, _id=None, params=None): + def _dispatch_with_db_logging(self, method_name, *args, params=None): try: - result = super().dispatch(method_name, _id=_id, params=params) + result = super().dispatch(method_name, *args, params=params) except exceptions.UserError as orig_exception: self._dispatch_exception( ShopfloorServiceUserErrorException, orig_exception, - _id=_id, + *args, params=params, ) except exceptions.ValidationError as orig_exception: self._dispatch_exception( ShopfloorServiceValidationErrorException, orig_exception, - _id=_id, + *args, params=params, ) except Exception as orig_exception: self._dispatch_exception( - ShopfloorServiceDispatchException, - orig_exception, - _id=_id, - params=params, + ShopfloorServiceDispatchException, orig_exception, *args, params=params, ) - log_entry = self._log_call_in_db(self.env, request, _id, params, result=result) + log_entry = self._log_call_in_db( + self.env, request, *args, params=params, result=result + ) log_entry_url = self._get_log_entry_url(log_entry) result["log_entry_url"] = log_entry_url return result - def _dispatch_exception( - self, exception_klass, orig_exception, _id=None, params=None - ): + def _dispatch_exception(self, exception_klass, orig_exception, *args, params=None): tb = traceback.format_exc() # TODO: how to test this? Cannot rollback nor use another cursor self.env.cr.rollback() with registry(self.env.cr.dbname).cursor() as cr: env = self.env(cr=cr) log_entry = self._log_call_in_db( - env, request, _id, params, traceback=tb, orig_exception=orig_exception + env, + request, + *args, + params=params, + traceback=tb, + orig_exception=orig_exception, ) log_entry_url = self._get_log_entry_url(log_entry) # UserError and alike have `name` attribute to store the msg @@ -133,14 +135,14 @@ def _get_log_entry_url(self, entry): def _log_call_header_strip(self): return ("Cookie", "Api-Key") - def _log_call_in_db_values(self, _request, _id, params, **kw): + def _log_call_in_db_values(self, _request, *args, params=None, **kw): httprequest = _request.httprequest headers = dict(httprequest.headers) for header_key in self._log_call_header_strip: if header_key in headers: headers[header_key] = "" - if _id: - params = dict(params, _id=_id) + if args: + params = dict(params or {}, args=args) result = kw.get("result") error = kw.get("traceback") @@ -164,8 +166,8 @@ def _log_call_in_db_values(self, _request, _id, params, **kw): "state": "success" if result else "failed", } - def _log_call_in_db(self, env, _request, _id, params, **kw): - values = self._log_call_in_db_values(_request, _id, params, **kw) + def _log_call_in_db(self, env, _request, *args, params=None, **kw): + values = self._log_call_in_db_values(_request, *args, params=params, **kw) if not values: return return env["shopfloor.log"].sudo().create(values) diff --git a/shopfloor/tests/test_db_logging.py b/shopfloor/tests/test_db_logging.py index 7a982b9169..737feb3861 100644 --- a/shopfloor/tests/test_db_logging.py +++ b/shopfloor/tests/test_db_logging.py @@ -97,7 +97,7 @@ def test_log_entry_values_success(self): httprequest=httprequest, extra_headers=extra_headers ) as mocked_request: entry = self.service._log_call_in_db( - self.env, mocked_request, _id, params, **kw + self.env, mocked_request, _id, params=params, **kw ) expected = { "request_url": httprequest["url"], @@ -110,7 +110,7 @@ def test_log_entry_values_success(self): self.assertRecordValues(entry, [expected]) expected_json = { "result": {"data": "worked!"}, - "params": dict(params, _id=_id), + "params": dict(params, args=[_id]), "headers": { "Cookie": "", "Api-Key": "", diff --git a/shopfloor/tests/test_picking_form.py b/shopfloor/tests/test_picking_form.py index 0ab9cc3e8c..8ae8ba677e 100644 --- a/shopfloor/tests/test_picking_form.py +++ b/shopfloor/tests/test_picking_form.py @@ -24,7 +24,7 @@ def setUp(self): def test_picking_form_get(self): available_carriers = self.service._get_available_carriers(self.picking) - response = self.service.dispatch("get", _id=self.picking.id) + response = self.service.dispatch("get", self.picking.id) self.assert_response( response, data={ @@ -42,7 +42,7 @@ def test_picking_form_update(self): available_carriers = self.service._get_available_carriers(self.picking) self.picking.carrier_id = available_carriers[0] params = {"carrier_id": available_carriers[1].id} - response = self.service.dispatch("update", _id=self.picking.id, params=params) + response = self.service.dispatch("update", self.picking.id, params=params) self.assert_response( response, data={ From 61343fb3e661887211453c2e77d8173a4b6c028c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 20 Jan 2021 08:56:28 +0100 Subject: [PATCH 468/940] Fix REST services tests Since version 13.0.3.0.0 of base_rest, we have to setup registries --- shopfloor/tests/common.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index d298be103e..2a445122eb 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -8,6 +8,7 @@ from odoo.tests.common import Form, SavepointCase from odoo.addons.base_rest.controllers.main import _PseudoCollection +from odoo.addons.base_rest.tests.common import RegistryMixin from odoo.addons.component.core import WorkContext from odoo.addons.component.tests.common import ComponentMixin @@ -26,7 +27,7 @@ def __eq__(self, other): return True -class CommonCase(SavepointCase, ComponentMixin): +class CommonCase(SavepointCase, RegistryMixin, ComponentMixin): """Base class for writing Shopfloor tests All tests are run as normal stock user by default, to check that all the @@ -90,6 +91,7 @@ def setUpClass(cls): ) cls.setUpComponent() + cls.setUpRegistry() cls.setUpClassUsers() cls.setUpClassVars() cls.setUpClassBaseData() From 93f5057a4fbdc61aced10e0e3e6d015c76f91767 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 20 Jan 2021 08:57:45 +0100 Subject: [PATCH 469/940] Fix validators for new base_rest API In the initial implementation of rest_api, the schema validators had to be returned by methods in the same service as the method, named after the endpoint's method with a prefix: "_validator_" or "_validator_return_". As we have a lot of endpoints methods in some services, we extracted the validator methods in dedicated components with "base.shopfloor.validator" and "base.shopfloor.validator.return" usages, and methods of the same name as the endpoint's method. With the new API, endpoints are decorated with "@restapi.method" and the validator is defined there. Example: @restapi.method( [(["//get", "/"], "GET")], output_param=restapi.CerberusValidator("_get_partner_schema"), auth="public", ) For backward compatilibity, base_rest patches the methods not decorated and sets the "input_param" and "output_param" to call the "_validator_" or "_validator_return_": https://github.com/OCA/rest-framework/blob/abd74cd7241d3b93054825cc3e41cb7b693c9000/base_rest/models/rest_service_registration.py#L240-L250 This change adds the extra layer that will call the validator components when one of these methods is called. A follow-up commit will replace the getattr by a better fix with a change in base_rest, but meanwhile, this commit will unblock the OCA/wms CI. --- shopfloor/services/service.py | 46 ++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 60e2dfc226..5fcd3b0fcc 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -14,6 +14,7 @@ from odoo.addons.base_rest.controllers.main import _PseudoCollection from odoo.addons.component.core import AbstractComponent, WorkContext +from odoo.addons.component.exception import NoComponentError def to_float(val): @@ -196,19 +197,42 @@ def _to_json(self, records): res.append(self._convert_one_record(record)) return res - def _get_input_validator(self, method_name): - # override the method to get the validator in a component - # instead of a method, to keep things apart - validator_component = self.component(usage="%s.validator" % self._usage) - return validator_component._get_validator(method_name) - - def _get_output_validator(self, method_name): - # override the method to get the validator in a component - # instead of a method, to keep things apart + def _get_validator_schema(self, method_name, usage_suffix): validator_component = self.component( - usage="%s.validator.response" % self._usage + usage="{}.{}".format(self._usage, usage_suffix) ) - return validator_component._get_validator(method_name) + return getattr(validator_component, method_name) + + # FIXME: must be replaced by a cleaner way to customize the validator + # handler, using: https://github.com/OCA/rest-framework/pull/99 + def __getattr__(self, item): + # We have delegated the validator / return validators to dedicated + # components. In the new base_rest API, validator schemas are handled + # differently, but a backward compatibility layer adds + # "_validator_" and "_validator_return_" in the + # "routing" of the endpoints, which are automatically called on the + # service. As we have no way to replace the current service by the + # validator upstream, catch calls to these methods and get the schema + # from the validator services. + if item.startswith("_validator_return_"): + method_name = item.replace("_validator_return_", "") + try: + schema_handler = self._get_validator_schema( + method_name, "validator.response" + ) + except NoComponentError: + return super().__getattr__(item) + return schema_handler + + if item.startswith("_validator_"): + method_name = item.replace("_validator_", "") + try: + schema_handler = self._get_validator_schema(method_name, "validator") + except NoComponentError: + return super().__getattr__(item) + return schema_handler + + return super().__getattr__(item) def _response( self, base_response=None, data=None, next_state=None, message=None, popup=None From 46227308ccef680cd33e9ca9a7bbc0235dc14f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 8 Jan 2021 09:29:26 +0100 Subject: [PATCH 470/940] shopfloor: create normal backorder when validating moves Before: when moves are processed, we were managing two cases: - if the current moves to process are all the moves remaining in a transfer, we validate the transfer - if the current moves to process are a subset of available moves of a transfer, then we put these moves to process in a new transfer which is validated. All remaining moves stay in the current transfer. After: a third case is added: - if the current moves to process are equal to all available moves of the current picking - but there are still unavailable moves to process - then we validate the current transfer as usual, which will create a backorder with remaining/unavailable moves. As this third case is very specific to Shopfloor, it has been implemented in a new StockAction component (used by all related scenarios). Now all scenarios use this new component to validate stock moves, and the `extract_and_action_done` method on `stock.move` can stay generic. The opportunity was taken to create a new method `split_unavailable_qty` on `stock.move` to put unavailable qty to a new move and update the current move to `assigned` (it was done directly in `extract_and_action_done` before). --- shopfloor/actions/__init__.py | 1 + shopfloor/actions/stock.py | 41 +++++++++++++++++++ shopfloor/models/stock_move.py | 20 ++++++--- .../services/location_content_transfer.py | 6 ++- shopfloor/services/single_pack_transfer.py | 3 +- shopfloor/services/zone_picking.py | 9 ++-- shopfloor/tests/test_stock_split.py | 4 +- .../tests/test_zone_picking_unload_all.py | 11 ++--- ...est_zone_picking_unload_set_destination.py | 10 +++-- 9 files changed, 82 insertions(+), 23 deletions(-) create mode 100644 shopfloor/actions/stock.py diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index 788ec5e8a9..43f307a745 100644 --- a/shopfloor/actions/__init__.py +++ b/shopfloor/actions/__init__.py @@ -27,3 +27,4 @@ from . import inventory from . import savepoint from . import move_line_search +from . import stock diff --git a/shopfloor/actions/stock.py b/shopfloor/actions/stock.py new file mode 100644 index 0000000000..54e9f0b848 --- /dev/null +++ b/shopfloor/actions/stock.py @@ -0,0 +1,41 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.component.core import Component + + +class StockAction(Component): + """Provide methods to work with stock operations.""" + + _name = "shopfloor.stock.action" + _inherit = "shopfloor.process.action" + _usage = "stock" + + def validate_moves(self, moves): + """Validate moves in different ways depending on several criterias: + + - moves to process are all the moves of the related transfer: + the current transfer is validated + - moves to process are a subset of available moves in the picking: + the moves are put in a new transfer which is validated, the current + transfer still have the remaining moves + - moves to process are exactly the assigned moves of the related transfer: + the transfer is validated as usual, creating a backorder. + """ + moves.split_unavailable_qty() + for picking in moves.picking_id: + moves_todo = picking.move_lines & moves + if self._check_backorder(picking, moves_todo): + picking.action_done() + else: + moves_todo.extract_and_action_done() + + def _check_backorder(self, picking, moves): + """Check if the `picking` has to be validated as usual to create a backorder. + + If the moves are equal to all available moves of the current picking + - but there are still unavailable moves to process - then we want to + create a normal backorder (i.e. the current picking is validated and + the remaining moves are put in a backorder as usual) + """ + assigned_moves = picking.move_lines.filtered(lambda m: m.state == "assigned") + return moves == assigned_moves diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py index 20473b6f26..26f34fbc2b 100644 --- a/shopfloor/models/stock_move.py +++ b/shopfloor/models/stock_move.py @@ -37,6 +37,15 @@ def split_other_move_lines(self, move_lines, intersection=False): return backorder_move return False + def split_unavailable_qty(self): + """Put unavailable qty of a partially available move in their own + move (which will be 'confirmed'). + """ + partial_moves = self.filtered(lambda m: m.state == "partially_available") + for partial_move in partial_moves: + partial_move.split_other_move_lines(partial_move.move_line_ids) + return partial_moves + def extract_and_action_done(self): """Extract the moves in a separate transfer and validate them. @@ -44,20 +53,19 @@ def extract_and_action_done(self): to first extract some move lines in a separate move, then validate it with this method. """ - # Put remaining qty to process from partially available moves - # in their own move (which will be then 'confirmed') - partial_moves = self.filtered(lambda m: m.state == "partially_available") - for partial_move in partial_moves: - partial_move.split_other_move_lines(partial_move.move_line_ids) # Process assigned moves moves = self.filtered(lambda m: m.state == "assigned") if not moves: return False for picking in moves.picking_id: moves_todo = picking.move_lines & moves + # No need to create a new transfer if we are processing all moves if moves_todo == picking.move_lines: - # No need to create a new transfer if we are processing all moves new_picking = picking + # We process some available moves of the picking, but there are still + # some other moves to process, then we put the moves to process in + # a new transfer to validate. All remaining moves stay in the + # current transfer. else: new_picking = picking.copy( { diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index c2a8dbe4c2..4fafb5e0c2 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -656,7 +656,8 @@ def set_destination_package( # split the move to process only the lines related to the package. package_move.split_other_move_lines(package_move_lines) self._write_destination_on_lines(package_level.move_line_ids, scanned_location) - package_moves.extract_and_action_done() + stock = self.actions_for("stock") + stock.validate_moves(package_moves) move_lines = self._find_transfer_move_lines(location) message = self.msg_store.location_content_transfer_item_complete( scanned_location @@ -730,7 +731,8 @@ def set_destination_line( remaining_move_line.qty_done = remaining_move_line.product_uom_qty move_line.move_id.split_other_move_lines(move_line) self._write_destination_on_lines(move_line, scanned_location) - move_line.move_id.extract_and_action_done() + stock = self.actions_for("stock") + stock.validate_moves(move_line.move_id) move_lines = self._find_transfer_move_lines(location) message = self.msg_store.location_content_transfer_item_complete( scanned_location diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index b0c7a6faa2..917d47e8a7 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -267,7 +267,8 @@ def _set_destination_and_done(self, move, scanned_location): # when writing the destination on the package level, it writes # on the move lines move.move_line_ids.package_level_id.location_dest_id = scanned_location - move.extract_and_action_done() + stock = self.actions_for("stock") + stock.validate_moves(move) def cancel(self, package_level_id): package_level = self.env["stock.package_level"].browse(package_level_id) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 2ef04204a6..cb01aa3168 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -566,7 +566,8 @@ def _set_destination_location( # try to re-assign any split move (in case of partial qty) if "confirmed" in move_line.picking_id.move_lines.mapped("state"): move_line.picking_id.action_assign() - move_line.move_id.extract_and_action_done() + stock = self.actions_for("stock") + stock.validate_moves(move_line.move_id) location_changed = True # Zero check zero_check = picking_type.shopfloor_zero_check @@ -1095,7 +1096,8 @@ def set_destination_all( self._write_destination_on_lines(buffer_lines, location) # set lines to done + refresh buffer lines (should be empty) moves = buffer_lines.mapped("move_id") - moves.extract_and_action_done() + stock = self.actions_for("stock") + stock.validate_moves(moves) message = self.msg_store.buffer_complete() buffer_lines = self._find_buffer_move_lines(zone_location, picking_type) else: @@ -1302,7 +1304,8 @@ def unload_set_destination( for move in moves: move.split_other_move_lines(buffer_lines & move.move_line_ids) - moves.extract_and_action_done() + stock = self.actions_for("stock") + stock.validate_moves(moves) buffer_lines = self._find_buffer_move_lines(zone_location, picking_type) if buffer_lines: diff --git a/shopfloor/tests/test_stock_split.py b/shopfloor/tests/test_stock_split.py index af2c925d5c..d4f099045f 100644 --- a/shopfloor/tests/test_stock_split.py +++ b/shopfloor/tests/test_stock_split.py @@ -171,7 +171,7 @@ def test_split_pickings_from_source_location(self): set(self.pack_move_a.move_line_ids.mapped("state")), {"assigned"} ) - def test_extract_and_action_done_one_move(self): + def test_extract_and_action_done_one_assigned_move(self): self.assertFalse(self.picking.backorder_ids) self.assertEqual(self.picking.state, "assigned") for move_line in self.pick_move_b.move_line_ids: @@ -187,7 +187,7 @@ def test_extract_and_action_done_one_move(self): self.assertEqual(self.pick_move_b.state, "done") self.assertEqual(new_picking.state, "done") - def test_extract_and_action_done_several_moves(self): + def test_extract_and_action_done_multiple_assigned_moves(self): self.assertFalse(self.picking.backorder_ids) self.assertEqual(self.picking.state, "assigned") for move_line in self.picking.move_line_ids: diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py index 33997cf93d..8880b4dcda 100644 --- a/shopfloor/tests/test_zone_picking_unload_all.py +++ b/shopfloor/tests/test_zone_picking_unload_all.py @@ -265,7 +265,7 @@ def test_set_destination_all_partial_qty_done_ok(self): ) # check data # picking validated - picking_validated = self.picking6.backorder_ids + picking_validated = self.picking6 self.assertEqual(picking_validated.state, "done") self.assertEqual(picking_validated.move_line_ids, move_line_g | move_line_h) self.assertEqual(move_line_g.state, "done") @@ -273,10 +273,11 @@ def test_set_destination_all_partial_qty_done_ok(self): self.assertEqual(move_line_h.state, "done") self.assertEqual(move_line_h.qty_done, 3) # current picking (backorder) - self.assertEqual(self.picking6.state, "confirmed") - self.assertEqual(self.picking6.move_lines.product_id, self.product_h) - self.assertEqual(self.picking6.move_lines.product_uom_qty, 3) - self.assertFalse(self.picking6.move_line_ids) + backorder = self.picking6.backorder_ids + self.assertEqual(backorder.state, "confirmed") + self.assertEqual(backorder.move_lines.product_id, self.product_h) + self.assertEqual(backorder.move_lines.product_uom_qty, 3) + self.assertFalse(backorder.move_line_ids) # buffer should be empty buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) self.assertFalse(buffer_lines) diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py index 5e6b935032..f0c475cb1e 100644 --- a/shopfloor/tests/test_zone_picking_unload_set_destination.py +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -361,11 +361,13 @@ def test_unload_set_destination_partially_available_backorder(self): ) # check data # move line has been moved to a new picking - self.assertEqual(move_line.move_id.picking_id, self.picking_z.backorder_ids[0]) - # the old picking contains a new line w/ the rest of the qty + # move line has been validated in the current picking + self.assertEqual(move_line.move_id.picking_id, self.picking_z) + # the new picking (backorder) contains a new line w/ the rest of the qty # that couldn't be processed - self.assertEqual(self.picking_z.move_lines[0].product_uom_qty, 8) - self.assertEqual(self.picking_z.state, "confirmed") + backorder = self.picking_z.backorder_ids[0] + self.assertEqual(backorder.move_lines[0].product_uom_qty, 8) + self.assertEqual(backorder.state, "confirmed") # the line has been processed self.assertEqual(move_line.location_dest_id, packing_sublocation) self.assertEqual(move_line.move_id.state, "done") From 72d00f4356687f0be1903a3f21e6146dbaea7a50 Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Sat, 6 Feb 2021 18:33:27 +0000 Subject: [PATCH 471/940] Added translation using Weblate (Spanish (Argentina)) --- shopfloor/i18n/es_AR.po | 1417 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 1417 insertions(+) create mode 100644 shopfloor/i18n/es_AR.po diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po new file mode 100644 index 0000000000..cfb0be0099 --- /dev/null +++ b/shopfloor/i18n/es_AR.po @@ -0,0 +1,1417 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 13.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: es_AR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" + +#. module: shopfloor +#: code:addons/shopfloor/services/forms/form_mixin.py:0 +#, python-format +msgid "%s updated." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "A destination package is required." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/change_package_lot.py:0 +#, python-format +msgid "A draft inventory has been created for control." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_zero_check +msgid "Activate Zero Check" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__active +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__active +msgid "Active" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_priority_postpone_mixin +msgid "Adds shopfloor priority/postpone fields" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "All packages processed." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_move_create +msgid "Allow Move Creation" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves +msgid "Allow to process reserved quantities" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_form_view +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_profile_form_view +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_profile_search_view +msgid "Archived" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Are you sure?" +msgstr "" + +#. module: shopfloor +#: model:ir.actions.server,name:shopfloor.ir_cron_autovacuum_shopfloor_log_ir_actions_server +#: model:ir.cron,cron_name:shopfloor.ir_cron_autovacuum_shopfloor_log +#: model:ir.cron,name:shopfloor.ir_cron_autovacuum_shopfloor_log +msgid "Auto-vacuum Shopfloor Logs" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Barcode does not match with {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Barcode not found" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking_batch +msgid "Batch Transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Batch Transfer complete" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Batch Transfer line done" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Bin %s doesn't exist" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Canceled, you can scan a new pack." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Cannot change to lot {} which is entirely picked." +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_checkout +#: model:stock.picking.type,name:shopfloor.picking_type_checkout_demo +msgid "Checkout" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_res_partner__shopfloor_packing_info +#: model:ir.model.fields,field_description:shopfloor.field_res_users__shopfloor_packing_info +msgid "Checkout Packing Information" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_cluster_picking +#: model:stock.picking.type,name:shopfloor.picking_type_cluster_picking_demo +msgid "Cluster Picking" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Confirm location change from %s to %s?" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_res_partner +msgid "Contact" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Content transfer to {} completed" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Content transferred from {} to {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Control stock issue in location {} for {}" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_uid +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__create_uid +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__create_uid +msgid "Created by" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move.py:0 +#, python-format +msgid "" +"Created from backorder %s." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_date +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__create_date +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__create_date +msgid "Created on" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Creation of moves is not allowed for menu {}." +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Date" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_delivery +#: model:stock.picking.type,name:shopfloor.picking_type_delivery_demo +msgid "Delivery" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__display_name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__display_name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__display_name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__display_name +msgid "Display Name" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__shopfloor_display_packing_info +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_display_packing_info +msgid "Display customer packing info" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__error +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view +msgid "Error" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__exception_name +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Exception" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__exception_message +msgid "Exception Message" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Exception message" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__state__failed +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Failed" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_zero_check +msgid "" +"For Shopfloor scenarios using it (Cluster Picking, Zone Picking, Discrete " +"order Picking), the zero check step will be activated when a location " +"becomes empty after a move." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__shopfloor_display_packing_info +#: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_display_packing_info +msgid "" +"For the Shopfloor Checkout/Packing scenarios to display the customer packing" +" info." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__location_id +msgid "From" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__functional +msgid "Functional" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Functional errors" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Group By" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__headers +msgid "Headers" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__id +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__id +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__id +msgid "ID" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__ignore_no_putaway_available +msgid "" +"If you tick this box, the transfer is reserved only if the put-away can find" +" a sublocation (when putaway destination is different from the operation " +"type's destination)." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves +msgid "" +"If you tick this box, this scenario will allow operator to move goods even " +"if a reservation is made by a different operation type." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available_is_possible +msgid "Ignore No Putaway Available Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available +msgid "Ignore transfers when no put-away is available" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Ignoring not found putaway is not allowed for menu {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_inventory +msgid "Inventory" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_location +msgid "Inventory Locations" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log____last_update +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu____last_update +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin____last_update +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile____last_update +msgid "Last Modified on" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_uid +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__write_uid +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_date +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__write_date +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__write_date +msgid "Last Updated on" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/completion_info.py:0 +#, python-format +msgid "" +"Last operation of transfer {}. Next operation ({}) is ready to proceed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Line cancelled" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lines have different destination location." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location %s doesn't contain any package." +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_location_content_transfer +#: model:stock.picking.type,name:shopfloor.picking_type_location_content_transfer_demo +msgid "Location Content Transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location not allowed here." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location {} empty" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Logs generated today" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Lot is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Lot {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lot {} is for another product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lot {} replaced by lot {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Lot: " +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_menu +msgid "Menu displayed in the scanner application" +msgstr "" + +#. module: shopfloor +#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_menu +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__menu_ids +#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_menu +msgid "Menus" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_profile__menu_ids +msgid "Menus visible for this profile" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__move_create_is_possible +msgid "Move Create Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__move_line_ids +msgid "Move Line" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__move_line_count +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__move_line_count +msgid "Move Line Count" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "Move lines processed have to share the same source location." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__name +msgid "Name" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Negative quantity not allowed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "New move lines cannot be assigned: canceled." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No lines to process." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No location found for this barcode." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No lot found among current transfers." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No lot found for {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "No more work to do, please create a new batch transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No operation type found for this menu and profile." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/service.py:0 +#, python-format +msgid "No operation types configured on menu {} for warehouse {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No package or lot found for barcode {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No pending operation for package %s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found among current transfers." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No putaway destination is available." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No quantity has been processed, unable to complete the transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "No valid package to select." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Not a valid destination package" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Not all lines have been processed with full quantity. Do you confirm partial" +" operation?" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "" +"Not allowed to pack more than the quantity, the value has been changed to " +"the maximum." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__picking_type_ids +msgid "Operation Types" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Operation already processed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Operation's already running. Would you like to take it over?" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Package cancelled" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package has been opened. You can move partial quantities." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "Package level has to be in draft" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_quant_package.py:0 +#, python-format +msgid "Package name must be unique!" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Package {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} cannot be picked, already moved by transfer {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} cannot be used: {} " +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "" +"Package {} does not contain available product {}, cannot replace package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} has a different content." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "Package {} has been partially picked in another location" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} is already used." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} is not available in transfer {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} is not empty." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Package {} is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} replaced by package {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_quant_package +msgid "Packages" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Packaging changed on package {}" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__shopfloor_packing_info +msgid "Packing information" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view +msgid "Parameters" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__params +msgid "Params" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/change_package_lot.py:0 +#, python-format +msgid "Pick: stock issue on lot: {} found in {}" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__picking_count +msgid "Picking Count" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking_type +msgid "Picking Type" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Picking has already been started in this location in transfer(s): {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Picking type {} complete." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__planned_move_line_ids +msgid "Planned Move Line" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Processing reserved quantities is not allowed for menu {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_move_line +msgid "Product Moves (Stock Move Line)" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Product is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Product tracked by lot, please scan one." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Product {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Product(s) packed in {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Product(s) processed as raw product(s)" +msgstr "" + +#. module: shopfloor +#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_profile +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__profile_ids +#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_profile +msgid "Profiles" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_quant +msgid "Quants" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/scan_anything.py:0 +#, python-format +msgid "" +"Record not found.\n" +"We've tried with the following types: {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Recovered previous session." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Remaining raw product not packed, proceed anyway?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__request_method +msgid "Request Method" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__request_url +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Request URL" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__reserved_move_line_ids +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__reserved_move_line_ids +msgid "Reserved Move Line" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Restart the operation, someone has canceled it." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__result +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view +msgid "Result" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF Priority" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF checkout done" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF unloaded" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Scan the destination location" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "Scan the package" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scenario +msgid "Scenario" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_form_view +msgid "Scenario Options" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "" +"Scenario `{}` require(s) 'Move Entire Packages' to be enabled.\n" +"These type(s) do not satisfy this constraint: \n" +"{}.\n" +"Please, adjust your configuration." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__sequence +msgid "Sequence" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several lots found in %s, please scan a lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several operation types found for this menu and profile." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several packages found in %s, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several products found in %s, please scan a product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "" +"Several transfers found, please scan a package or select a transfer " +"manually." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__severe +msgid "Severe" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Severe errors" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__severity +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Severity" +msgstr "" + +#. module: shopfloor +#: model:ir.ui.menu,name:shopfloor.menu_shopfloor_settings +#: model_terms:ir.ui.view,arch_db:shopfloor.res_partner_shopfloor_form +#: model_terms:ir.ui.view,arch_db:shopfloor.view_location_form +#: model_terms:ir.ui.view,arch_db:shopfloor.view_picking_type_form +msgid "Shopfloor" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_checkout_done +msgid "Shopfloor Checkout Done" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_log +msgid "Shopfloor Logging" +msgstr "" + +#. module: shopfloor +#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_log +#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_log +msgid "Shopfloor Logs" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_menu_ids +msgid "Shopfloor Menus" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__shopfloor_picking_sequence +msgid "Shopfloor Picking Sequence" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_postponed +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_postponed +msgid "Shopfloor Postponed" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_priority +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_priority +msgid "Shopfloor Priority" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_unloaded +msgid "Shopfloor Unloaded" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_user_id +msgid "Shopfloor User" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_profile +msgid "Shopfloor profile settings" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_single_pallet_transfer +#: model:stock.picking.type,name:shopfloor.picking_type_single_pallet_transfer_demo +msgid "Single Pallet Transfer" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_move_create +msgid "" +"Some scenario may create move(s) when a product or package is scanned and no" +" move already exists. Any new move is created in the selected operation " +"type, so it can be active only when one type is selected." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__source_move_line_ids +msgid "Source Move Line" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__package_id +msgid "Source Package" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__state +msgid "State" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Status" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_move +msgid "Stock Move" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_package_level +msgid "Stock Package Level" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__picking_id +msgid "Stock Picking" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__state__success +msgid "Success" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_postponed +#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_postponed +msgid "" +"Technical field. Indicates if the operation has been postponed in a barcode " +"scenario." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__move_line_count +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__move_line_count +msgid "Technical field. Indicates number of move lines included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__picking_count +msgid "Technical field. Indicates number of transfers included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__total_weight +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__total_weight +msgid "Technical field. Indicates total weight of transfers included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__planned_move_line_ids +msgid "Technical field. Move lines for which destination is this package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__move_line_ids +msgid "Technical field. Move lines moving this package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_priority +#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_priority +msgid "Technical field. Overrides operation priority in barcode scenario." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "" +"The backorder %s has been created." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "The destination bin {} is not empty, please take another." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The pack has been moved, you can scan a new pack." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s doesn't exist" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_location__shopfloor_picking_sequence +msgid "" +"The picking done in Shopfloor scenarios will respect this order. The " +"sequence is a char so it can be composed of fields such as 'corridor-rack-" +"side-level'. Pay attention to the padding ('09' is before '19', '9' is not)." +" It is recommended to use an Export then an Import to populate this field " +"using a spreadsheet." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/service.py:0 +#, python-format +msgid "The record %s %s does not exist" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The record you were working on does not exist anymore." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__picking_id +msgid "The stock operation where the packing has been made" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "This batch cannot be selected." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This line has a package, please select the package instead." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This line is not available in transfer {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "This location content can't be moved at once." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "This location content can't be moved using this menu." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This lot does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This lot is part of a package with other products, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This lot is part of multiple packages, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This operation does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This package does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This product is part of a package with other products, please scan a " +"package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product is part of multiple packages, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This transfer does not exist or is not available anymore." +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Today" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__total_weight +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__total_weight +msgid "Total Weight" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking +msgid "Transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} complete" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} done" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} is not available." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Units replaced by package {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Unrecoverable error, please restart." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unreserve_other_moves_is_possible +msgid "Unreserve Other Moves Is Possible" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "User" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__profile_ids +msgid "Visible for these profiles" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__warehouse_id +msgid "Warehouse" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__warning +msgid "Warning" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +msgid "Warning errors" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "Wrong bin" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot move this using this menu." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot place it here" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot work on a package (%s) outside of locations: %s" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You must not pick more than {} units." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Zero check issue on location {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Zero check issue on location {} ({})" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_zone_picking +#: model:stock.picking.type,name:shopfloor.picking_type_zone_picking_demo +msgid "Zone Picking" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "" +"{picking.name} stock correction in location {location.name} for " +"{product_desc}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "{} {} put in {}" +msgstr "" From 4d566982721bb6cf25635292104ad70c65b8d23a Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Sat, 6 Feb 2021 20:06:38 +0000 Subject: [PATCH 472/940] Translated using Weblate (Spanish (Argentina)) Currently translated at 100.0% (235 of 235 strings) Translation: wms-14.0/wms-14.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-14-0/wms-14-0-shopfloor/es_AR/ --- shopfloor/i18n/es_AR.po | 476 ++++++++++++++++++++++------------------ 1 file changed, 267 insertions(+), 209 deletions(-) diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index cfb0be0099..ac41377758 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -6,63 +6,65 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 13.0\n" "Report-Msgid-Bugs-To: \n" -"Last-Translator: Automatically generated\n" +"PO-Revision-Date: 2021-02-06 22:44+0000\n" +"Last-Translator: Ignacio Buioli \n" "Language-Team: none\n" "Language: es_AR\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: \n" "Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.3.2\n" #. module: shopfloor #: code:addons/shopfloor/services/forms/form_mixin.py:0 #, python-format msgid "%s updated." -msgstr "" +msgstr "%s actualizado." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "A destination package is required." -msgstr "" +msgstr "Un paquete de destino es requerido." #. module: shopfloor #: code:addons/shopfloor/actions/change_package_lot.py:0 #, python-format msgid "A draft inventory has been created for control." -msgstr "" +msgstr "Se ha creado un borrador de inventario para su control." #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_zero_check msgid "Activate Zero Check" -msgstr "" +msgstr "Activar Verificación Cero" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__active #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__active msgid "Active" -msgstr "" +msgstr "Activo" #. module: shopfloor #: model:ir.model,name:shopfloor.model_shopfloor_priority_postpone_mixin msgid "Adds shopfloor priority/postpone fields" -msgstr "" +msgstr "Agrega campos de prioridad / aplazamiento del taller" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "All packages processed." -msgstr "" +msgstr "Todos los paquetes procesados." #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_move_create msgid "Allow Move Creation" -msgstr "" +msgstr "Permitir Creación de Movimiento" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves msgid "Allow to process reserved quantities" -msgstr "" +msgstr "Permitir procesar cantidades reservadas" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_form_view @@ -70,121 +72,121 @@ msgstr "" #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_profile_form_view #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_profile_search_view msgid "Archived" -msgstr "" +msgstr "Archivado" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Are you sure?" -msgstr "" +msgstr "¿Está seguro?" #. module: shopfloor #: model:ir.actions.server,name:shopfloor.ir_cron_autovacuum_shopfloor_log_ir_actions_server #: model:ir.cron,cron_name:shopfloor.ir_cron_autovacuum_shopfloor_log #: model:ir.cron,name:shopfloor.ir_cron_autovacuum_shopfloor_log msgid "Auto-vacuum Shopfloor Logs" -msgstr "" +msgstr "Eliminación Automática de Registros del Taller" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Barcode does not match with {}." -msgstr "" +msgstr "Código de barras no coincide con {}." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Barcode not found" -msgstr "" +msgstr "Código de barras no encontrado" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_picking_batch msgid "Batch Transfer" -msgstr "" +msgstr "Transferencia por Lotes" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Batch Transfer complete" -msgstr "" +msgstr "Transferencia por Lotes completa" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Batch Transfer line done" -msgstr "" +msgstr "Línea de Transferencia por lotes hecha" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Bin %s doesn't exist" -msgstr "" +msgstr "Compartimento %s no existe" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Canceled, you can scan a new pack." -msgstr "" +msgstr "Cancelado, puede escanear un nuevo paquete." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Cannot change to lot {} which is entirely picked." -msgstr "" +msgstr "No se puede cambiar al lote {} ya que está completamente recogido." #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_checkout #: model:stock.picking.type,name:shopfloor.picking_type_checkout_demo msgid "Checkout" -msgstr "" +msgstr "Checkout" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_res_partner__shopfloor_packing_info #: model:ir.model.fields,field_description:shopfloor.field_res_users__shopfloor_packing_info msgid "Checkout Packing Information" -msgstr "" +msgstr "Información del Paquete del Checkout" #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_cluster_picking #: model:stock.picking.type,name:shopfloor.picking_type_cluster_picking_demo msgid "Cluster Picking" -msgstr "" +msgstr "Grupo de Picking" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Confirm location change from %s to %s?" -msgstr "" +msgstr "¿Confirma cambiar ubicación desde %s hacia %s?" #. module: shopfloor #: model:ir.model,name:shopfloor.model_res_partner msgid "Contact" -msgstr "" +msgstr "Contacto" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Content transfer to {} completed" -msgstr "" +msgstr "Transferencia de contenido a {} completada" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Content transferred from {} to {}." -msgstr "" +msgstr "Transferencia de contenido desde {} hacia {}." #. module: shopfloor #: code:addons/shopfloor/actions/inventory.py:0 #, python-format msgid "Control stock issue in location {} for {}" -msgstr "" +msgstr "Error en control de inventario en ubicación {} para {}" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_uid #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__create_uid #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__create_uid msgid "Created by" -msgstr "" +msgstr "Creado por" #. module: shopfloor #: code:addons/shopfloor/models/stock_move.py:0 @@ -193,30 +195,32 @@ msgid "" "Created from backorder %s." msgstr "" +"Creado desde pedido pendiente %s." #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_date #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__create_date #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__create_date msgid "Created on" -msgstr "" +msgstr "Creado el" #. module: shopfloor #: code:addons/shopfloor/models/shopfloor_menu.py:0 #, python-format msgid "Creation of moves is not allowed for menu {}." -msgstr "" +msgstr "La creación de movimientos no está permitida para el menú {}." #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Date" -msgstr "" +msgstr "Fecha" #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_delivery #: model:stock.picking.type,name:shopfloor.picking_type_delivery_demo msgid "Delivery" -msgstr "" +msgstr "Entrega" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__display_name @@ -224,41 +228,41 @@ msgstr "" #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__display_name #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__display_name msgid "Display Name" -msgstr "" +msgstr "Mostrar Nombre" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_picking__shopfloor_display_packing_info #: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_display_packing_info msgid "Display customer packing info" -msgstr "" +msgstr "Mostrar información del empaquetado de cliente" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__error #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view msgid "Error" -msgstr "" +msgstr "Error" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__exception_name #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Exception" -msgstr "" +msgstr "Excepción" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__exception_message msgid "Exception Message" -msgstr "" +msgstr "Mensaje de Excepción" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Exception message" -msgstr "" +msgstr "Mensaje de Excepción" #. module: shopfloor #: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__state__failed #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Failed" -msgstr "" +msgstr "Fallido" #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_zero_check @@ -267,6 +271,9 @@ msgid "" "order Picking), the zero check step will be activated when a location " "becomes empty after a move." msgstr "" +"Para los escenarios del Taller que lo utilizan (Selección de grupos, " +"Selección de zonas, Selección de pedidos discretos), el paso de verificación " +"cero se activará cuando una ubicación quede vacía después de un movimiento." #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_picking__shopfloor_display_packing_info @@ -275,31 +282,33 @@ msgid "" "For the Shopfloor Checkout/Packing scenarios to display the customer packing" " info." msgstr "" +"Para que los escenarios de Checkout/Empaquetado del Taller muestren la " +"información de empaquetado del cliente." #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__location_id msgid "From" -msgstr "" +msgstr "Desde" #. module: shopfloor #: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__functional msgid "Functional" -msgstr "" +msgstr "Funcional" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Functional errors" -msgstr "" +msgstr "Errores funcionales" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Group By" -msgstr "" +msgstr "Agrupar por" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__headers msgid "Headers" -msgstr "" +msgstr "Cabeceras" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__id @@ -307,7 +316,7 @@ msgstr "" #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__id msgid "ID" -msgstr "" +msgstr "ID" #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__ignore_no_putaway_available @@ -316,6 +325,9 @@ msgid "" " a sublocation (when putaway destination is different from the operation " "type's destination)." msgstr "" +"Si marca esta casilla, la transferencia se reserva solo si la ubicación " +"puede encontrar una sububicación (cuando el destino de la ubicación es " +"diferente del destino del tipo de operación)." #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves @@ -323,32 +335,34 @@ msgid "" "If you tick this box, this scenario will allow operator to move goods even " "if a reservation is made by a different operation type." msgstr "" +"Si marca esta casilla, este escenario permitirá al operador mover mercancías " +"incluso si se realiza una reserva mediante un tipo de operación diferente." #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available_is_possible msgid "Ignore No Putaway Available Is Possible" -msgstr "" +msgstr "Ignorar que No Hay Almacenamiento Disponible Es Posible" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available msgid "Ignore transfers when no put-away is available" -msgstr "" +msgstr "Ignora las transferencias cuando no haya disponibilidad de ubicación" #. module: shopfloor #: code:addons/shopfloor/models/shopfloor_menu.py:0 #, python-format msgid "Ignoring not found putaway is not allowed for menu {}." -msgstr "" +msgstr "No se permite ignorar el almacenamiento no encontrado para el menú {}." #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_inventory msgid "Inventory" -msgstr "" +msgstr "Inventario" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_location msgid "Inventory Locations" -msgstr "" +msgstr "Ubicaciones de Inventario" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log____last_update @@ -356,21 +370,21 @@ msgstr "" #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin____last_update #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile____last_update msgid "Last Modified on" -msgstr "" +msgstr "Última Modificación el" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_uid #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__write_uid #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__write_uid msgid "Last Updated by" -msgstr "" +msgstr "Última Actualización por" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_date #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__write_date #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__write_date msgid "Last Updated on" -msgstr "" +msgstr "Última Actualización el" #. module: shopfloor #: code:addons/shopfloor/actions/completion_info.py:0 @@ -378,219 +392,226 @@ msgstr "" msgid "" "Last operation of transfer {}. Next operation ({}) is ready to proceed." msgstr "" +"Última operación de transferencia: {}. Siguiente operación ({}) está lista " +"para proceder." #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Line cancelled" -msgstr "" +msgstr "Línea cancelada" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Lines have different destination location." -msgstr "" +msgstr "La líneas tiene diferente ubicación de destino." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Location %s doesn't contain any package." -msgstr "" +msgstr "La Ubicación %s no contiene ningún paquete." #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_location_content_transfer #: model:stock.picking.type,name:shopfloor.picking_type_location_content_transfer_demo msgid "Location Content Transfer" -msgstr "" +msgstr "Transferencia de Contenido de Ubicación" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Location not allowed here." -msgstr "" +msgstr "La Ubicación no está permitida aquí." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Location {} empty" -msgstr "" +msgstr "Ubicación {} vacía" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Logs generated today" -msgstr "" +msgstr "Registros generados hoy" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Lot is not in the current transfer." -msgstr "" +msgstr "El Lote no está en la transferencia actual." #. module: shopfloor #: code:addons/shopfloor/services/delivery.py:0 #, python-format msgid "Lot {} belongs to a picking without a valid state." -msgstr "" +msgstr "El Lote {} pertenece a un picking sin estado válido." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Lot {} is for another product." -msgstr "" +msgstr "El Lote {} es para otro producto." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Lot {} replaced by lot {}." -msgstr "" +msgstr "Lote {} reemplazado por lote {}." #. module: shopfloor #: code:addons/shopfloor/actions/inventory.py:0 #, python-format msgid "Lot: " -msgstr "" +msgstr "Lote: " #. module: shopfloor #: model:ir.model,name:shopfloor.model_shopfloor_menu msgid "Menu displayed in the scanner application" -msgstr "" +msgstr "Menú mostrado en la aplicación de escaner" #. module: shopfloor #: model:ir.actions.act_window,name:shopfloor.action_shopfloor_menu #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__menu_ids #: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_menu msgid "Menus" -msgstr "" +msgstr "Menús" #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_shopfloor_profile__menu_ids msgid "Menus visible for this profile" -msgstr "" +msgstr "Menús visibles para este perfil" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__move_create_is_possible msgid "Move Create Is Possible" -msgstr "" +msgstr "Crear Movimiento es Posible" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__move_line_ids msgid "Move Line" -msgstr "" +msgstr "Línea de Movimiento" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_picking__move_line_count #: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__move_line_count msgid "Move Line Count" -msgstr "" +msgstr "Cuenta de Línea de Movimiento" #. module: shopfloor #: code:addons/shopfloor/models/stock_move_line.py:0 #, python-format msgid "Move lines processed have to share the same source location." msgstr "" +"Movimiento de líneas procesadas tienen que compartir la misma ubicación." #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__name #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__name msgid "Name" -msgstr "" +msgstr "Nombre" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Negative quantity not allowed." -msgstr "" +msgstr "Cantidad negativa no permitida." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "New move lines cannot be assigned: canceled." -msgstr "" +msgstr "Los nuevos movimientos de líneas no puede ser asignados: cancelados." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No lines to process." -msgstr "" +msgstr "No hay líneas para procesar." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No location found for this barcode." -msgstr "" +msgstr "No se encontró ubicación para este código de barras." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No lot found among current transfers." -msgstr "" +msgstr "No se encontró lote perteneciente a transferencias actuales." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No lot found for {}" -msgstr "" +msgstr "No se encontró lote para {}" #. module: shopfloor #: code:addons/shopfloor/services/cluster_picking.py:0 #, python-format msgid "No more work to do, please create a new batch transfer" -msgstr "" +msgstr "No más trabajo por hacer, cree una nueva transferencia por lotes" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No operation type found for this menu and profile." -msgstr "" +msgstr "No se ha encontrado ningún tipo de operación para este menú y perfil." #. module: shopfloor #: code:addons/shopfloor/services/service.py:0 #, python-format msgid "No operation types configured on menu {} for warehouse {}." msgstr "" +"No hay tipos de operación configurados en el menú {} para el almacén {}." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No package or lot found for barcode {}." -msgstr "" +msgstr "No hay paquete o lote encontrado para el código de barras {}." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No pending operation for package %s." -msgstr "" +msgstr "No hay operación pendiente para el paquete %s." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No product found among current transfers." msgstr "" +"No se ha encontrado ningún producto perteneciente a la transferencia actual." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No putaway destination is available." -msgstr "" +msgstr "No hay ningún destino de almacenamiento disponible." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No quantity has been processed, unable to complete the transfer." msgstr "" +"No se ha procesado ninguna cantidad, no se ha podido completar la " +"transferencia." #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "No valid package to select." -msgstr "" +msgstr "No hay paquete válido para seleccionar." #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Not a valid destination package" -msgstr "" +msgstr "No es un paquete de destino válido" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 @@ -599,6 +620,8 @@ msgid "" "Not all lines have been processed with full quantity. Do you confirm partial" " operation?" msgstr "" +"No todas las líneas se han procesado con la cantidad completa. ¿Confirma " +"operación parcial?" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 @@ -607,65 +630,68 @@ msgid "" "Not allowed to pack more than the quantity, the value has been changed to " "the maximum." msgstr "" +"No se permite empacar más de la cantidad, el valor se ha cambiado al máximo." #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__picking_type_ids msgid "Operation Types" -msgstr "" +msgstr "Tipos de Operación" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Operation already processed." -msgstr "" +msgstr "Operación ya procesada." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Operation's already running. Would you like to take it over?" -msgstr "" +msgstr "La operación ya está en marcha. ¿Le gustaría hacerse cargo?" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Package cancelled" -msgstr "" +msgstr "Paquete cancelado" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Package has been opened. You can move partial quantities." -msgstr "" +msgstr "El paquete ha sido abierto. Puede mover cantidades parciales." #. module: shopfloor #: code:addons/shopfloor/services/location_content_transfer.py:0 #, python-format msgid "Package level has to be in draft" -msgstr "" +msgstr "El paquete tiene que estar en Borrador" #. module: shopfloor #: code:addons/shopfloor/models/stock_quant_package.py:0 #, python-format msgid "Package name must be unique!" -msgstr "" +msgstr "¡El nombre del Paquete debe ser único!" #. module: shopfloor #: code:addons/shopfloor/services/delivery.py:0 #, python-format msgid "Package {} belongs to a picking without a valid state." -msgstr "" +msgstr "El Paquete {} pertenece a una entrega sin un estado válido." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Package {} cannot be picked, already moved by transfer {}." msgstr "" +"El Paquete {} no puede ser seleccionado, ya ha sido movido por la " +"transferencia {}." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Package {} cannot be used: {} " -msgstr "" +msgstr "El Paquete {} no puede ser usado: {} " #. module: shopfloor #: code:addons/shopfloor/models/stock_move_line.py:0 @@ -673,160 +699,164 @@ msgstr "" msgid "" "Package {} does not contain available product {}, cannot replace package." msgstr "" +"El Paquete {} no contiene un producto disponible {}, no se puede reemplazar " +"el paquete." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Package {} has a different content." -msgstr "" +msgstr "El Paquete {} tiene diferente contenido." #. module: shopfloor #: code:addons/shopfloor/models/stock_move_line.py:0 #, python-format msgid "Package {} has been partially picked in another location" -msgstr "" +msgstr "El Paquete {} ha sido parcialmente entregado en otra ubicación" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Package {} is already used." -msgstr "" +msgstr "El Paquete {} está usado." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Package {} is not available in transfer {}." -msgstr "" +msgstr "El Paquete {} no está disponible en la transferencia {}." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Package {} is not empty." -msgstr "" +msgstr "El Paquete {} no está vacío." #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Package {} is not in the current transfer." -msgstr "" +msgstr "El Paquete {} no está en la transferencia actual." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Package {} replaced by package {}." -msgstr "" +msgstr "El Paquete {} está reemplazado por el paquete {}." #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_quant_package msgid "Packages" -msgstr "" +msgstr "Paquetes" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Packaging changed on package {}" -msgstr "" +msgstr "El Empaquetado cambió en el paquete {}" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_picking__shopfloor_packing_info msgid "Packing information" -msgstr "" +msgstr "Información del empaquetado" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view msgid "Parameters" -msgstr "" +msgstr "Parámetros" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__params msgid "Params" -msgstr "" +msgstr "Parámetros" #. module: shopfloor #: code:addons/shopfloor/actions/change_package_lot.py:0 #, python-format msgid "Pick: stock issue on lot: {} found in {}" -msgstr "" +msgstr "Entrega: error de inventario en el lote: {} encontrado en {}" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__picking_count msgid "Picking Count" -msgstr "" +msgstr "Cuenta de Entrega" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_picking_type msgid "Picking Type" -msgstr "" +msgstr "Tipo de Entrega" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Picking has already been started in this location in transfer(s): {}" msgstr "" +"La Entrega ya ha sido iniciada en esta ubicación en la(s) transferencia(s): " +"{}" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Picking type {} complete." -msgstr "" +msgstr "Tipo de Entrega {} completo." #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__planned_move_line_ids msgid "Planned Move Line" -msgstr "" +msgstr "Línea de Movimiento Planificada" #. module: shopfloor #: code:addons/shopfloor/models/shopfloor_menu.py:0 #, python-format msgid "Processing reserved quantities is not allowed for menu {}." -msgstr "" +msgstr "Procesar cantidades reservadas no está permitido para el menú {}." #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_move_line msgid "Product Moves (Stock Move Line)" -msgstr "" +msgstr "Movimientos de Producto (Stock Move Line)" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Product is not in the current transfer." -msgstr "" +msgstr "El Producto no está en la transferencia actual." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Product tracked by lot, please scan one." -msgstr "" +msgstr "Producto rastreado por lote, por favor escanée uno." #. module: shopfloor #: code:addons/shopfloor/services/delivery.py:0 #, python-format msgid "Product {} belongs to a picking without a valid state." -msgstr "" +msgstr "Producto {} pertenece a una entrega sin estado válido." #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Product(s) packed in {}" -msgstr "" +msgstr "Producto(s) empaquetado(s) en {}" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Product(s) processed as raw product(s)" -msgstr "" +msgstr "Producto(s) procesado(s) como producto(s) crudo(s)" #. module: shopfloor #: model:ir.actions.act_window,name:shopfloor.action_shopfloor_profile #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__profile_ids #: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_profile msgid "Profiles" -msgstr "" +msgstr "Perfiles" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_quant msgid "Quants" -msgstr "" +msgstr "Cantidades" #. module: shopfloor #: code:addons/shopfloor/services/scan_anything.py:0 @@ -835,85 +865,88 @@ msgid "" "Record not found.\n" "We've tried with the following types: {}" msgstr "" +"Registro no encontrado.\n" +"Hemos tratado con los siguientes tipos: {}" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Recovered previous session." -msgstr "" +msgstr "Sesión anterior recuperada." #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Remaining raw product not packed, proceed anyway?" msgstr "" +"El producto crudo restante no está empaquetado, ¿continuar de todos modos?" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__request_method msgid "Request Method" -msgstr "" +msgstr "Método del Request" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__request_url #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Request URL" -msgstr "" +msgstr "URL del Request" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_location__reserved_move_line_ids #: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__reserved_move_line_ids msgid "Reserved Move Line" -msgstr "" +msgstr "Movimiento de Línea Reservado" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Restart the operation, someone has canceled it." -msgstr "" +msgstr "Reinicie la operación, alguien la ha cancelado." #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__result #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view msgid "Result" -msgstr "" +msgstr "Resultado" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree msgid "SF Priority" -msgstr "" +msgstr "Prioridad del Taller" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree msgid "SF checkout done" -msgstr "" +msgstr "Checkout del Taller Hecho" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree msgid "SF unloaded" -msgstr "" +msgstr "Taller Descargado" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Scan the destination location" -msgstr "" +msgstr "Escanear la ubicación de destino" #. module: shopfloor #: code:addons/shopfloor/services/location_content_transfer.py:0 #: code:addons/shopfloor/services/location_content_transfer.py:0 #, python-format msgid "Scan the package" -msgstr "" +msgstr "Escanear el paquete" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scenario msgid "Scenario" -msgstr "" +msgstr "Escenario" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_form_view msgid "Scenario Options" -msgstr "" +msgstr "Opciones de Escenario" #. module: shopfloor #: code:addons/shopfloor/models/shopfloor_menu.py:0 @@ -924,35 +957,39 @@ msgid "" "{}.\n" "Please, adjust your configuration." msgstr "" +"El escenario `{}` requiere que se habilite 'Mover paquetes completos'.\n" +"Estos tipos no satisfacen esta restricción:\n" +"{}.\n" +"Por favor, ajuste su configuración." #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__sequence msgid "Sequence" -msgstr "" +msgstr "Secuencia" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Several lots found in %s, please scan a lot." -msgstr "" +msgstr "Se han encontrado varios lotes en %s, escanee mucho." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Several operation types found for this menu and profile." -msgstr "" +msgstr "Se han encontrado varios tipos de operaciones para este menú y perfil." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Several packages found in %s, please scan a package." -msgstr "" +msgstr "Se han encontrado varios paquetes en %s, escanee un paquete." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Several products found in %s, please scan a product." -msgstr "" +msgstr "Se han encontrado varios productos en %s, escanee un producto." #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 @@ -961,22 +998,24 @@ msgid "" "Several transfers found, please scan a package or select a transfer " "manually." msgstr "" +"Se han encontrado varias transferencias, escanee un paquete o seleccione una " +"transferencia manualmente." #. module: shopfloor #: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__severe msgid "Severe" -msgstr "" +msgstr "Severo" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Severe errors" -msgstr "" +msgstr "Errores severos" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__severity #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Severity" -msgstr "" +msgstr "Severidad" #. module: shopfloor #: model:ir.ui.menu,name:shopfloor.menu_shopfloor_settings @@ -984,68 +1023,68 @@ msgstr "" #: model_terms:ir.ui.view,arch_db:shopfloor.view_location_form #: model_terms:ir.ui.view,arch_db:shopfloor.view_picking_type_form msgid "Shopfloor" -msgstr "" +msgstr "Taller" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_checkout_done msgid "Shopfloor Checkout Done" -msgstr "" +msgstr "Checkout del Taller Hecho" #. module: shopfloor #: model:ir.model,name:shopfloor.model_shopfloor_log msgid "Shopfloor Logging" -msgstr "" +msgstr "Registro del Taller" #. module: shopfloor #: model:ir.actions.act_window,name:shopfloor.action_shopfloor_log #: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_log msgid "Shopfloor Logs" -msgstr "" +msgstr "Registros del Taller" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_menu_ids msgid "Shopfloor Menus" -msgstr "" +msgstr "Menús del Taller" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_location__shopfloor_picking_sequence msgid "Shopfloor Picking Sequence" -msgstr "" +msgstr "Secuencia del Picking del Taller" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_postponed #: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_postponed msgid "Shopfloor Postponed" -msgstr "" +msgstr "Taller Pospuesto" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_priority #: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_priority msgid "Shopfloor Priority" -msgstr "" +msgstr "Prioridad del Taller" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_unloaded msgid "Shopfloor Unloaded" -msgstr "" +msgstr "Taller Descargado" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_user_id msgid "Shopfloor User" -msgstr "" +msgstr "Usuario del Taller" #. module: shopfloor #: model:ir.model,name:shopfloor.model_shopfloor_profile msgid "Shopfloor profile settings" -msgstr "" +msgstr "Ajustes del perfil de taller" #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_single_pallet_transfer #: model:stock.picking.type,name:shopfloor.picking_type_single_pallet_transfer_demo msgid "Single Pallet Transfer" -msgstr "" +msgstr "Transferencia de un solo Palet" #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_move_create @@ -1054,46 +1093,50 @@ msgid "" " move already exists. Any new move is created in the selected operation " "type, so it can be active only when one type is selected." msgstr "" +"Algunos escenarios pueden crear movimientos cuando se escanea un producto o " +"paquete cuando no existe ningún movimiento. Cualquier movimiento nuevo se " +"crea en el tipo de operación seleccionado, por lo que solo puede estar " +"activo cuando se selecciona un tipo." #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_location__source_move_line_ids msgid "Source Move Line" -msgstr "" +msgstr "Recurso del Movimiento de Línea" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__package_id msgid "Source Package" -msgstr "" +msgstr "Recurso del paquete" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__state msgid "State" -msgstr "" +msgstr "Estado" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Status" -msgstr "" +msgstr "Estado" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_move msgid "Stock Move" -msgstr "" +msgstr "Movimiento de Inventario" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_package_level msgid "Stock Package Level" -msgstr "" +msgstr "Nivel de Paquete de Existencias" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__picking_id msgid "Stock Picking" -msgstr "" +msgstr "Inventario de la Entrega" #. module: shopfloor #: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__state__success msgid "Success" -msgstr "" +msgstr "Satisfactorio" #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed @@ -1103,33 +1146,35 @@ msgid "" "Technical field. Indicates if the operation has been postponed in a barcode " "scenario." msgstr "" +"Campo técnico. Indica si la operación se ha pospuesto en un escenario de " +"código de barras." #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_picking__move_line_count #: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__move_line_count msgid "Technical field. Indicates number of move lines included." -msgstr "" +msgstr "Campo técnico. Indica el número de líneas de movimiento incluidas." #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__picking_count msgid "Technical field. Indicates number of transfers included." -msgstr "" +msgstr "Campo técnico. Indica el número de transferencias incluidas." #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_picking__total_weight #: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__total_weight msgid "Technical field. Indicates total weight of transfers included." -msgstr "" +msgstr "Campo técnico. Indica el peso total de las transferencias incluidas." #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_quant_package__planned_move_line_ids msgid "Technical field. Move lines for which destination is this package." -msgstr "" +msgstr "Campo técnico. Mueva las líneas para qué destino es este paquete." #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_quant_package__move_line_ids msgid "Technical field. Move lines moving this package." -msgstr "" +msgstr "Campo técnico. Mueva líneas moviendo este paquete." #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority @@ -1137,6 +1182,8 @@ msgstr "" #: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_priority msgid "Technical field. Overrides operation priority in barcode scenario." msgstr "" +"Campo técnico. Anula la prioridad de operación en el escenario del código de " +"barras." #. module: shopfloor #: code:addons/shopfloor/models/stock_move_line.py:0 @@ -1145,24 +1192,26 @@ msgid "" "The backorder %s has been created." msgstr "" +"Se ha creado el pedido pendiente %s." #. module: shopfloor #: code:addons/shopfloor/services/cluster_picking.py:0 #, python-format msgid "The destination bin {} is not empty, please take another." -msgstr "" +msgstr "El contenedor de destino {} no está vacío, tome otro." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "The pack has been moved, you can scan a new pack." -msgstr "" +msgstr "El paquete se ha movido, puede escanear un paquete nuevo." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "The package %s doesn't exist" -msgstr "" +msgstr "El paquete %s no existe" #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_location__shopfloor_picking_sequence @@ -1173,59 +1222,64 @@ msgid "" " It is recommended to use an Export then an Import to populate this field " "using a spreadsheet." msgstr "" +"La entrega realizada en los escenarios del Taller respetará este orden. La " +"secuencia es un char, por lo que puede estar compuesta por campos como " +"'corredor-rack-side-level'. Preste atención al relleno ('09' es antes de " +"'19', '9' no). Se recomienda usar Exportar y luego Importar para completar " +"este campo usando una hoja de cálculo." #. module: shopfloor #: code:addons/shopfloor/services/service.py:0 #, python-format msgid "The record %s %s does not exist" -msgstr "" +msgstr "El registro %s %s no existe" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "The record you were working on does not exist anymore." -msgstr "" +msgstr "El registro en el que estaba trabajando ya no existe." #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_move_line__picking_id msgid "The stock operation where the packing has been made" -msgstr "" +msgstr "La operación de inventario donde se ha realizado el embalaje" #. module: shopfloor #: code:addons/shopfloor/services/cluster_picking.py:0 #, python-format msgid "This batch cannot be selected." -msgstr "" +msgstr "Este lote no se puede seleccionar." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "This line has a package, please select the package instead." -msgstr "" +msgstr "Esta línea tiene un paquete, seleccione el paquete en su lugar." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "This line is not available in transfer {}." -msgstr "" +msgstr "Esta línea no está disponible en la transferencia {}." #. module: shopfloor #: code:addons/shopfloor/services/location_content_transfer.py:0 #, python-format msgid "This location content can't be moved at once." -msgstr "" +msgstr "El contenido de esta ubicación no se puede mover a la vez." #. module: shopfloor #: code:addons/shopfloor/services/location_content_transfer.py:0 #, python-format msgid "This location content can't be moved using this menu." -msgstr "" +msgstr "El contenido de esta ubicación no se puede mover usando este menú." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "This lot does not exist anymore." -msgstr "" +msgstr "Este lote ya no existe." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 @@ -1233,30 +1287,31 @@ msgstr "" msgid "" "This lot is part of a package with other products, please scan a package." msgstr "" +"Este lote es parte de un paquete con otros productos, escanee un paquete." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "This lot is part of multiple packages, please scan a package." -msgstr "" +msgstr "Este lote es parte de varios paquetes, escanee un paquete." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "This operation does not exist anymore." -msgstr "" +msgstr "Esta operación ya no existe." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "This package does not exist anymore." -msgstr "" +msgstr "Este paquete ya no existe." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "This product does not exist anymore." -msgstr "" +msgstr "Este producto ya no existe." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 @@ -1265,142 +1320,143 @@ msgid "" "This product is part of a package with other products, please scan a " "package." msgstr "" +"Este producto es parte de un paquete con otros productos, escanee un paquete." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "This product is part of multiple packages, please scan a package." -msgstr "" +msgstr "Este producto es parte de varios paquetes, escanee un paquete." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "This transfer does not exist or is not available anymore." -msgstr "" +msgstr "Esta transferencia no existe o ya no está disponible." #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Today" -msgstr "" +msgstr "Hoy" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_picking__total_weight #: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__total_weight msgid "Total Weight" -msgstr "" +msgstr "Peso Total" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_picking msgid "Transfer" -msgstr "" +msgstr "Transferencia" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Transfer {} complete" -msgstr "" +msgstr "Transferencia {} completa" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Transfer {} done" -msgstr "" +msgstr "Transferencia {} realizada" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Transfer {} is not available." -msgstr "" +msgstr "Transferencia {} no está disponible." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Units replaced by package {}." -msgstr "" +msgstr "Unidades reemplazadas por paquete {}." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Unrecoverable error, please restart." -msgstr "" +msgstr "Error irrecuperable, por favor reinicie." #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unreserve_other_moves_is_possible msgid "Unreserve Other Moves Is Possible" -msgstr "" +msgstr "Es posible Anular la Reserva de Otros Movimientos" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "User" -msgstr "" +msgstr "Usuario" #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__profile_ids msgid "Visible for these profiles" -msgstr "" +msgstr "Visible para estos perfiles" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__warehouse_id msgid "Warehouse" -msgstr "" +msgstr "Almacén" #. module: shopfloor #: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__warning msgid "Warning" -msgstr "" +msgstr "Advertencia" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Warning errors" -msgstr "" +msgstr "Errores de advertencia" #. module: shopfloor #: code:addons/shopfloor/services/cluster_picking.py:0 #, python-format msgid "Wrong bin" -msgstr "" +msgstr "Compartimento incorrecto" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "You cannot move this using this menu." -msgstr "" +msgstr "No puede mover esto usando este menú." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "You cannot place it here" -msgstr "" +msgstr "No puede colocarlo aquí" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "You cannot work on a package (%s) outside of locations: %s" -msgstr "" +msgstr "No puede trabajar en el paquete (%s) fuera de la ubicación: %s" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "You must not pick more than {} units." -msgstr "" +msgstr "No debe seleccionar más de {} unidades." #. module: shopfloor #: code:addons/shopfloor/actions/inventory.py:0 #, python-format msgid "Zero check issue on location {}" -msgstr "" +msgstr "Error de verificación cero en la ubicación {}" #. module: shopfloor #: code:addons/shopfloor/actions/inventory.py:0 #, python-format msgid "Zero check issue on location {} ({})" -msgstr "" +msgstr "Error de verificación cero en la ubicación {} ({})" #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_zone_picking #: model:stock.picking.type,name:shopfloor.picking_type_zone_picking_demo msgid "Zone Picking" -msgstr "" +msgstr "Zona de Entreda" #. module: shopfloor #: code:addons/shopfloor/actions/inventory.py:0 @@ -1409,9 +1465,11 @@ msgid "" "{picking.name} stock correction in location {location.name} for " "{product_desc}" msgstr "" +"{picking.name} corrección de inventario en la ubicación {location.name} para " +"{product_desc}" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "{} {} put in {}" -msgstr "" +msgstr "{} {} poner en {}" From bae418b0cabdfdcca94c174e23cef19ee964eb39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 15 Dec 2020 17:56:25 +0100 Subject: [PATCH 473/940] [FIX] shopfloor: cluster picking, remove unavailable picking from validated batch Unavailable transfers could be part of a batch (following a stock inventory happening at the same time for instance), as such we have to remove such transfers from the batch picking when this one is validated. The transfers will be assigned to a new batch thereafter. --- shopfloor/services/cluster_picking.py | 11 ++++++ .../tests/test_cluster_picking_unload.py | 39 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index e5db7fd007..1697e43ec7 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -958,6 +958,11 @@ def _unload_write_destination_on_lines(self, lines, location): picking.action_done() def _unload_end(self, batch, completion_info_popup=None): + """Try to close the batch if all transfers are done. + + Returns to `start_line` transition if some lines could still be processed, + otherwise try to validate all the transfers of the batch. + """ if all(picking.state == "done" for picking in batch.picking_ids): # do not use the 'done()' method because it does many things we # don't care about @@ -980,6 +985,12 @@ def _unload_end(self, batch, completion_info_popup=None): # produce backorders) batch.mapped("picking_ids").action_done() batch.state = "done" + # Unassign not validated pickings from the batch, they will be + # processed in another batch automatically later on + pickings_not_done = batch.mapped("picking_ids").filtered( + lambda p: p.state != "done" + ) + pickings_not_done.batch_id = False return self._response_for_start( message=self.msg_store.batch_transfer_complete(), popup=completion_info_popup, diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index b32dcf1938..d1b3de47e1 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -239,6 +239,45 @@ def test_set_destination_all_remaining_lines(self): message={"body": "Batch Transfer line done", "message_type": "success"}, ) + def test_set_destination_all_picking_unassigned(self): + """Set destination on lines for some transfers of the batch. + + The remaining transfers stay as unavailable (confirmed) and are removed + from the batch when this one is validated. + The remaining transfers will be processed later in a new batch. + """ + self.batch.picking_ids.do_unreserve() + location = self.one_line_picking.location_id + product = self.one_line_picking.move_lines.product_id + qty = self.one_line_picking.move_lines.product_uom_qty + self._update_qty_in_location(location, product, qty) + self.one_line_picking.action_assign() + # Prepare lines to process + lines = self.one_line_picking.move_line_ids + self._set_dest_package_and_done(lines, self.bin1) + lines.write({"location_dest_id": self.packing_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_location.barcode, + }, + ) + # The batch should be done with only one picking. + # The remaining picking has been removed from the current batch + self.assertRecordValues(self.one_line_picking, [{"state": "done"}]) + self.assertRecordValues(self.two_lines_picking, [{"state": "confirmed"}]) + self.assertRecordValues(self.batch, [{"state": "done"}]) + self.assertEqual(self.one_line_picking.batch_id, self.batch) + self.assertFalse(self.two_lines_picking.batch_id) + + self.assert_response( + response, + next_state="start", + message=self.service.msg_store.batch_transfer_complete(), + ) + def test_set_destination_all_but_different_dest(self): """Endpoint was called but destinations are different""" move_lines = self.move_lines From 5b2abb3c12f67bca5073d03bff1394f59ec52a81 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 13 Jan 2021 09:10:50 +0000 Subject: [PATCH 474/940] shopfloor 13.0.1.5.2 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index a519a8cbc9..9ca78bd736 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "13.0.1.5.1", + "version": "13.0.1.5.2", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 89aaf21fc978b619616f17d29c6ae45851f61ef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 27 Nov 2020 09:44:37 +0100 Subject: [PATCH 475/940] [FIX] shopfloor, checkout: ability to recover/restart If the user starts to process a line, and for whatever reason he stops there and restarts the scenario from the beginning, he should still be able to find/recover the previous line. For that purpose we are using the `shopfloor_user_id` field on move lines (already used to manage currently processed lines in the Zone picking and Location content transfer scenarios). --- shopfloor/services/checkout.py | 9 ++++++-- shopfloor/tests/test_checkout_scan.py | 31 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 7a156ec2d7..4556735223 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -296,13 +296,16 @@ def _select_lines(self, lines): if line.shopfloor_checkout_done: continue line.qty_done = line.product_uom_qty + line.shopfloor_user_id = self.env.user picking = lines.mapped("picking_id") other_lines = picking.move_line_ids - lines self._deselect_lines(other_lines) def _deselect_lines(self, lines): - lines.filtered(lambda l: not l.shopfloor_checkout_done).qty_done = 0 + lines.filtered(lambda l: not l.shopfloor_checkout_done).write( + {"qty_done": 0, "shopfloor_user_id": False} + ) def scan_line(self, picking_id, barcode): """Scan move lines of the stock picking @@ -600,7 +603,9 @@ def _switch_line_qty_done(self, picking, selected_lines, switch_lines): @staticmethod def _filter_lines_unpacked(move_line): - return move_line.qty_done == 0 and not move_line.shopfloor_checkout_done + return ( + move_line.qty_done == 0 or move_line.shopfloor_user_id + ) and not move_line.shopfloor_checkout_done @staticmethod def _filter_lines_to_pack(move_line): diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py index 1af8473da2..6cc8c5708c 100644 --- a/shopfloor/tests/test_checkout_scan.py +++ b/shopfloor/tests/test_checkout_scan.py @@ -128,3 +128,34 @@ def test_scan_document_error_location_several_pickings(self): " or select a transfer manually.", }, ) + + def test_scan_document_recover(self): + """If the user starts to process a line, and for whatever reason he + stops there and restarts the scenario from the beginning, he should + still be able to find the previous line. + """ + picking = self._create_picking() + self._fill_stock_for_moves(picking.move_lines, in_package=True) + picking.action_assign() + package = picking.move_line_ids.package_id + # The user selects a line, then stops working in the middle of the process + response = self.service.dispatch( + "scan_document", params={"barcode": package.name} + ) + data = response["data"]["select_line"] + self.assertEqual(data["picking"]["move_line_count"], 2) + self.assertEqual(len(data["picking"]["move_lines"]), 2) + self.assertFalse(picking.move_line_ids.shopfloor_user_id) + response = self.service.dispatch( + "select_line", params={"picking_id": picking.id, "package_id": package.id}, + ) + self.assertTrue(all(l.qty_done for l in picking.move_line_ids)) + self.assertEqual(picking.move_line_ids.shopfloor_user_id, self.env.user) + # He restarts the scenario and try to select again the previous line + # to continue its job + response = self.service.dispatch( + "scan_document", params={"barcode": package.name} + ) + data = response["data"]["select_line"] + self.assertEqual(data["picking"]["move_line_count"], 2) + self.assertEqual(len(data["picking"]["move_lines"]), 2) # Lines found From c9eba21309abe9a131036f26f0e1b88cee42f0e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 29 Sep 2020 16:36:41 +0200 Subject: [PATCH 476/940] [DOC] shopfloor: zone_picking sequence diagram --- shopfloor/docs/oca_logo.png | Bin 0 -> 3297 bytes shopfloor/docs/zone_picking_diag_seq.png | Bin 0 -> 171913 bytes shopfloor/docs/zone_picking_diag_seq.txt | 84 +++++++++++++++++++++++ shopfloor/services/zone_picking.py | 7 +- 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 shopfloor/docs/oca_logo.png create mode 100644 shopfloor/docs/zone_picking_diag_seq.png create mode 100644 shopfloor/docs/zone_picking_diag_seq.txt diff --git a/shopfloor/docs/oca_logo.png b/shopfloor/docs/oca_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..84f216c2941a7fd6c70bdb3c951539fd80258409 GIT binary patch literal 3297 zcmV<73?B1|P)%z00006VoOIv0RI60 z0RN!9r;`8x010qNS#tmY3ljhU3ljkVnw%H_000McNliruD}6aw7|dN=?803B&m zSad^gZEa<4bN~PV002XBWnpw>WFU8GbZ8()Nlj2>E@cM*01QP*L_t(|+U=Wra8%VD z$3JJ2@J2yuQ7fRfRVzAHE3LL_wGNt93y8LYxS`q#sJzZeZoZWe$rvF&bU!0CF-SAhHV z{oT|>>I+1GSAoZY2}yzt08Ywl5wJDsdjqfvr~;k_dUT7lYxH^=p}z_o3;ZqRdK`Eg z*aF0WHhujQJ=aCRb3m{EML{eKg%0~3tgZd8uVHeEz}vK-V?YzILw|17zcm2206l?8 zz((L>eeaoi&I>i5eT%ioa5zjj9PV`8ibT3JuW4DGuFWwF!r?G8YHHH1qbp807kUmj zFKPF`2|NRA{N{jRue`Ts>w#XSJ!{H*WuKjS^eNju*}r|*mOURW@7JTxbxp0!Ym(-< z1lR?XiD0saU~@HwU$Md{#Jh*Gri=Vt+bIviRA5rEj9qKsv_OyIK%!yMXd|&K4)b!er{>R?q;H(Q= zZ#0Vu!T~4|CB&ULT8mn;Ex@X)L(ZH@*fbGBlsS&mUkGs(a2aq8aAL|W?*h*P58yb@ zBSc(D*#-~_g)j`mThlCyN-ap!vJxPS0LB0p0B59V_7~cuZvfT++u9&jC8cAUCQ7NI z1JX|G$1X3~(Y$+*fLRH%B!s|moK6!vfI#5jput?=IL_Tdh=T#!a85dM2wV1kl#l5s z1冭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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 0000000000..1884644dcd --- /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 cb01aa3168..74d6eb9878 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 26733bb3071c727ab3bddc1b42eedda5d12c498d 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 477/940] [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 0000000000..0747eb01d2 --- /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 f8aea169ea..8390a963cc 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 e66dc40785594716633dabf55ce87b5236a6c6c7 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 13 Jan 2021 11:34:51 +0100 Subject: [PATCH 478/940] 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 26edc2cfb0..ecbc8f21c0 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 634264dddb..ceed40c9ea 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 f9b4f514be..c85d6e9cd1 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 fa873cb42c..384d888893 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 32de47c631c2af4f6efb2a925db1f6bb20c62110 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 13 Jan 2021 11:16:15 +0000 Subject: [PATCH 479/940] 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 9ca78bd736..b098d2d03b 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 528418b289dd9a9525fb535691d0b05b3f8f6666 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 10 Dec 2020 16:23:41 +0100 Subject: [PATCH 480/940] 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 74d6eb9878..2b2376a59d 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 7804aa2900cd0bb1e7e53526496864db82dd9f5e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 10 Dec 2020 16:27:08 +0100 Subject: [PATCH 481/940] 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 a5e6c924af..38abf19504 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 35a0ab8e73..afb51be093 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 ebfed4006ef7a5442b9def4bb898a46d0a98c656 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 10 Dec 2020 18:19:22 +0100 Subject: [PATCH 482/940] 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 2b2376a59d..99b244e8dd 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 ed603bf201a77fcfc3a0eef23b211dac43643372 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 18 Dec 2020 11:05:11 +0100 Subject: [PATCH 483/940] 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 5fcd3b0fcc..19ce448c8e 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -404,12 +404,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)) ) @@ -430,16 +434,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 a0c01163b553e76cb9a4cf9efef41028401771ce Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 13 Jan 2021 13:48:36 +0000 Subject: [PATCH 484/940] 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 b098d2d03b..841cc196e9 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 2e331c63d564cd40594c10abf1dab7a1bec532f5 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 14 Jan 2021 10:19:53 +0000 Subject: [PATCH 485/940] 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 841cc196e9..63ac8224e5 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 e2c1db5d316a296f18987a28faecbfde4f31b120 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 14 Jan 2021 13:37:52 +0000 Subject: [PATCH 486/940] 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 63ac8224e5..a64a5924a1 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 60217231a55dbff2bf2cb662f5975ccadebe30c1 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 20 Jan 2021 13:12:49 +0000 Subject: [PATCH 487/940] 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 a64a5924a1..26c9d012eb 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 ff7e9fe9845d73aea46e198db7309378c653bcf7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 13 Jan 2021 13:54:25 +0100 Subject: [PATCH 488/940] 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 0275b3e335..5e3b36a287 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 1a4809fa37..1d6a6575eb 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 2d490b3b3f..029affc881 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 02a6a90ede..d910a2f311 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 55d2297937..6724ecaca1 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 19ce448c8e..0772c6f0ad 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -483,14 +483,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): @@ -498,8 +492,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 c85d6e9cd1..8a41ad8695 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 4c6ffe65c7..16b1986688 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 093322dc1f..ae40265b87 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 fc95b63af7..d30fca2dad 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 737feb3861..3efbbe666c 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 a17f2f37fe..8c80fd9f04 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 cbc1c2c852..54568133e7 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 c27b937bde..919e2b7847 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 8ae8ba677e..67e4f8696b 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 52189578f7..a7fc094ad6 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 d512d10bcc..05f76f166b 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 69dbc125ba..317f06730e 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 7a95ac5506..1e2d9beee9 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 489/940] 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 26c9d012eb..bfd8718f4f 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 1d6a6575eb..f206574a9f 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 0000000000..4a8a627efb --- /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 e398fe32cf..0e154243d0 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 029affc881..060df4355e 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 d910a2f311..ca353b7d84 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 919e2b7847..652fcaee8c 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 384d888893..736bb86e1a 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 552c2e7193..da7468561e 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 490/940] 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 da7468561e..3f6c6e331b 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -83,14 +83,28 @@ - + + + + + From 5f84ba572dda7ef786b3b553f44a286fbd82f573 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 18 Jan 2021 10:01:55 +0100 Subject: [PATCH 491/940] 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 0000000000..248d8734d0 --- /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 8390a963cc..59c1a52052 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 917d47e8a7..4b88a35e26 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 99b244e8dd..ab7616653b 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 6080a6f1e9ae4ae9de8c37d913ef444b5520e3d8 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 492/940] [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 38abf19504..a4acfc3ed9 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 4fafb5e0c2..ba4e92e4ea 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 4b88a35e26..c8098fbf12 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 6013d93885..3c67d70a69 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 6421f71bab..86a6898cf5 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 586fb145a9..caced595ff 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 b67acf5d5fcc3874751ff0e77f5f6beb8e7d9c52 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 22 Jan 2021 17:55:17 +0000 Subject: [PATCH 493/940] 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 bfd8718f4f..d9bb05170e 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 a5e64681885937dfb28a6a8a6e665dca19978296 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 25 Jan 2021 09:53:40 +0100 Subject: [PATCH 494/940] 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 0747eb01d2..5bb786c0f8 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 248d8734d0..35865182d4 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 1884644dcd..712b104094 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 59c1a52052..072b6bc710 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 c8098fbf12..415847b06f 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 ab7616653b..d90c9e35a4 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 7b738b613f7b0b1c547ff37e851502df3df3ae15 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 25 Jan 2021 13:01:57 +0100 Subject: [PATCH 495/940] 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 0000000000..71763b2b45 --- /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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
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

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�(*{}IqOip7 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≤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 Date: Tue, 26 Jan 2021 23:14:28 +0000 Subject: [PATCH 496/940] 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 d9bb05170e..dbfb8f2e1c 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 6206c1dd8a84fca3c0c67ba7420034acd33ed333 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 18 Dec 2020 12:34:40 +0100 Subject: [PATCH 497/940] 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 d90c9e35a4..263b156549 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 b7f6753cb5..acc4704c49 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 317f06730e..a8e8e6f10e 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 51985e041a..107829fd44 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 afb51be093..e7555b61e4 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 7638292fa2..25657004be 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 a4c1594b92..3e1ee753b9 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 d755113e70..bd4b4f110a 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 8880b4dcda..3f8ae15d8c 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 f0c475cb1e..d9ae3ae963 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 1db582c00c..e719a8b9bb 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 48ad12ab06..e0661f7f61 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 9831dbbfa6a17356ae8156a57d1792a49616dfac Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 12 Jan 2021 10:47:41 +0100 Subject: [PATCH 498/940] 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 f1d3bbb644..540148a643 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 a4288244c2a1e18e375e6de27ff2fe898d7601fa Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 28 Jan 2021 08:52:24 +0100 Subject: [PATCH 499/940] 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 dbfb8f2e1c..c437b91687 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 3dc64c97c97778af212567b0678973c786f95e9c Mon Sep 17 00:00:00 2001 From: oca-travis Date: Thu, 28 Jan 2021 08:11:59 +0000 Subject: [PATCH 500/940] [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 f206574a9f..cf30226a3d 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 2399b8ec3172bc567a4946938583026bf011575c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 25 Jan 2021 15:20:27 +0100 Subject: [PATCH 501/940] 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 263b156549..5e89b5822c 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 a8e8e6f10e..09b2c70011 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 3e1ee753b9..5bd1a1a122 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 615fcd1d07f17ee1d0931c054abd92f5fcf8d55d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 28 Jan 2021 09:02:25 +0100 Subject: [PATCH 502/940] 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 c437b91687..20a68c638d 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 3746e3b3f095212d00723b74f99f9d3e7f224a0b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 27 Jan 2021 15:03:26 +0100 Subject: [PATCH 503/940] 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 107829fd44..7e080c90e2 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 25657004be..e2639db1f3 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 3f8ae15d8c..87f5303657 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 d9ae3ae963..bf0cf7bdf5 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 a9e0c36faf9beff8659f667cc017c496c5e782f0 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 27 Jan 2021 15:01:59 +0100 Subject: [PATCH 504/940] 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 5e89b5822c..7d0891cedf 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 3c67d70a69..49f639674a 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 0000000000..0944d96ae2 --- /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 bf40fc2b942d3bf8fc8824cc5d79f0060133abc0 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 28 Jan 2021 14:54:06 +0100 Subject: [PATCH 505/940] 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 20a68c638d..e2b0da6a78 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 e6ead8c8f5ecbdc4d5bead893b7728cac8f00b77 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 20 Jan 2021 12:15:11 +0100 Subject: [PATCH 506/940] 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 0772c6f0ad..5e51aeb033 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 b26d011375..717fd9296f 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 1d632ad8d1e034483ebd915ba315e471233cddd3 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 28 Jan 2021 14:58:16 +0100 Subject: [PATCH 507/940] 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 e2b0da6a78..435ba18191 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 f91b8905db56f49f853cd40b209623f07bfa1ce3 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 508/940] [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 54e9f0b848..7c7d4d0dd3 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 ba4e92e4ea..66b4237f26 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 5edc6ad50b..89b2b0cd2f 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 3db023776cfbfd776ae4210378af72367de29503 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 28 Jan 2021 15:00:20 +0100 Subject: [PATCH 509/940] 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 435ba18191..d9bdd5168b 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 95b4555fa1d1d8326610e3e272e21c9b90343afa 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 510/940] [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 d9bdd5168b..ee3315f1bb 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 540148a643..e36714daf2 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 c155115bca..f54c4097c0 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 4556735223..74b9efffb7 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 11022ff31b..c467a75bd4 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 6215ea5e8e..55bec2d1a7 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 a87ad57fc9..f559eb2f06 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 e32fcf16d6..ec26b756db 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 e666b9c141b372af3c67305d3a161418a826cf0e Mon Sep 17 00:00:00 2001 From: oca-travis Date: Fri, 29 Jan 2021 07:51:40 +0000 Subject: [PATCH 511/940] [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 cf30226a3d..6f2440c3c4 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 343a1ed9d497c462f027bafcc77ad2b32029cba7 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 29 Jan 2021 14:33:40 +0000 Subject: [PATCH 512/940] 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 ee3315f1bb..fb65fca6d0 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 499534fda4f166f20403905328e7750897ccb102 Mon Sep 17 00:00:00 2001 From: Carlos Serra-Toro Date: Wed, 3 Feb 2021 14:07:22 +0100 Subject: [PATCH 513/940] [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 fb65fca6d0..51f122c58e 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 30930739fd..cb9a9dd406 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 35111600bb..0000000000 --- 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 e37e3ff67d..2394a79ce2 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 984a69cb8c..83a636df73 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 74b9efffb7..97909e50e8 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 49f639674a..0a5575d468 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 bedc37a8bf..7eb4780dac 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 0000000000..42aaf91993 --- /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 f361b26fd8..4b3abd6dc0 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 3152277af6..0000000000 --- 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 81b809c27a..ad90a59d5b 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 308071f266..0000000000 --- a/shopfloor/views/stock_picking_views.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - stock.picking.shopfloor.form - stock.picking - - - - - - - - - - - - - - - - - From 7d3a5865554e751d01039a3cfa0ebd6efa3147ce Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Mon, 1 Feb 2021 11:43:09 +0100 Subject: [PATCH 514/940] 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 ddd7527802..d25127aab2 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 934c27b3d4..cb4e10b038 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 1c0e9f926fe9d41462711fdf2aedb82654276d5a Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Thu, 4 Feb 2021 10:58:03 +0100 Subject: [PATCH 515/940] 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 f559eb2f06..d198d9a6bc 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 3e49d472ed..7b2e9d4334 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 c61819400ce4f4278d4308cc85c0252ef834fd70 Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Sat, 6 Feb 2021 20:07:17 +0000 Subject: [PATCH 516/940] 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 ac41377758..5f5b2f4b41 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 bf027d03c8a3c95bc09e9ba6ba954efbdb26d1cc Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Sat, 6 Feb 2021 20:09:37 +0000 Subject: [PATCH 517/940] 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 5f5b2f4b41..9889e5efda 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 3fc680ce9f4b722758b4285cce06dbb1bd193e5c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 29 Jan 2021 16:08:39 +0100 Subject: [PATCH 518/940] 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 51f122c58e..b774e30e28 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 2127ea5cbe..0000000000 --- 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 7c5809584b..0000000000 --- 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 9e94415f01..4f57a594bc 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 bdf728b8e5..a3454e230e 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 5e51aeb033..059ac2031b 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 0a5575d468..7749abc987 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 3efbbe666c..0000000000 --- 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 f1a619ed6b..29876a3a77 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 603a185c3b..0000000000 --- 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 3b41d0b5efd51d6c509cd1109d02b370baff9123 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 11 Feb 2021 10:58:52 +0100 Subject: [PATCH 519/940] 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 b774e30e28..19dc90a1d9 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 16a7d256902c2adc4040e1440129b2ba734e5d05 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Thu, 11 Feb 2021 10:44:55 +0000 Subject: [PATCH 520/940] [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 6f2440c3c4..04df978ba4 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 1baf472ac2d112c32a0c443c73e100d97aea38ea Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Thu, 11 Feb 2021 10:45:16 +0000 Subject: [PATCH 521/940] 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 9889e5efda..55094041bf 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 ebd0d908be21da260bbe34bcb896ed34addb23f1 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 11 Feb 2021 16:29:59 +0100 Subject: [PATCH 522/940] 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 e36714daf2..8d59f2d27b 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 d25127aab2..7ecb450832 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 c86e11360c..7dda0d0243 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 cb4e10b038..e87a2179fb 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 55bec2d1a7..3b51b83ca7 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 d198d9a6bc..53bb743d1b 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 ec26b756db..956f7eabed 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 d99115c2844abe52e803abd7ad9fc07c1fc15337 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 523/940] 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 80832f6d7e..d501a117fd 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 c467a75bd4..bd36ba886c 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 66b4237f26..2b9912fe84 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 03feec18fd..c486509eac 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 ae416df886..831f7f97d5 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 3a1dbfac97959e27a5c00a0c0517969384ad636e Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 12 Feb 2021 17:15:14 +0000 Subject: [PATCH 524/940] 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 19dc90a1d9..868e05eeb3 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 1f5a45609a0dfc7fdcf4a1431eb1f701ea6aaf84 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 11 Feb 2021 16:32:40 +0100 Subject: [PATCH 525/940] 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 868e05eeb3..15b5129718 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 a4acfc3ed9..053704db18 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 97909e50e8..a99f7d3e39 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 948e335840..3592649589 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 520af1d2bbfe4aa5b265b49317563f52a3e2d3d7 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Mon, 15 Feb 2021 13:06:08 +0000 Subject: [PATCH 526/940] [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 04df978ba4..073ed7806a 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 1aed452be5a95551a523060fce957dc01b933296 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 15 Feb 2021 13:19:28 +0000 Subject: [PATCH 527/940] 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 15b5129718..652a259b3d 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 8b588dcc1e0fbe69d84cc095ab0be8c7f9281b3f Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Mon, 15 Feb 2021 13:19:52 +0000 Subject: [PATCH 528/940] 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 55094041bf..180d78890c 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 f515cc8a7152c6fb20f37ffea316ed06f46d2da8 Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Tue, 16 Feb 2021 19:10:13 +0000 Subject: [PATCH 529/940] 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 180d78890c..0e43dfe545 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 17cd0a75950881a4568789ad10543e9e3488ef0f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 2 Feb 2021 10:11:19 +0100 Subject: [PATCH 530/940] 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 0000000000..bc177166d0 --- /dev/null +++ b/shopfloor/security/groups.xml @@ -0,0 +1,17 @@ + + + + + + + + + + From 2e2a4658e0934f632437c2cb795d1341b8fef887 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 2 Feb 2021 16:06:44 +0100 Subject: [PATCH 531/940] 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 | 295 +----------------- 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, 414 insertions(+), 1763 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 6a34e5681f..31f5a21b83 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 652a259b3d..a5ec72c51a 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 43f307a745..0192f5cd2b 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 fb7eb2ae12..0000000000 --- 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 82876f1d04..18a89aae33 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 8d59f2d27b..2b7a5bc254 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 7ecb450832..8523ca88b7 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 053704db18..2968a31291 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 4592653ab8..7a316051e7 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 12a7e529b6..0000000000 --- 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 218e08e3b7..0000000000 --- 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 0000000000..3c13a0c098 --- /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 7858d1d044..0000000000 --- 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 036a20b724..5360865ca5 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 cb9a9dd406..4da30d3788 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 0e154243d0..e9dae5fd40 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 060df4355e..0000000000 --- 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 a3454e230e..b4273b2dda 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 7fe2f664ea..924fa1c5ae 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 ecbc8f21c0..0000000000 --- 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 a99f7d3e39..a1084cbab5 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 bd36ba886c..64154c9d04 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 072b6bc710..e965ec48c5 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 8d328d097a..8acf220a82 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 2b9912fe84..d645e57b2b 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 ca353b7d84..a0f672e190 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 6724ecaca1..0000000000 --- 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 059ac2031b..4c04025b91 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -1,310 +1,25 @@ # 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 import _, exceptions -from odoo.exceptions import MissingError -from odoo.http import request -from odoo.osv import expression - -from odoo.addons.base_rest.controllers.main import _PseudoCollection -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 +from odoo.addons.component.core import AbstractComponent class BaseShopfloorService(AbstractComponent): """Base class for REST services""" - _inherit = "base.rest.service" - _name = "base.shopfloor.service" - _collection = "shopfloor.service" - _actions_collection_name = "shopfloor.action" - _expose_model = None - _log_calls_in_db = True - - def dispatch(self, method_name, *args, params=None): - self._validate_headers_update_work_context(request, method_name) - return super().dispatch(method_name, *args, params=params) - - 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(self, 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 _response( - self, base_response=None, data=None, next_state=None, message=None, popup=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) - :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) - :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() - 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 - # 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 - - if popup: - response["popup"] = popup - - return response - - _requires_header_menu = False - _requires_header_profile = False - - 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) - if demo_api_key: - demo_api_key = demo_api_key.sudo() - - 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", - "description": "ID of the current menu", - "required": True, - "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", - "description": "ID of the current profile", - "required": True, - "schema": {"type": "integer"}, - "style": "simple", - "value": profile.id, - } - ), - defaults.extend(service_params) - return defaults - - @property - def actions_collection(self): - return _PseudoCollection(self._actions_collection_name, self.env) - - 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 - 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 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) - - 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) - - @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") + _inherit = "base.shopfloor.service" @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""" @@ -328,7 +43,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 415847b06f..97d5021615 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 ceed40c9ea..0000000000 --- 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 717fd9296f..0000000000 --- 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 7d0891cedf..910ecc15b9 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 7749abc987..4715a0bf2b 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 2a445122eb..b80dc507d6 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 3b51b83ca7..6759c2b609 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 0000000000..fd33d53fe9 --- /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 53bb743d1b..aa166ed200 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 8a41ad8695..0000000000 --- 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 16b1986688..e1d7517440 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 ae40265b87..941adec4ee 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 d30fca2dad..fcb3f0642a 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 8c80fd9f04..7c071f88f1 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 54568133e7..376a59c12a 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 89b2b0cd2f..73b0d8a654 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 1853edba35..e090849a2f 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 831f7f97d5..46dd5291a3 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 652fcaee8c..0000000000 --- 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 a85d407677..6c3fb86d49 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 561b040072..019753f4b8 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 67e4f8696b..5938fc1165 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 a7fc094ad6..0000000000 --- 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 764e62f127..01464d82b7 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 05f76f166b..23030abee7 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 09b2c70011..0c3872358e 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 87f5303657..922768a6f3 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 bf0cf7bdf5..9bcfc3dac6 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 e719a8b9bb..c5e2058b11 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 0000000000..7e88ea3b36 --- /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 29876a3a77..0000000000 --- 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 3f6c6e331b..0000000000 --- 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 1e2d9beee9..0000000000 --- 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 efca2dba2ca05572d9600c4faf350f8a7079ed97 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 2 Feb 2021 16:08:08 +0100 Subject: [PATCH 532/940] 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 31f5a21b83..436961449d 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 0000000000..e09631841c --- /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 0000000000..8a450c9390 --- /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 0000000000..dc9e3f1188 --- /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 0000000000..9124125e08 --- /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 0000000000..8ee81c4b55 --- /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 0000000000..eb69240591 --- /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 0000000000..1db3897b75 --- /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 0000000000..39b77cf0e9 --- /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 924fa1c5ae..c299b657a6 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 a943fc5c65..0000000000 --- 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 7b2e9d4334..300716dc47 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 7a28fcf565fb2b73088c312fcb6609fd18e8290f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 2 Feb 2021 17:22:47 +0100 Subject: [PATCH 533/940] 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 3c13a0c098..35c202eee2 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 e9dae5fd40..caaf50212d 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 09ca0db797affd83afffcd3a02dd1505123051bd Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 11 Feb 2021 09:08:07 +0100 Subject: [PATCH 534/940] 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 a1084cbab5..6c91f9e7fe 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 07cb846c34..acc289e40f 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 2218060026..d82d6859a8 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 2fb6c4edf76cf673d480505fcb90cd621a0509d9 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 12 Feb 2021 07:43:05 +0100 Subject: [PATCH 535/940] 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 a5ec72c51a..a545d2b3d2 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 8a450c9390..734d80ca40 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 aa166ed200..5154b2467d 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 88470ca7d64180d6e7e84dd032aef9d8d0a9e8cd Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 12 Feb 2021 09:10:44 +0100 Subject: [PATCH 536/940] 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 a545d2b3d2..97883480a9 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 9124125e08..dae8b354c2 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 8ee81c4b55..95d816724f 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 eb69240591..ee0eff762a 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 1db3897b75..ac06a933ed 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 39b77cf0e9..3fc2609222 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 0000000000..47d56ccc28 --- /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 300716dc47..c8ae3c5504 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 08450be95393cdb7fae26248df97d2a1c39b9775 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 12 Feb 2021 10:44:18 +0100 Subject: [PATCH 537/940] 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 47d56ccc28..0000000000 --- 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 490afdcef194372eb0239db9ec007ccc2e447d8a Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 15 Feb 2021 08:28:28 +0100 Subject: [PATCH 538/940] 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 e09631841c..b226daeb72 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 734d80ca40..99b164651a 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 d645e57b2b..a5c1765c3b 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 7dda0d0243..0000000000 --- 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 e87a2179fb..0000000000 --- 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 17adcdeccae42cac20319f6d93c4983c1ea6965d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 16 Feb 2021 08:22:50 +0100 Subject: [PATCH 539/940] 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 35c202eee2..0a2370d160 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 99ea717af11a8c0dc8f43303addd12253eff90e8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 16 Feb 2021 08:30:48 +0100 Subject: [PATCH 540/940] 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 b8a2a4657d..ad3b32e1e9 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 92102a8b42..0000000000 --- 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 fc9823dfd8..9dc7a72fc1 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 5dbbf6aebcd2b081d83f7926107769c8c1108830 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 17 Feb 2021 17:09:33 +0100 Subject: [PATCH 541/940] 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 97883480a9..4294b76b06 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 0000000000..6167d6a644 --- /dev/null +++ b/shopfloor/views/shopfloor_menu.xml @@ -0,0 +1,61 @@ + + + + shopfloor.menu + + + + + + + + + + + + + + + + + + + + + + + shopfloor.menu + + + + + + + + + shopfloor.menu + + + + + + + + From 4300846aa64e522ff5c5fe3b839607d19803d143 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 17 Feb 2021 17:10:03 +0100 Subject: [PATCH 542/940] 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 caaf50212d..90673dc4b2 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 5a0c419c0aed4a33afa3568260a2dd9c0e1b2149 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 18 Feb 2021 15:35:25 +0100 Subject: [PATCH 543/940] 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 0000000000..8af37fe7c7 --- /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 533c0e70c43b2d8a4a3be2068f851c1afba255c9 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 19 Feb 2021 10:05:52 +0100 Subject: [PATCH 544/940] 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 8dd4aea84a..764b00af70 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 8f49cb77104edd7f6563ac931513522313218f27 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 24 Feb 2021 11:06:24 +0100 Subject: [PATCH 545/940] 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 b80dc507d6..d0de05d08a 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 a7c8d5c0aa233faaebb3c7b6c00785673b9c1081 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 24 Feb 2021 11:07:09 +0100 Subject: [PATCH 546/940] 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 26f34fbc2b..87bac89037 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 0c3872358e..a4552109dc 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 63dca33deb7df787fb769d2d5eef03a4b44a4a30 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 9 Feb 2021 12:17:33 +0100 Subject: [PATCH 547/940] 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 4294b76b06..534609a077 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 6b8c3cd921..c7c5ee637a 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 49f926cfacaa484192471fde4c19cadce2a61bda Mon Sep 17 00:00:00 2001 From: oca-travis Date: Wed, 24 Feb 2021 11:25:05 +0000 Subject: [PATCH 548/940] [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 073ed7806a..0c6471b5d9 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 12b19d8fc15ac5379161f3ca726961192b01810c Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Wed, 24 Feb 2021 11:25:31 +0000 Subject: [PATCH 549/940] 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 0e43dfe545..4ccb745fc5 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 0b63542809a648b9ae21765f35f916f5155392f7 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 24 Feb 2021 12:52:10 +0000 Subject: [PATCH 550/940] 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 534609a077..228d6ce8e8 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 f0c55f100696614178fc389f9936af7e48b972dd Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Thu, 25 Feb 2021 04:32:11 +0000 Subject: [PATCH 551/940] 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 4ccb745fc5..27deb26fa1 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 d56884a726f1da166baf08d64091c67e7651c178 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 25 Feb 2021 14:31:24 +0100 Subject: [PATCH 552/940] 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 7a316051e7..9095d104cd 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 0000000000..b7cf8a67d0 --- /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 1537fdcc9564ef63c562cf23fee435e4535ba469 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 25 Feb 2021 14:33:09 +0100 Subject: [PATCH 553/940] 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 9095d104cd..527dd62b86 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 b7cf8a67d0..516ca2c0d5 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 5432bdb98c3b1d23f3a584c2528802c84edfd529 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 25 Feb 2021 14:34:06 +0100 Subject: [PATCH 554/940] 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 2b7a5bc254..83aab96449 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 4715a0bf2b..add142a638 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 6759c2b609..33c5c27cc8 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 801e84b7960f262f2f35bcb988fe1eea56d59350 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 1 Mar 2021 15:39:34 +0000 Subject: [PATCH 555/940] 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 228d6ce8e8..db4247fc2d 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 b469f00d7991ee86f33506ec79af287b4b8a8b06 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 12 Feb 2021 15:07:49 +0100 Subject: [PATCH 556/940] 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 7f274ec28f..e4e135eaa9 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 3474beae7ad00da62164ee5bf7a9bffc234bb776 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 3 Mar 2021 09:56:15 +0100 Subject: [PATCH 557/940] 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 db4247fc2d..89ac5aa1a0 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 2eac543040451a6c53e3d2d165611d238ebb3199 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 3 Mar 2021 09:56:49 +0100 Subject: [PATCH 558/940] 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 527dd62b86..26d17af44c 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 abb21622f8b7a09ce3a8e52f3db8503d82fff24e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 8 Mar 2021 14:43:40 +0100 Subject: [PATCH 559/940] 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 b226daeb72..55c0e2d609 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 a0f672e190..bac675bf63 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 4f02aba2bc08c0f8b7e0a1350c6615ca72c38b6b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 8 Mar 2021 14:44:48 +0100 Subject: [PATCH 560/940] 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 6c3fb86d49..59cbb05c67 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 8cec852e08..61de5416b2 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 3045d8a866b2cb5896b512d066cfd442c865d29d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 9 Mar 2021 15:55:31 +0100 Subject: [PATCH 561/940] 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 89ac5aa1a0..dae9506c8d 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 a07ebee1f9292234807b5f6aeecc573af406d34b Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 9 Mar 2021 15:26:36 +0000 Subject: [PATCH 562/940] [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 ad3b32e1e9..1f0a9b8a1f 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 9dc7a72fc1..45ee1c99e8 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 0354d407e0cba3c07760ecc217203d153d7b8ce6 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 9 Mar 2021 15:45:45 +0100 Subject: [PATCH 563/940] 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 6c91f9e7fe..55a1fff2aa 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 fb00789b84b3387c8d1f04ba8db8adb2ab77b160 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 11 Mar 2021 15:27:34 +0000 Subject: [PATCH 564/940] 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 dae9506c8d..e1a1157fae 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 501a16524d8032eb417e3b10fdcf68438dd50a55 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 12 Mar 2021 08:33:39 +0100 Subject: [PATCH 565/940] 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 87bac89037..43bab66b06 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 e965ec48c5..44045d872c 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 3f10577b99368a59a06fff68a44d9618e5c8e76b Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 12 Mar 2021 09:11:46 +0000 Subject: [PATCH 566/940] 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 e1a1157fae..56d845ead9 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 96a630ef9cb19e19e511fccf082551291581333b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 12 Mar 2021 13:31:17 +0100 Subject: [PATCH 567/940] 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 97d5021615..5cb696de37 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 d1f64533c44639a06e3346012a60e3ddb14dd102 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 568/940] 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 2968a31291..c14177ee46 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 55a1fff2aa..fb660d8625 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 956f7eabed..81ea35ceac 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 3592649589..c8b5f014f2 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 292a69175950d534066728b02460868874c1ee5e 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 569/940] 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 83aab96449..a68822f044 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 c14177ee46..a1f523b534 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 55c0e2d609..84cd7fe32c 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 fb660d8625..b8e3ef67df 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 add142a638..ffe883632e 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 0000000000..7fef207931 --- /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 33c5c27cc8..7c60ed8f11 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 fd33d53fe9..f1312ec30a 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 0000000000..48fc87c446 --- /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 475bef6d0a9c1b19351ba73129127ec114e12b78 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 570/940] 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 b8e3ef67df..d82832ea67 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 6a9d4e74a7..87156ea5c6 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 98f7b9da2387c39863c06498898af68da9acc5d2 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 15 Mar 2021 07:46:26 +0000 Subject: [PATCH 571/940] 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 56d845ead9..258a398f94 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 947bf1f257ed7623d7ed51d9a2224e8d318b7e08 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Mon, 15 Mar 2021 08:53:43 +0000 Subject: [PATCH 572/940] [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 0c6471b5d9..a2f8c73692 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 b112ae62f60df64d2531407205a70021ce3ebf42 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 15 Mar 2021 11:06:49 +0000 Subject: [PATCH 573/940] 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 258a398f94..fa07bee2bb 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 f6a4379befffea00d0f50b21eab12f8370c708c1 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 16 Mar 2021 15:43:11 +0100 Subject: [PATCH 574/940] 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 d82832ea67..7986583d10 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 7c04233679af31dd06a17abc171e8a43579a4388 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 23 Feb 2021 08:02:48 +0100 Subject: [PATCH 575/940] 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 910ecc15b9..5522422f98 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 b01b599654d0c48f32a82a4ac5eb1c0be1a6770f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 16 Mar 2021 16:11:37 +0100 Subject: [PATCH 576/940] 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 64154c9d04..16b2795112 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 46a055f19c82ff28a6fc26bfd80b624dc32f23c4 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 23 Mar 2021 07:04:07 +0000 Subject: [PATCH 577/940] 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 fa07bee2bb..90a296bd5a 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 10262f4c87918a13e4e5099420c93189c1c82eb8 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 23 Mar 2021 08:58:00 +0000 Subject: [PATCH 578/940] 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 90a296bd5a..6f0d9d7f1e 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 b5f08aa5f57909a46d4b1c846b524ce79b2eb232 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 22 Mar 2021 14:10:32 +0100 Subject: [PATCH 579/940] 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 a1f523b534..386e5b1200 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 27deb26fa1..338daa3b05 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 a2f8c73692..d91ea7e0b5 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 7986583d10..b0b7514660 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 81ea35ceac..09a81b3421 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 a6a038f72f..79126597a0 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 c8b5f014f2..39c87e92ae 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 0b204df75cb833e167caa98d2ef20c931ef8d732 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 22 Mar 2021 14:12:43 +0100 Subject: [PATCH 580/940] 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 386e5b1200..99fd6c8e9f 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 ed2d93356148812f3954a0ff840c2e7caebe91ba Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 22 Mar 2021 14:14:43 +0100 Subject: [PATCH 581/940] 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 0192f5cd2b..32d2adc86a 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 0000000000..e8809f448a --- /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 b0b7514660..384d579d84 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 080d7d8cfcaa86d71470cada949a517851af3941 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 22 Mar 2021 14:18:13 +0100 Subject: [PATCH 582/940] 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 5522422f98..e6c07aa1e4 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 0cfb39c8e39ca6737bd4de5e1d0212b20b4da7dd Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Mon, 15 Mar 2021 08:54:13 +0000 Subject: [PATCH 583/940] 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 338daa3b05..da37cb3cac 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 778ee175c97b10058dcfa3efa65da090e32af070 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 6 Apr 2021 09:28:48 +0200 Subject: [PATCH 584/940] 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 626779a7f9..9cd89033e8 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 82e4deeeef30f3f3eab0b20c33ee669663caba1a Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 6 Apr 2021 09:34:08 +0200 Subject: [PATCH 585/940] 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 16b2795112..53b980e6ce 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 a7d7c3892d60d486dbeca8b32014ccc44dc9d25b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 6 Apr 2021 09:42:24 +0200 Subject: [PATCH 586/940] 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 53b980e6ce..ad27d20e97 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 c6a8352ce6c42ea58245201fac806c2c12142ac9 Mon Sep 17 00:00:00 2001 From: jabelchi Date: Tue, 13 Apr 2021 09:09:13 +0000 Subject: [PATCH 587/940] 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 0000000000..1c99a26e59 --- /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 70e322d6f077f2ad35e32be465b4d9b1e2fb0276 Mon Sep 17 00:00:00 2001 From: jabelchi Date: Tue, 13 Apr 2021 09:10:02 +0000 Subject: [PATCH 588/940] 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 1c99a26e59..d588033c99 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 25371143e03cce0d5817dbfd994c3a453eafa7b0 Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Wed, 14 Apr 2021 18:17:53 +0000 Subject: [PATCH 589/940] 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 da37cb3cac..9f39bd0302 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 bea504022e1458536baea8e45c9104b87e150d24 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 590/940] 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 6f0d9d7f1e..a620a224b9 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 384d579d84..28e5dea4a8 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 379bf4cecdf7cd2a11d4e784ce99122e3d68ad72 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 22 Mar 2021 14:17:45 +0100 Subject: [PATCH 591/940] 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 a620a224b9..7113f8e932 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 99fd6c8e9f..bdf8c6978c 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 e8809f448a..e2d4f13935 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 0a2370d160..a4128371a0 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 0000000000..1a3956b7db --- /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 90673dc4b2..fbe821cae0 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 e6c07aa1e4..876cb3d00c 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 ffe883632e..77f29f7080 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 7fef207931..ca9aa69c3c 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 0000000000..377f4ad455 --- /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 6167d6a644..e6d1c8c202 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -33,6 +33,13 @@ + + + + From 98c9f7d47bf094c434cc22c15bc6f809ec8d354b Mon Sep 17 00:00:00 2001 From: oca-travis Date: Tue, 20 Apr 2021 10:52:54 +0000 Subject: [PATCH 592/940] [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 d91ea7e0b5..2a75749cce 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 032e816416e0b95e76917f841f75c78b9e7839ee Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 20 Apr 2021 11:10:47 +0000 Subject: [PATCH 593/940] 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 7113f8e932..2d28f9726f 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 cb4431263d82680633cd25ab9f29a4e21dfaef36 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 20 Apr 2021 12:00:48 +0000 Subject: [PATCH 594/940] 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 2d28f9726f..cb5a517614 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 0469ec557774e557daf9713951d11f63e350afc0 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 20 Apr 2021 13:11:12 +0000 Subject: [PATCH 595/940] 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 cb5a517614..bd75d9e74a 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 b53a7e747400fcbb7d7450ed9ffc6cf82fb6980e Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Tue, 20 Apr 2021 11:11:03 +0000 Subject: [PATCH 596/940] 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 d588033c99..a47058a5e6 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 9f39bd0302..76c4fc0715 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 9ca3ff8bef9d30994693b95bc7e300f9dca6f373 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 597/940] [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 acc4704c49..d5e2377f6d 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 6bd5050796af129fc3e7d10bca8cb718f5fd61ed 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 598/940] [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 f6105e4d55..8f4a79f0f6 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 2394a79ce2..fe27b7b4eb 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 a5c1765c3b..2bea64e5a0 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 984e88c8b92be6d99907a68ad415d286e8ceb6e2 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Sat, 1 May 2021 17:25:25 +0000 Subject: [PATCH 599/940] 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 bd75d9e74a..198b0c60e6 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 061698fa14af6f47494848b761c0ff62588e6b2d Mon Sep 17 00:00:00 2001 From: oca-travis Date: Sat, 1 May 2021 18:53:27 +0000 Subject: [PATCH 600/940] [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 2a75749cce..6aca71f572 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 601/940] 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 a47058a5e6..dca242e9cd 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 602/940] 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 876cb3d00c..02c4ddb7c6 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 e11fa3e3f832735af79ff500954ffae607cd0896 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 18 May 2021 08:10:04 +0200 Subject: [PATCH 603/940] 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 198b0c60e6..dc85d76ab1 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 e7f946fbcb..0000000000 --- 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 4da30d3788..2f3414a52f 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 4f57a594bc..0000000000 --- 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 b4273b2dda..0000000000 --- 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 d8510f5e338cda978b80b697fb8ed13682015062 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Tue, 18 May 2021 06:48:44 +0000 Subject: [PATCH 604/940] [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 6aca71f572..94979373b1 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 ea4c43cfcf7b46e46bd593cda1424c116b26a358 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 18 May 2021 07:04:20 +0000 Subject: [PATCH 605/940] 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 dc85d76ab1..c6f544a25d 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 d5a555e1a5cc09a9e7f2584cb3c56b49c508b0e9 Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Tue, 18 May 2021 07:04:31 +0000 Subject: [PATCH 606/940] 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 dca242e9cd..1467ea7eb9 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 83790ef17a..22f076b760 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 c717a172f6f8b9e9f1b5594ea8856ac926144b87 Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Wed, 26 May 2021 15:41:37 +0200 Subject: [PATCH 607/940] 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 bdf8c6978c..238696a4d8 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 712b104094..1d859d9346 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 02c4ddb7c6..7b70cfbcc8 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 a4552109dc..51ac41218a 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 e7555b61e4..61323fef4e 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 fc291c5f68220ddf2d9efaa4943734adf81d8391 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Thu, 3 Jun 2021 10:47:00 +0000 Subject: [PATCH 608/940] [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 94979373b1..47cd13d5aa 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 56b1c20cce2d12d7e8e3126f8c9bd010c7610442 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 3 Jun 2021 11:01:57 +0000 Subject: [PATCH 609/940] 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 c6f544a25d..d8d3602575 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 131f960d41c81ac2793af25cb1392e524a6d445e Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Thu, 3 Jun 2021 11:02:13 +0000 Subject: [PATCH 610/940] 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 1467ea7eb9..6ded97489c 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 22f076b760..492b0989d1 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 137258a8941175aa3f90aeaa5f06354994c6566c Mon Sep 17 00:00:00 2001 From: Jacques-Etienne Baudoux Date: Wed, 28 Apr 2021 16:30:41 +0200 Subject: [PATCH 611/940] 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 1f0a9b8a1f..fbbd202102 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 4d4d24ed1b..dae732196e 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 28e5dea4a8..3637f3b86f 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 ad27d20e97..16209f88de 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 2bea64e5a0..e71d4c915a 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 4c04025b91..09bbded512 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 odoo import _, exceptions @@ -60,3 +61,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 5cb696de37..3c46d61a8a 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 7b70cfbcc8..2feb75f206 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 d1b3de47e1..68980b2851 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 73b0d8a654..f881641fd0 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 e090849a2f..a74ab138d3 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 01464d82b7..9206cd5b30 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 caced595ff..2860cde037 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 5bd1a1a122..9fa9bca924 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 9bcfc3dac6..0c18fc7cff 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 958d97415e7ad977be27fa568fffe04d41e34ebb Mon Sep 17 00:00:00 2001 From: jabelchi Date: Fri, 4 Jun 2021 08:31:29 +0000 Subject: [PATCH 612/940] 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 6ded97489c..7df7f08c43 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 2fab6b1d4f46b528f7c78966280602c75cb8f17f Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 4 Jun 2021 12:18:31 +0000 Subject: [PATCH 613/940] [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 45ee1c99e8..f35d6ab5ff 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 b5e1c0ac60d558b7e681225974d0178f2aa5e815 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 4 Jun 2021 12:18:32 +0000 Subject: [PATCH 614/940] 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 d8d3602575..7fb3beeb0f 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 f16722f8be247f6a81467ff2aeb0ecb0098a6a40 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 615/940] [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 3c46d61a8a..3c9e528fe9 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 9206cd5b30..969c631d44 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 15c95f1b6209fc2f586be037ee58fe67899badf1 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 616/940] [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 7c7d4d0dd3..45f553f203 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 e71d4c915a..e14a5fe3a2 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 3c9e528fe9..32e7489ee8 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 647745a7283ed3caa7246476b82c366f78b10654 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 9 Jun 2021 06:30:39 +0000 Subject: [PATCH 617/940] 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 7fb3beeb0f..10d3d50a1a 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 05e04d11174efd531398dce254e9cb85b9aec18c Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 2 Jul 2021 09:11:01 +0000 Subject: [PATCH 618/940] 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 10d3d50a1a..55600fa8e1 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 985c392351883bc846e0673a1f268102f64b201c Mon Sep 17 00:00:00 2001 From: hparfr Date: Mon, 19 Jul 2021 16:15:11 +0200 Subject: [PATCH 619/940] shopfloor: mark as uninstallable --- shopfloor/__manifest__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 55600fa8e1..80841d6852 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 c48542fe855369a1bbaa3255835f0bdc699208f8 Mon Sep 17 00:00:00 2001 From: hparfr Date: Tue, 1 Dec 2020 22:33:24 +0100 Subject: [PATCH 620/940] [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 80841d6852..9a8330b691 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 8523ca88b7..2e4f871ae6 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 2b5adbba5c..f789720604 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 e2d4f13935..391484efca 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 45f553f203..f5ed66b9e3 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 dae8b354c2..94bc874570 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 95d816724f..a1ea34857e 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 ee0eff762a..79c463de73 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 ac06a933ed..1a56e0236e 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 3fc2609222..8a1dc285ba 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 ef555c78c0..0000000000 --- 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 43bab66b06..3e480be2b7 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 f54c4097c0..4d66a3e0b5 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 dae732196e..be30942b85 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 3637f3b86f..1d89c40651 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 16209f88de..5c9897af40 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 44045d872c..4c6c5e217b 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 e14a5fe3a2..bfe940b293 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 2feb75f206..0ec9b3814f 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 ca9aa69c3c..cfbced9840 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 03678c0b56..5f2e6a6e18 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 f1312ec30a..0a80bde44b 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 5154b2467d..a694ee5970 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 b3bcf36aa6..727c838689 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 6cc8c5708c..e85b644458 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 39c87e92ae..f049bf4400 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 60968810b1..4a0d2b48ad 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 4b3abd6dc0..021794c780 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 b49b1aed5e..d31b88ac05 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 fcb3f0642a..e208b857a0 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 02f35414af..200cf80a5e 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 68980b2851..da84811c85 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 ae2cd598f6..d7d5177853 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 d5e2377f6d..7ba6825bfd 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 a74ab138d3..d347b3f474 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 59cbb05c67..8d4ce672f6 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 61de5416b2..e63615fdce 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 969c631d44..7649a0c9ef 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 51ac41218a..b72d896e4f 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 61323fef4e..0ddd42ad7a 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 e2639db1f3..ef50bea0f1 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 9fa9bca924..9b6f8e9759 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 377f4ad455..84e106e238 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 bd4b4f110a..a7d282e55b 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 922768a6f3..760ba9abc4 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 0944d96ae2..b9d765b208 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 0c18fc7cff..7983d3e330 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 c5e2058b11..44bf703cd6 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 e0661f7f61..c192d83728 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 30eaec8df16b2c423b7c3a33da582ca624c9d78f Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Sat, 24 Jul 2021 16:05:24 +0000 Subject: [PATCH 621/940] 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 492b0989d1..089521b748 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 e41919d0a067c128b7a8c0b1d7e0d9205b0bac04 Mon Sep 17 00:00:00 2001 From: Hpar Date: Mon, 26 Jul 2021 09:34:09 +0200 Subject: [PATCH 622/940] 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 f789720604..84f36a8730 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 35b1be19c9752ffd194d121b59a33e75ea99bd22 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Thu, 29 Jul 2021 09:10:34 +0000 Subject: [PATCH 623/940] [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 47cd13d5aa..576c27ab8e 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 b220e64572425db1e339118d456b29b9593e661c Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 29 Jul 2021 09:43:24 +0000 Subject: [PATCH 624/940] [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 fbbd202102..844e66ab58 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 f35d6ab5ff..082f163196 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 d1e60db2205457f84c267b82034c1b3854353fdd Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Thu, 29 Jul 2021 09:43:36 +0000 Subject: [PATCH 625/940] 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 7df7f08c43..f0d0172f6d 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 089521b748..11543a265e 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 94a861ba81b05849456a600d217e158caa06fb56 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 626/940] [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 a68822f044..11034c0eed 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 84cd7fe32c..c3b07c8eb9 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 fe27b7b4eb..bb11e54c2b 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 7c60ed8f11..1231505077 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 a694ee5970..9d3570de4a 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 65fac48da4dcc195786fd3815657f00e7bb35492 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Fri, 15 Oct 2021 06:42:45 +0000 Subject: [PATCH 627/940] [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 576c27ab8e..4db0726fac 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 bd4456143f3b562e55e050d2df793d891987ee69 Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Fri, 15 Oct 2021 07:22:52 +0000 Subject: [PATCH 628/940] 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 f0d0172f6d..451da7b9ce 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 11543a265e..343c7456ab 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 157cad68b184acdd03cc9ad6724ba500fef987e5 Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Tue, 19 Oct 2021 21:03:37 +0000 Subject: [PATCH 629/940] 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 343c7456ab..5ffc07845e 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 096b0ba1f66b93c9e3a92afad91ed0d9ed6c9942 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 630/940] [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 32e7489ee8..928919d42a 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 7a520905353102d0d18d1d1d4b5d3b12893abfc5 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 631/940] [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 d501a117fd..cbe64cdfbf 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 8164f2366ac80234b0cf880e79b3aa4949a23b8c Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Wed, 19 May 2021 10:39:24 +0200 Subject: [PATCH 632/940] [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 11034c0eed..2e9523d71c 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 c3b07c8eb9..d80ddf63f0 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 1231505077..99412286dd 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 9d3570de4a..4cb2733e7c 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 6e65c23dade5ad01a2e381abcd2d565ae95da705 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 3 Nov 2021 14:09:54 +0000 Subject: [PATCH 633/940] 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 9a8330b691..19cc70f5e1 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 d9b9a9407b1bdd405177647a29212197fe7409ee Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 16 Nov 2021 12:08:43 +0100 Subject: [PATCH 634/940] 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 77f29f7080..a617d08546 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 8d4ce672f6..cc8a7f9300 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 736bb86e1a..4b2608645e 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 74a52030e327e4b3f321518688f579ca644cf3a9 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 17 Nov 2021 13:29:18 +0000 Subject: [PATCH 635/940] 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 19cc70f5e1..059be997e9 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 fa0338dc0b6ab7f5c2501d9dd31c75f0277b655a 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 636/940] 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 059be997e9..16281cf112 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 238696a4d8..d2a28d2744 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 f5ed66b9e3..4264c7dc8b 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 a4128371a0..3b6ad81e33 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 5360865ca5..dc57c1ba2e 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 c912a1087d..b76b3db1c6 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 0000000000..041779ecf7 --- /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 928919d42a..67bf2853e5 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 a617d08546..47b135c3be 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 0000000000..2fcfa25a28 --- /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 0000000000..814e27ce34 --- /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 0000000000..b1ac5032b4 --- /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 0000000000..6140f592a9 --- /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 0000000000..55760722d9 --- /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 0000000000..5f80f300c2 --- /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 cfdcc6671be03ff24555f1c7047bfbc20ad69210 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 637/940] 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 16281cf112..059be997e9 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 d2a28d2744..0d6793c496 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 3b6ad81e33..a4128371a0 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 dc57c1ba2e..5360865ca5 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 b76b3db1c6..c912a1087d 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 041779ecf7..0000000000 --- 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 47b135c3be..a617d08546 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 2fcfa25a28..0000000000 --- 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 814e27ce34..0000000000 --- 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 b1ac5032b4..0000000000 --- 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 6140f592a9..0000000000 --- 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 55760722d9..0000000000 --- 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 5f80f300c2..0000000000 --- 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 3f88b35c2026d342770b9db502f5010062a0595a 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 638/940] 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 fbe821cae0..28ad436338 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 a809a0d409..583739eb14 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 e6d1c8c202..1f534fbe4d 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -40,6 +40,13 @@ + + + + From 5fd7af3d27170fad5610d1d354e67805f90eab8e Mon Sep 17 00:00:00 2001 From: oca-travis Date: Tue, 14 Dec 2021 10:35:14 +0000 Subject: [PATCH 639/940] [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 4db0726fac..1aaae75264 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 119a6b10d0922e291fc6d84058b10288c94acd1c Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 14 Dec 2021 10:59:15 +0000 Subject: [PATCH 640/940] 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 059be997e9..d487b64182 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 f31abc58780ac0e0f9497a3004aaf58792b73cfc Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Tue, 14 Dec 2021 10:59:37 +0000 Subject: [PATCH 641/940] 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 451da7b9ce..708224855e 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 5ffc07845e..715455878e 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 b0cadfc2c83240d3fb12d6ac7d691e36f8bff7ca Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Mon, 27 Dec 2021 02:48:03 +0000 Subject: [PATCH 642/940] 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 715455878e..ff3cf48d6d 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 418e0883594b23948dbc6782313dac4f4dbafc27 Mon Sep 17 00:00:00 2001 From: Marcel Savegnago Date: Tue, 28 Dec 2021 15:01:14 +0000 Subject: [PATCH 643/940] 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 0000000000..320649de4c --- /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 b6a6559a1cdc97e3b87f386607009c2421b00cfa 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 644/940] [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 764b00af70..29b7f180af 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 bafab373b76b8edb42f625dc727a0a80ebe80740 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 18 Jan 2022 15:46:10 +0100 Subject: [PATCH 645/940] 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 d487b64182..e3c98647b0 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 7a841b1f4c720dc72c0b25789b1d00dd560b66fe Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 19 Jan 2022 10:12:10 +0000 Subject: [PATCH 646/940] [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 844e66ab58..2c594e3be6 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 082f163196..ad30dad4ef 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: