From 85c153a211bb48e751c8990da6df62653cd535da Mon Sep 17 00:00:00 2001 From: David Date: Fri, 3 Nov 2023 19:05:18 +0100 Subject: [PATCH 1/2] [ADD] stock_vlm_mgmt: New module for 14.0 A lighter implementation for VLM management TT45739 --- stock_vlm_mgmt/README.rst | 111 +++++ stock_vlm_mgmt/__init__.py | 2 + stock_vlm_mgmt/__manifest__.py | 28 ++ stock_vlm_mgmt/models/__init__.py | 8 + stock_vlm_mgmt/models/stock_location.py | 139 ++++++ .../models/stock_location_vlm_tray.py | 79 +++ .../models/stock_location_vlm_tray_type.py | 66 +++ stock_vlm_mgmt/models/stock_move_line.py | 152 ++++++ stock_vlm_mgmt/models/stock_picking.py | 85 ++++ stock_vlm_mgmt/models/stock_quant.py | 117 +++++ stock_vlm_mgmt/models/stock_vlm_task.py | 192 ++++++++ .../models/vlm_tray_cell_position_mixin.py | 55 +++ stock_vlm_mgmt/readme/CONTRIBUTORS.rst | 4 + stock_vlm_mgmt/readme/DESCRIPTION.rst | 3 + stock_vlm_mgmt/readme/ROADMAP.rst | 18 + stock_vlm_mgmt/security/ir.model.access.csv | 7 + stock_vlm_mgmt/static/description/index.html | 452 ++++++++++++++++++ .../static/src/js/stock_location_tray.js | 284 +++++++++++ .../src/js/vlm_task_tree_action_buttons.js | 48 ++ .../static/src/scss/stock_vlm_mgmt.scss | 19 + .../vlm_task_tree_action_buttons_views.xml | 11 + stock_vlm_mgmt/views/assets.xml | 20 + .../views/stock_location_tray_type_views.xml | 99 ++++ stock_vlm_mgmt/views/stock_location_views.xml | 102 ++++ .../views/stock_location_vlm_tray_views.xml | 87 ++++ stock_vlm_mgmt/views/stock_picking_views.xml | 40 ++ stock_vlm_mgmt/views/stock_quant_views.xml | 37 ++ .../views/stock_quant_vlm_views.xml | 115 +++++ stock_vlm_mgmt/views/stock_vlm_task_views.xml | 141 ++++++ stock_vlm_mgmt/wizards/__init__.py | 1 + .../wizards/stock_vlm_task_action.py | 186 +++++++ .../wizards/stock_vlm_task_action_views.xml | 152 ++++++ 32 files changed, 2860 insertions(+) create mode 100644 stock_vlm_mgmt/README.rst create mode 100644 stock_vlm_mgmt/__init__.py create mode 100644 stock_vlm_mgmt/__manifest__.py create mode 100644 stock_vlm_mgmt/models/__init__.py create mode 100644 stock_vlm_mgmt/models/stock_location.py create mode 100644 stock_vlm_mgmt/models/stock_location_vlm_tray.py create mode 100644 stock_vlm_mgmt/models/stock_location_vlm_tray_type.py create mode 100644 stock_vlm_mgmt/models/stock_move_line.py create mode 100644 stock_vlm_mgmt/models/stock_picking.py create mode 100644 stock_vlm_mgmt/models/stock_quant.py create mode 100644 stock_vlm_mgmt/models/stock_vlm_task.py create mode 100644 stock_vlm_mgmt/models/vlm_tray_cell_position_mixin.py create mode 100644 stock_vlm_mgmt/readme/CONTRIBUTORS.rst create mode 100644 stock_vlm_mgmt/readme/DESCRIPTION.rst create mode 100644 stock_vlm_mgmt/readme/ROADMAP.rst create mode 100644 stock_vlm_mgmt/security/ir.model.access.csv create mode 100644 stock_vlm_mgmt/static/description/index.html create mode 100644 stock_vlm_mgmt/static/src/js/stock_location_tray.js create mode 100644 stock_vlm_mgmt/static/src/js/vlm_task_tree_action_buttons.js create mode 100644 stock_vlm_mgmt/static/src/scss/stock_vlm_mgmt.scss create mode 100644 stock_vlm_mgmt/static/src/xml/vlm_task_tree_action_buttons_views.xml create mode 100644 stock_vlm_mgmt/views/assets.xml create mode 100644 stock_vlm_mgmt/views/stock_location_tray_type_views.xml create mode 100644 stock_vlm_mgmt/views/stock_location_views.xml create mode 100644 stock_vlm_mgmt/views/stock_location_vlm_tray_views.xml create mode 100644 stock_vlm_mgmt/views/stock_picking_views.xml create mode 100644 stock_vlm_mgmt/views/stock_quant_views.xml create mode 100644 stock_vlm_mgmt/views/stock_quant_vlm_views.xml create mode 100644 stock_vlm_mgmt/views/stock_vlm_task_views.xml create mode 100644 stock_vlm_mgmt/wizards/__init__.py create mode 100644 stock_vlm_mgmt/wizards/stock_vlm_task_action.py create mode 100644 stock_vlm_mgmt/wizards/stock_vlm_task_action_views.xml diff --git a/stock_vlm_mgmt/README.rst b/stock_vlm_mgmt/README.rst new file mode 100644 index 000000000..0ecd66035 --- /dev/null +++ b/stock_vlm_mgmt/README.rst @@ -0,0 +1,111 @@ +=============================== +Vertical Lift Module management +=============================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:16cc58033099be982092f3cf06236fc4a8ab693fcfa4ff09ff5555062362ce43 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :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 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstock--logistics--warehouse-lightgray.png?logo=github + :target: https://github.com/OCA/stock-logistics-warehouse/tree/14.0/stock_vlm_mgmt + :alt: OCA/stock-logistics-warehouse +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/stock-logistics-warehouse-14-0/stock-logistics-warehouse-14-0-stock_vlm_mgmt + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/stock-logistics-warehouse&target_branch=14.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds basic a management system for Vertical Lift Modules. It's thought as +a simpler alternative attemp to stock_vertical_lift and all the dependencies that +come with it. + +**Table of contents** + +.. contents:: + :local: + +Known issues / Roadmap +====================== + +* Launch the tasks in batches so we don't have to send them to the VLM one by one. In + the case of Kardex, we'll be dealing with the connection limitations. If we send a + list of tasks, right now we're closing the connection once we receive a response (Kardex). + We need to keep listening until all the ids are received, but that locks our thread... + We also need to respond to operation issues on every task, like full trays, changes + of quantity, etc... and we can't lock many threads as we could put down the instance. + Something along the lines of queue_job maybe would need to push to the bus the tasks + updates (hard?) Or maybe the kardex proxy from c2c where we use a controller to send + the tasks? This should rely on js, if we want a proper UX. +* Confirming the VLM tasks after the picking is confirmed makes not much sense, but + we're dealing with the quants limitations. Anyway we shouldn't allow to leave + operations on halt and after a real VLM task is done, the picking should be validated. + What to do with the non existing quants (inputs)... maybe we could leave the vlm task + pending assignation, so when we finally validate the picking we just have to perform + the proper links. +* Not a requiste right now, but we could need to support batch pickings. Let's deal + with the basics for now anyway. +* Support inventories. + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Tecnativa + +Contributors +~~~~~~~~~~~~ + +* `Tecnativa `_: + + * David Vidal + * Pedro M. Baeza + +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-chienandalu| image:: https://github.com/chienandalu.png?size=40px + :target: https://github.com/chienandalu + :alt: chienandalu + +Current `maintainer `__: + +|maintainer-chienandalu| + +This module is part of the `OCA/stock-logistics-warehouse `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/stock_vlm_mgmt/__init__.py b/stock_vlm_mgmt/__init__.py new file mode 100644 index 000000000..aee8895e7 --- /dev/null +++ b/stock_vlm_mgmt/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/stock_vlm_mgmt/__manifest__.py b/stock_vlm_mgmt/__manifest__.py new file mode 100644 index 000000000..c552ce316 --- /dev/null +++ b/stock_vlm_mgmt/__manifest__.py @@ -0,0 +1,28 @@ +# Copyright 2023 Tecnativa - David Vidal +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Vertical Lift Module management", + "summary": "Light self contained alternative for VLM integrations", + "version": "14.0.1.0.0", + "author": "Tecnativa, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/stock-logistics-warehouse", + "maintainers": ["chienandalu"], + "license": "AGPL-3", + "category": "Stock", + "depends": ["stock"], + "data": [ + "security/ir.model.access.csv", + "views/assets.xml", + "views/stock_location_vlm_tray_views.xml", + "views/stock_location_views.xml", + "views/stock_picking_views.xml", + "views/stock_quant_views.xml", + "views/stock_vlm_task_views.xml", + "views/stock_quant_vlm_views.xml", + "views/stock_location_tray_type_views.xml", + "wizards/stock_vlm_task_action_views.xml", + ], + "qweb": [ + "static/src/xml/vlm_task_tree_action_buttons_views.xml", + ], +} diff --git a/stock_vlm_mgmt/models/__init__.py b/stock_vlm_mgmt/models/__init__.py new file mode 100644 index 000000000..c6329e37b --- /dev/null +++ b/stock_vlm_mgmt/models/__init__.py @@ -0,0 +1,8 @@ +from . import vlm_tray_cell_position_mixin +from . import stock_location +from . import stock_location_vlm_tray +from . import stock_location_vlm_tray_type +from . import stock_move_line +from . import stock_picking +from . import stock_quant +from . import stock_vlm_task diff --git a/stock_vlm_mgmt/models/stock_location.py b/stock_vlm_mgmt/models/stock_location.py new file mode 100644 index 000000000..9d1876786 --- /dev/null +++ b/stock_vlm_mgmt/models/stock_location.py @@ -0,0 +1,139 @@ +# Copyright 2023 Tecnativa - David Vidal +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import _, fields, models +from odoo.exceptions import UserError + + +class StockLocation(models.Model): + _inherit = "stock.location" + + is_vlm = fields.Boolean() + vlm_vendor = fields.Selection( + selection=[ + ("test", "Test"), + ], + ) + vlm_address = fields.Char() + vlm_hostname = fields.Char() + vlm_port = fields.Char() + vlm_removal_strategy = fields.Selection( + selection=[ + ("fifo", "FIFO"), + ("lifo", "LIFO"), + ("optimal", "Less carrier movements"), + ], + default="fifo", + ) + vlm_tray_ids = fields.One2many( + comodel_name="stock.location.vlm.tray", inverse_name="location_id" + ) + vlm_sequence_id = fields.Many2one(comodel_name="ir.sequence") + vlm_user = fields.Char() + vlm_password = fields.Char() + + def _prepare_vlm_request(self, **kw) -> dict: + return { + "task_type": kw.get("task_type", "release"), + "task_id": self.vlm_sequence_id.next_by_id(), + "address": self.vlm_address, + "carrier": kw.get("carrier", "0"), + "pos_x": kw.get("pos_x", "0"), + "pos_y": kw.get("pos_y", "0"), + "qty": str(kw.get("qty", "0")), + "info1": kw.get("info1", "") or "", + "info2": kw.get("info2", "") or "", + "info3": kw.get("info3", "") or "", + "info4": kw.get("info4", "") or "", + } + + def send_vlm_request(self, data: dict, **options) -> dict: + """Send request to the vendor methods. The vendor connector should deal with + the connection issues transforming the response into these standard codes: + -1: Connection refused + -2: The request is issued but the response is lost + -3: Timeout in the request + -4: VLM Hardware issues + """ + self.ensure_one() + if not hasattr(self, "_%s_vlm_connector" % self.vlm_vendor): + raise UserError(_("No implemented request connector for this vendor!")) + timeout = ( + self.env["ir.config_parameter"].sudo().get_param("stock_vlm_mgmt.timeout") + ) + vlm_connector = getattr(self, "_%s_vlm_connector" % self.vlm_vendor)()( + self.vlm_hostname, + self.vlm_port, + timeout=timeout, + user=self.vlm_user, + password=self.vlm_password, + **options + ) + response = vlm_connector.request_operation(data) + response_code = response.get("code", "") + # These negative codes are issued by the connector and they're common connection + # problems. + if response_code == "-1": + raise UserError( + _("The connection was refused by the VLM and couldn't be stablished.") + ) + elif response_code == "-2": + raise UserError( + _( + "The command response has been lost for unknown reasons. Did you " + "perform the operation on the VLM? Make sure there aren't " + "unconsistencies with the recorded data" + ) + ) + elif response_code == "-3": + raise UserError( + _("The task couldn't be performed due to a timeout in the request"), + ) + elif response_code == "-4": + raise UserError( + _( + "The task couldn't be performed. Try again or check the vertical " + "lift module for hardware issues" + ) + ) + elif response_code == "-5": + raise UserError(_("The task was cancelled by the VLM")) + return response + + def action_release_vlm_trays(self): + """Send to the VLM a special command that releases all the trays""" + data = self._prepare_vlm_request( + task_type="release", + info1=_( + "%(user)s has requested a release of the trays from Odoo", + user=self.env.user.name, + ), + ) + self.send_vlm_request(data) + + def action_view_vlm_tray(self): + action = self.env["ir.actions.act_window"]._for_xml_id( + "stock_vlm_mgmt.location_vlm_tray_action" + ) + action["domain"] = [("id", "in", self.vlm_tray_ids.ids)] + action["context"] = dict(self.env.context, default_location_id=self.id) + return action + + def action_view_vlm_quants(self): + action = self.env["ir.actions.act_window"]._for_xml_id( + "stock_vlm_mgmt.location_quant_vlm_action" + ) + action["domain"] = [("location_id", "=", self.id)] + action["context"] = dict( + self.env.context, + vlm_inventory_mode=True, + ) + view_id = self.env.ref("stock_vlm_mgmt.view_stock_quant_inventory_tree").id + action.update( + { + "view_mode": "tree", + "views": [ + [view_id, "tree"] for view in action["views"] if view[1] == "tree" + ], + } + ) + return action diff --git a/stock_vlm_mgmt/models/stock_location_vlm_tray.py b/stock_vlm_mgmt/models/stock_location_vlm_tray.py new file mode 100644 index 000000000..ff33a398b --- /dev/null +++ b/stock_vlm_mgmt/models/stock_location_vlm_tray.py @@ -0,0 +1,79 @@ +# Copyright 2023 Tecnativa - David Vidal +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import _, api, fields, models + + +class StockLocationVlmTray(models.Model): + _name = "stock.location.vlm.tray" + _description = "Individual trays in a Vertical Lift Module" + + name = fields.Char() + location_id = fields.Many2one( + comodel_name="stock.location", domain=[("is_vlm", "=", True)] + ) + tray_type_id = fields.Many2one(comodel_name="stock.location.vlm.tray.type") + tray_matrix = fields.Serialized(compute="_compute_tray_matrix") + is_full = fields.Boolean() + + @api.depends("tray_type_id") + def _compute_tray_matrix(self): + """Render empty and allocated cells""" + # TODO: Unify computes and optimize query to do it in one shot + for tray in self: + cell_not_empty = self.env["stock.quant.vlm"].search_read( + [("tray_id", "=", tray.id)], ["pos_x", "pos_y", "tray_id"] + ) + tray_matrix = { + "selected": [], + "cells": tray.tray_type_id._generate_cells_matrix(), + "first_empty_cell": [], + } + for position in cell_not_empty: + tray_matrix["cells"][position["pos_y"] - 1][position["pos_x"] - 1] = 1 + for row, cells in enumerate(tray_matrix["cells"]): + if 0 not in cells: + continue + tray_matrix["first_empty_cell"] = [cells.index(0), row] + break + tray.tray_matrix = tray_matrix + + def action_tray_content(self, pos_x=None, pos_y=None): + """See the vlm quants belonging to the tray""" + self.ensure_one() + domain = [("tray_id", "=", self.id)] + if pos_x and pos_y: + domain += [("pos_x", "=", pos_x), ("pos_y", "=", pos_y)] + vlm_quant = self.env["stock.quant.vlm"].search(domain) + action = self.env["ir.actions.act_window"]._for_xml_id( + "stock_vlm_mgmt.location_quant_vlm_action" + ) + self.env.ref("stock_vlm_mgmt.view_location_form") + action["domain"] = [("id", "in", vlm_quant.ids)] + action["context"] = dict( + self.env.context, + default_tray_id=self.id, + default_location_id=self.location_id.id, + vlm_inventory_mode=True, + ) + view_id = self.env.ref("stock_vlm_mgmt.view_stock_quant_inventory_tree").id + action.update( + { + "view_mode": "tree", + "views": [ + [view_id, "tree"] for view in action["views"] if view[1] == "tree" + ], + } + ) + return action + + def action_tray_call(self): + """Send to the VLM a special command that calls this tray""" + data = self.location_id._prepare_vlm_request( + task_type="count", + carrier=self.name, + info1=_( + "%(user)s has requested a release of the trays from Odoo", + user=self.env.user.name, + ), + ) + self.location_id.with_context(vlm_tray_call=True).send_vlm_request(data) diff --git a/stock_vlm_mgmt/models/stock_location_vlm_tray_type.py b/stock_vlm_mgmt/models/stock_location_vlm_tray_type.py new file mode 100644 index 000000000..6dcb76943 --- /dev/null +++ b/stock_vlm_mgmt/models/stock_location_vlm_tray_type.py @@ -0,0 +1,66 @@ +# Copyright 2019 Camptocamp SA +# Copyright 2024 Tecnativa - David Vidal +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import api, fields, models +from odoo.osv import expression + + +class StockLocationVlmTrayType(models.Model): + _name = "stock.location.vlm.tray.type" + _description = "VLM Tray configuration" + + active = fields.Boolean(default=True) + name = fields.Char(required=True) + code = fields.Char(required=True) + rows = fields.Integer(required=True) + cols = fields.Integer(required=True) + width = fields.Integer(help="Width of the tray in mm") + depth = fields.Integer(help="Depth of the tray in mm") + height = fields.Integer(help="Height of the tray in mm") + width_per_cell = fields.Float(compute="_compute_width_per_cell") + depth_per_cell = fields.Float(compute="_compute_depth_per_cell") + tray_matrix = fields.Serialized(compute="_compute_tray_matrix") + + @api.depends("width", "cols") + def _compute_width_per_cell(self): + for record in self: + width = record.width + if not width: + record.width_per_cell = 0.0 + continue + record.width_per_cell = width / record.cols + + @api.depends("depth", "rows") + def _compute_depth_per_cell(self): + for record in self: + depth = record.depth + if not depth: + record.depth_per_cell = 0.0 + continue + record.depth_per_cell = depth / record.rows + + @api.depends("rows", "cols") + def _compute_tray_matrix(self): + for record in self: + # As we only want to show the disposition of + # the tray, we generate a "full" tray, we'll + # see all the boxes on the web widget. + # (0 means empty, 1 means used) + cells = self._generate_cells_matrix(default_state=1) + record.tray_matrix = {"selected": [], "cells": cells} + + @api.model + def _name_search( + self, name, args=None, operator="ilike", limit=100, name_get_uid=None + ): + args = args or [] + domain = [] + if name: + domain = ["|", ("name", operator, name), ("code", operator, name)] + + return self._search( + expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid + ) + + def _generate_cells_matrix(self, default_state=0): + return [[default_state] * self.cols for __ in range(self.rows)] diff --git a/stock_vlm_mgmt/models/stock_move_line.py b/stock_vlm_mgmt/models/stock_move_line.py new file mode 100644 index 000000000..76e5b0cdb --- /dev/null +++ b/stock_vlm_mgmt/models/stock_move_line.py @@ -0,0 +1,152 @@ +# Copyright 2023 Tecnativa - David Vidal +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from collections import defaultdict + +from odoo import api, fields, models + + +class StockMoveLine(models.Model): + _inherit = "stock.move.line" + + has_vlm_operation = fields.Boolean(compute="_compute_has_vlm_operation") + vlm_pending_quantity = fields.Float( + help="Quantity pending on VLM Operations", + default=0.0, + digits="Product Unit of Measure", + copy=False, + store=True, + readonly=False, + compute="_compute_vlm_pending_quantity", + ) + + @api.depends("location_id", "location_dest_id") + def _compute_has_vlm_operation(self): + self.has_vlm_operation = False + if self.env.context.get("skip_vlm_task"): + return + self.filtered( + lambda x: x.location_id.is_vlm or x.location_dest_id.is_vlm + ).has_vlm_operation = True + + @api.depends("qty_done") + def _compute_vlm_pending_quantity(self): + """Pair the reported quantities to the done ones""" + # TODO: Maybe get rid of these and rely on operation task once they're created + self.vlm_pending_quantity = 0 + for line in self.filtered("has_vlm_operation"): + line.vlm_pending_quantity = line.qty_done + + def _prepare_vlm_put_task(self, key): + """Over the recordset of detailed operations return a list of tasks to + perform""" + product, location = key + quants = self.env["stock.quant"].search( + [ + ("product_id", "=", product.id), + ("location_id", "=", location.id), + ] + ) + # Get free spots + # TODO: Sort them according to their strategy + existing_quants = quants.vlm_quant_ids.filtered(lambda x: not x.tray_id.is_full) + location_trays = location.vlm_tray_ids.filtered(lambda x: not x.is_full) + # This is our demand for this product we'll try to fulfill it in one shot. + # Anyway we could use other trays if one gets full + quantity_fulfilled = sum(self.mapped("qty_done")) + task_vlm_quant = fields.first(existing_quants) + trays = location_trays - existing_quants.tray_id + first_tray = task_vlm_quant.tray_id or fields.first(trays) + if task_vlm_quant: + pos_x = task_vlm_quant.pos_x + pos_y = task_vlm_quant.pos_y + else: + pos_x, pos_y = first_tray.tray_matrix.get("first_empty_cell") + return { + "quantity_pending": quantity_fulfilled, + "product_id": product.id, + "state": "pending", + "vlm_quant_id": task_vlm_quant.id, + "location_id": first_tray.location_id.id, + "reference": ",".join(self.mapped("reference")), + "tray_id": first_tray.id, + "pos_x": pos_x, + "pos_y": pos_y, + "move_line_ids": [(6, 0, self.ids)], + "task_type": "put", + } + + def _prepare_vlm_get_tasks(self, key): + """Over the recordset of detailed operations return a list of tasks to + perform""" + product, location = key + quants = self.env["stock.quant"].search( + [ + ("product_id", "=", product.id), + ("location_id", "=", location.id), + ] + ) + # TODO: Sort them according to their strategy + # This is our demand for this product we'll try to fulfill it in one shot. + # Anyway we could use other trays if one gets full + pending_quantity = sum(self.mapped("qty_done")) + tasks = [] + for vlm_quant in quants.vlm_quant_ids: + qty = min(pending_quantity, vlm_quant.quantity) + tasks.append( + { + "quantity_pending": qty, + "product_id": product.id, + "state": "pending", + "vlm_quant_id": vlm_quant.id, + "tray_id": vlm_quant.tray_id.id, + "location_id": vlm_quant.location_id.id, + "quant_id": vlm_quant.quant_id.id, + "pos_x": vlm_quant.pos_x, + "pos_y": vlm_quant.pos_y, + "move_line_ids": [(6, 0, self.ids)], + "task_type": "get", + "reference": ",".join(self.mapped("reference")), + } + ) + pending_quantity -= qty + if pending_quantity <= 0: + break + return tasks + + def _action_done(self): + """Once we've completed our ml operations we need to complete them in the VLM. + For that, we'll prepare the needed operations for each move line. Each move line + could have several operations and one operation could correspond to several move + lines. Some simple example: + - A move line from vlm1 to vlm2 will split into two operations: one to get the + products from vlm1 and another to put the products into vlm2. + - If we've got several move lines for the same quant, we don't want to do an + operation for each one. We'll try to merge them into a single operation so we + minimize tray calls. + """ + res = super()._action_done() + if self.env.context.get("skip_vlm_task"): + return res + sml = self.exists() + # 1. Prepare put operations + put_in_vlm = sml.filtered(lambda x: x.qty_done and x.location_dest_id.is_vlm) + # Let's group lines by common features. For this first design we don't care + # about lot, owner, etc. Maybe in the feature this is relevant + put_dict = defaultdict(self.browse) + for line in put_in_vlm: + put_dict[(line.product_id, line.location_dest_id)] += line + put_task_values = [] + for key, lines in put_dict.items(): + put_task_values.append(lines._prepare_vlm_put_task(key)) + self.env["stock.vlm.task"].create(put_task_values) + # 2. Prepare get operations + get_from_vlm = sml.filtered(lambda x: x.qty_done and x.location_id.is_vlm) + # TODO: Again we group lines by common features, merge into single method + get_dict = defaultdict(self.browse) + for line in get_from_vlm: + get_dict[(line.product_id, line.location_id)] += line + get_task_values = [] + for key, lines in get_dict.items(): + get_task_values += lines._prepare_vlm_get_tasks(key) + self.env["stock.vlm.task"].create(get_task_values) + return res diff --git a/stock_vlm_mgmt/models/stock_picking.py b/stock_vlm_mgmt/models/stock_picking.py new file mode 100644 index 000000000..4a9431c4f --- /dev/null +++ b/stock_vlm_mgmt/models/stock_picking.py @@ -0,0 +1,85 @@ +# Copyright 2023 Tecnativa - David Vidal +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import api, fields, models + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + vlm_move_line_ids = fields.Many2many( + comodel_name="stock.move.line", compute="_compute_vlm_move_line_ids" + ) + vlm_pending_move_line_ids = fields.Many2many( + comodel_name="stock.move.line", compute="_compute_vlm_move_line_ids" + ) + has_vlm_operations = fields.Boolean(compute="_compute_has_vlm_operations") + has_vlm_pending_operations = fields.Boolean( + compute="_compute_has_vlm_pending_operations" + ) + vlm_task_ids = fields.Many2many( + comodel_name="stock.vlm.task", + compute="_compute_vlm_task_ids", + ) + has_pending_vlm_tasks = fields.Boolean(compute="_compute_has_pending_vlm_tasks") + + @api.depends("location_id", "location_dest_id") + def _compute_vlm_move_line_ids(self): + for pick in self: + pick.vlm_move_line_ids = pick.move_line_ids.filtered( + lambda x: x.location_id.is_vlm or x.location_dest_id.is_vlm + ) + pick.vlm_pending_move_line_ids = pick.vlm_move_line_ids.filtered( + "vlm_pending_quantity" + ) + + @api.depends("vlm_move_line_ids") + def _compute_has_vlm_operations(self): + for pick in self: + pick.has_vlm_operations = bool(pick.vlm_move_line_ids) + + @api.depends("vlm_pending_move_line_ids") + def _compute_has_vlm_pending_operations(self): + """We need to complete the quantities in the VLM""" + self.has_vlm_pending_operations = False + for pick in self.filtered(lambda x: x.state == "done"): + pick.has_vlm_pending_operations = bool(pick.vlm_pending_move_line_ids) + + @api.depends("state") + def _compute_vlm_task_ids(self): + self.vlm_task_ids = False + for pick in self.filtered(lambda x: x.state == "done"): + pick.vlm_task_ids = self.env["stock.vlm.task"].search( + [ + ("move_line_ids", "in", pick.move_line_ids.ids), + ] + ) + + def _compute_has_pending_vlm_tasks(self): + """Helper to show the task action button""" + self.has_pending_vlm_tasks = False + for pick in self: + pick.has_pending_vlm_tasks = bool( + pick.vlm_task_ids.filtered(lambda x: x.state != "done") + ) + + def action_do_vlm_tasks(self): + """Perform the VLM pending tasks""" + return self.vlm_task_ids.filtered(lambda x: x.state != "done").action_do_tasks() + + def action_open_vlm_task(self): + action = self.env["ir.actions.actions"]._for_xml_id( + "stock_vlm_mgmt.vlm_task_action" + ) + # Let's pre-sort the task so we always first get and then put. i.e.: when we're + # moving goods from one VLM to another + get_tasks = self.vlm_task_ids.filtered(lambda x: x.task_type == "get").sorted( + lambda x: (x.location_id, x.tray_id, x.product_id) + ) + put_tasks = self.vlm_task_ids.filtered(lambda x: x.task_type == "put").sorted( + lambda x: (x.location_id, x.tray_id, x.product_id) + ) + vlm_tasks = get_tasks + put_tasks + action["name"] = f"SVM tasks for {self.name}" + action["domain"] = [("id", "in", vlm_tasks.ids)] + action["context"] = dict(self.env.context, search_default_pending=1) + return action diff --git a/stock_vlm_mgmt/models/stock_quant.py b/stock_vlm_mgmt/models/stock_quant.py new file mode 100644 index 000000000..2ea1044bd --- /dev/null +++ b/stock_vlm_mgmt/models/stock_quant.py @@ -0,0 +1,117 @@ +# Copyright 2023 Tecnativa - David Vidal +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.osv import expression +from odoo.tools.float_utils import float_compare, float_round + + +class StockQuant(models.Model): + _inherit = "stock.quant" + + vlm_quant_ids = fields.One2many( + comodel_name="stock.quant.vlm", inverse_name="quant_id" + ) + + def action_view_in_vlm_structure(self): + """Open the VLM structure filtering by this product to locate it easily""" + action = self.location_id.action_view_vlm_quants() + action["domain"] = expression.AND( + [action["domain"], [("product_id", "=", self.product_id.id)]] + ) + return action + + +class StockQuantVlm(models.Model): + _name = "stock.quant.vlm" + _inherit = ["vlm.tray.cell.position.mixin"] + _description = "Vertical Lift Module structure inside the quant" + _rec_name = "product_id" + + quant_id = fields.Many2one(comodel_name="stock.quant") + location_id = fields.Many2one(comodel_name="stock.location") + product_id = fields.Many2one(comodel_name="product.product", required=True) + tray_id = fields.Many2one(comodel_name="stock.location.vlm.tray") + tray_type_id = fields.Many2one(related="tray_id.tray_type_id") + quantity = fields.Float() + + @api.model + def _is_inventory_mode(self): + """As in stock.quant inventory mode, we have an special context to trigger + the update of the linked quant. This way we can make partial stocks tray + by tray just setting the difference (positive or negative in the quant)""" + return self.env.context.get("vlm_inventory_mode") and self.user_has_groups( + "stock.group_stock_manager" + ) + + def _set_inventory_quantity(self, inventory_quantity, skip_diff=None): + """Update the related quant quantity according to the part of the counted + quant or tray""" + if not self._is_inventory_mode(): + return + for vlm_quant in self.filtered(lambda x: x.product_id.type == "product"): + rounding = vlm_quant.product_id.uom_id.rounding + # TODO: Support lots and other quant stuff + quant = vlm_quant.quant_id or vlm_quant.quant_id.search( + [ + ("product_id", "=", vlm_quant.product_id.id), + ("location_id", "=", vlm_quant.location_id.id), + ], + limit=1, + ) + quant_quantity = quant.quantity + diff = float_round( + inventory_quantity - vlm_quant.quantity, precision_rounding=rounding + ) + diff_float_compared = float_compare(diff, 0, precision_rounding=rounding) + # When we create a new record, the diff would be 0 + if skip_diff: + diff = diff_float_compared = inventory_quantity + # Update the related quant or create a brand new one + if diff_float_compared == 0: + continue + quant_quantity += diff + # We want to skip the VLM tasks as we're setting the inventory on hand + quant = vlm_quant.quant_id.with_context( + inventory_mode=True, skip_vlm_task=True + ) + if quant: + quant.inventory_quantity = quant_quantity + else: + vlm_quant.quant_id = quant.create( + { + "product_id": self.product_id.id, + "location_id": self.location_id.id, + "inventory_quantity": quant_quantity, + } + ) + + def write(self, vals): + if self._is_inventory_mode() and "quantity" in vals: + self._set_inventory_quantity(vals["quantity"]) + if "product_id" in vals: + raise UserError(_("You can't change the product")) + return super().write(vals) + + @api.model + def create(self, vals): + vlm_quant = super().create(vals) + if self._is_inventory_mode(): + vlm_quant._set_inventory_quantity(vals["quantity"], skip_diff=True) + return vlm_quant + + def unlink(self): + """We need to trigger the quantities update whenever this happens""" + for vlm_quant in self: + vlm_quant.with_context(vlm_inventory_mode=True).quantity = 0 + return super().unlink() + + def action_task_detail(self): + return { + "view_mode": "form", + "res_model": "stock.quant.vlm", + "res_id": self.id, + "type": "ir.actions.act_window", + "context": self._context, + "target": "new", + } diff --git a/stock_vlm_mgmt/models/stock_vlm_task.py b/stock_vlm_mgmt/models/stock_vlm_task.py new file mode 100644 index 000000000..fdc009cca --- /dev/null +++ b/stock_vlm_mgmt/models/stock_vlm_task.py @@ -0,0 +1,192 @@ +# Copyright 2023 Tecnativa - David Vidal +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from concurrent.futures import ThreadPoolExecutor, as_completed + +from odoo import api, fields, models +from odoo.tools import float_compare, float_is_zero + + +class VlmOperationTask(models.Model): + _name = "stock.vlm.task" + _inherit = ["vlm.tray.cell.position.mixin"] + _description = "Vertical Lift Module task" + + display_name = fields.Char(compute="_compute_display_name") + vlm_quant_id = fields.Many2one(comodel_name="stock.quant.vlm") + quant_id = fields.Many2one(comodel_name="stock.quant") + move_line_ids = fields.Many2many(comodel_name="stock.move.line") + product_id = fields.Many2one(comodel_name="product.product") + location_id = fields.Many2one(comodel_name="stock.location") + tray_id = fields.Many2one(comodel_name="stock.location.vlm.tray") + tray_type_id = fields.Many2one(related="tray_id.tray_type_id") + splitted_vlm_task_id = fields.Many2one(comodel_name="stock.vlm.task") + quantity_pending = fields.Float(digits="Product Unit of Measure") + quantity_done = fields.Float(digits="Product Unit of Measure") + reference = fields.Char(default="") + state = fields.Selection( + selection=[ + ("pending", "Pending"), + ("waiting", "Waiting"), + ("done", "Done"), + ], + ) + task_type = fields.Selection( + selection=[ + ("put", "Put"), + ("get", "Get"), + ("count", "Count"), + ], + ) + skipped = fields.Boolean() + + @api.depends("task_type", "quantity_pending", "reference", "location_id", "tray_id") + def _compute_display_name(self): + """E.g.: WH/003: Put 80.0 Units of [RS200] Running socks in KARDEX ⇨ tray 31""" + for task in self: + qty = task.quantity_pending if task.state != "done" else task.quantity_done + task.display_name = ( + f"{task.reference}: " + f"{task.task_type.capitalize()} {qty} " + f"{task.product_id.uom_id.name} of {task.product_id.display_name} " + f"{'in' if task.task_type == 'put' else 'from'} " + f"{task.location_id.name} ⇨ TRAY {task.tray_id.name}" + ) + + def action_command_task_threaded(self): + # TODO: Not working, fails on environment or cursor things. Not usable + with api.Environment.manage(), self.pool.cursor() as new_cr: + self = self.with_env(self.env(cr=new_cr)) + with ThreadPoolExecutor() as executor: + futures = [] + for task in self: + futures.append(executor.submit(task.action_command_task)) + for future in as_completed(futures): + yield future.result() + + def action_command_task(self): + """Send to the VLM then needed operations""" + self.state = "waiting" + pos_x, pos_y = self.tray_cell_center_position() + response = self.location_id.send_vlm_request( + self.location_id._prepare_vlm_request( + task_type=self.task_type, + carrier=self.tray_id.name, + pos_x=pos_x, + pos_y=pos_y, + qty=self.quantity_pending, + info1=self.reference, + info2=self.product_id.display_name, + ) + ) + # The only relevant thing we get in the response is the quantity + return self._post_command(float(response.get("qty", "0"))) + + def _post_command(self, quantity_done): + """What to do depending on the VLM response""" + quantity_compare = float_compare( + quantity_done, + self.quantity_pending, + precision_rounding=self.product_id.uom_id.rounding, + ) + # There could be several cases: + # A) The done quantity is equal to the task quantity: task is done + if quantity_compare == 0: + self._set_done(quantity_done) + # Set the pending quantity of the mls to 0 + self.move_line_ids.vlm_pending_quantity = 0 + self._update_quantities() + return ("done", self) + # B) The done quantity is lower than the task quantity: + # - Was the tray full?: propose another task + # - TODO: The quantity couldn't be fulfilled: the picking was wrong + elif quantity_compare < 0: + # Return it as it is + if float_is_zero(quantity_done, self.product_id.uom_id.rounding): + return ("zero_quantity", self) + # Add an extra task for the remaining quantity and launch the wizard + # so the user decides where to put them + new_task = self._action_split(quantity_done) + self._update_quantities() + # Set the new task position and command it to the VLM + return ("split", new_task) + # C) Then done quantity is higher: + # - TODO: Was the picking was wrong? + elif quantity_compare > 0: + # Show the issue to the user + if not self.env.context.get("skip_vlm_mismatch"): + return ( + "mismatch_greater", + self.with_context(vlm_task_action_warning="mismatch_greater"), + ) + # TODO: Just confirm the qtys? Something else to do? It isnt't going + # to match with the quant! + self._set_done(quantity_done) + self._update_quantities() + + def _set_done(self, quantity_done=None): + quantity_done = quantity_done or self.quantity_pending + self.quantity_pending = 0 + self.state = "done" + self.quantity_done = quantity_done + + def _action_skip(self): + """The task isn't done (it can be done later) or be finished manually""" + self.skipped = True + self.state = "done" + + def _action_split(self, quantity_done): + """Divide a partially done task""" + new_task = self.copy( + { + "quantity_pending": self.quantity_pending - quantity_done, + "splitted_vlm_task_id": self.id, + "state": "pending", + } + ) + self._set_done(quantity_done) + return new_task + + def _update_quantities(self): + if self.vlm_quant_id: + if self.task_type == "put": + self.vlm_quant_id.quantity += self.quantity_done + elif self.task_type == "get": + self.vlm_quant_id.quantity -= self.quantity_done + elif self.task_type == "put": + # For lots we should refine the search from the linked move lines + quant = self.env["stock.quant"].search( + [ + ("location_id", "=", self.location_id.id), + ("product_id", "=", self.product_id.id), + ], + limit=1, + ) + vlm_quant = self.env["stock.quant.vlm"].create( + { + "quant_id": quant.id, + "product_id": self.product_id.id, + "location_id": self.location_id.id, + "tray_id": self.tray_id.id, + "pos_x": self.pos_x, + "pos_y": self.pos_y, + "quantity": self.quantity_done, + } + ) + self.quant_id = quant + self.vlm_quant_id = vlm_quant + + def action_do_tasks(self): + """Perform the tasks sequentially""" + action = self.env["ir.actions.actions"]._for_xml_id( + "stock_vlm_mgmt.action_vlm_task_action" + ) + tasks = self.filtered(lambda x: x.state != "done" or x.skipped) + if not tasks: + return + action["name"] = f"VLM task {1} of {len(self.ids)}" + action["context"] = dict( + self.env.context, + default_vlm_task_id=tasks.ids[0], + default_vlm_task_ids=tasks.ids, + ) + return action diff --git a/stock_vlm_mgmt/models/vlm_tray_cell_position_mixin.py b/stock_vlm_mgmt/models/vlm_tray_cell_position_mixin.py new file mode 100644 index 000000000..2ad399c18 --- /dev/null +++ b/stock_vlm_mgmt/models/vlm_tray_cell_position_mixin.py @@ -0,0 +1,55 @@ +# Copyright 2019 Camptocamp SA +# Copyright 2024 Tecnativa - David Vidal +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import api, fields, models + + +class VlmTrayCellPositionMixin(models.AbstractModel): + _name = "vlm.tray.cell.position.mixin" + _description = "Tray position helpers" + + tray_id = fields.Many2one(comodel_name="stock.location.vlm.tray") + tray_type_id = fields.Many2one(comodel_name="stock.location.vlm.tray.type") + tray_matrix = fields.Serialized(compute="_compute_tray_matrix") + pos_x = fields.Integer() + pos_y = fields.Integer() + + @api.depends("pos_x", "pos_y", "tray_type_id", "tray_id") + def _compute_tray_matrix(self): + self.tray_matrix = {} + for record in self.filtered("tray_type_id"): + cell_not_empty = self.env["stock.quant.vlm"].search_read( + [("tray_id", "=", record.tray_id.id)], ["pos_x", "pos_y"] + ) + tray_matrix = { + "selected": [record.pos_x, record.pos_y], + "cells": record.tray_type_id._generate_cells_matrix(), + "first_empty_cell": False, + } + for position in cell_not_empty: + # Let's be gentle with positioning errors. + try: + tray_matrix["cells"][position["pos_y"]][position["pos_x"]] = 1 + except IndexError: + pass + for row, cells in enumerate(tray_matrix["cells"]): + if 0 not in cells: + continue + tray_matrix["first_empty_cell"] = [cells.index(0), row] + break + record.tray_matrix = tray_matrix + + def tray_cell_center_position(self, pos_x=None, pos_y=None): + """Center position in mm of a cell. Used to position the laser pointer. + @return {tuple} millimeters from bottom-left corner (left, bottom) + """ + if not self.tray_type_id: + return 0, 0 + pos_x = pos_x or self.pos_x + pos_y = pos_y or self.pos_y + cell_width = self.tray_type_id.width_per_cell + cell_depth = self.tray_type_id.depth_per_cell + # pos_x and pos_y start at one, we want to count from 0 + from_left = pos_x * cell_width + (cell_width / 2) + from_bottom = pos_y * cell_depth + (cell_depth / 2) + return int(from_left), int(from_bottom) diff --git a/stock_vlm_mgmt/readme/CONTRIBUTORS.rst b/stock_vlm_mgmt/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..b9b928d44 --- /dev/null +++ b/stock_vlm_mgmt/readme/CONTRIBUTORS.rst @@ -0,0 +1,4 @@ +* `Tecnativa `_: + + * David Vidal + * Pedro M. Baeza diff --git a/stock_vlm_mgmt/readme/DESCRIPTION.rst b/stock_vlm_mgmt/readme/DESCRIPTION.rst new file mode 100644 index 000000000..48b4f1727 --- /dev/null +++ b/stock_vlm_mgmt/readme/DESCRIPTION.rst @@ -0,0 +1,3 @@ +This module adds basic a management system for Vertical Lift Modules. It's thought as +a simpler alternative attemp to stock_vertical_lift and all the dependencies that +come with it. diff --git a/stock_vlm_mgmt/readme/ROADMAP.rst b/stock_vlm_mgmt/readme/ROADMAP.rst new file mode 100644 index 000000000..a9d02326a --- /dev/null +++ b/stock_vlm_mgmt/readme/ROADMAP.rst @@ -0,0 +1,18 @@ +* Launch the tasks in batches so we don't have to send them to the VLM one by one. In + the case of Kardex, we'll be dealing with the connection limitations. If we send a + list of tasks, right now we're closing the connection once we receive a response (Kardex). + We need to keep listening until all the ids are received, but that locks our thread... + We also need to respond to operation issues on every task, like full trays, changes + of quantity, etc... and we can't lock many threads as we could put down the instance. + Something along the lines of queue_job maybe would need to push to the bus the tasks + updates (hard?) Or maybe the kardex proxy from c2c where we use a controller to send + the tasks? This should rely on js, if we want a proper UX. +* Confirming the VLM tasks after the picking is confirmed makes not much sense, but + we're dealing with the quants limitations. Anyway we shouldn't allow to leave + operations on halt and after a real VLM task is done, the picking should be validated. + What to do with the non existing quants (inputs)... maybe we could leave the vlm task + pending assignation, so when we finally validate the picking we just have to perform + the proper links. +* Not a requiste right now, but we could need to support batch pickings. Let's deal + with the basics for now anyway. +* Support inventories. diff --git a/stock_vlm_mgmt/security/ir.model.access.csv b/stock_vlm_mgmt/security/ir.model.access.csv new file mode 100644 index 000000000..f2addfc2a --- /dev/null +++ b/stock_vlm_mgmt/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_stock_quant_vlm,access_stock_quant_vlm,model_stock_quant_vlm,stock.group_stock_user,1,1,1,1 +access_stock_location_vlm_tray_user,access_stock_location_vlm_tray,model_stock_location_vlm_tray,stock.group_stock_user,1,0,0,0 +access_stock_location_vlm_tray_manager,access_stock_location_vlm_tray,model_stock_location_vlm_tray,stock.group_stock_manager,1,1,1,1 +access_vlm_task,access_vlm_task,model_stock_vlm_task,stock.group_stock_user,1,1,1,1 +access_vlm_task_action,access_vlm_task_action,model_stock_vlm_task_action,stock.group_stock_user,1,1,1,1 +access_stock_location_vlm_tray_type,access_stock_location_vlm_tray_type,model_stock_location_vlm_tray_type,stock.group_stock_user,1,1,1,1 diff --git a/stock_vlm_mgmt/static/description/index.html b/stock_vlm_mgmt/static/description/index.html new file mode 100644 index 000000000..9c2eed41f --- /dev/null +++ b/stock_vlm_mgmt/static/description/index.html @@ -0,0 +1,452 @@ + + + + + +Vertical Lift Module management + + + +
+

Vertical Lift Module management

+ + +

Beta License: AGPL-3 OCA/stock-logistics-warehouse Translate me on Weblate Try me on Runboat

+

This module adds basic a management system for Vertical Lift Modules. It’s thought as +a simpler alternative attemp to stock_vertical_lift and all the dependencies that +come with it.

+

Table of contents

+ +
+

Known issues / Roadmap

+
    +
  • Launch the tasks in batches so we don’t have to send them to the VLM one by one. In +the case of Kardex, we’ll be dealing with the connection limitations. If we send a +list of tasks, right now we’re closing the connection once we receive a response (Kardex). +We need to keep listening until all the ids are received, but that locks our thread… +We also need to respond to operation issues on every task, like full trays, changes +of quantity, etc… and we can’t lock many threads as we could put down the instance. +Something along the lines of queue_job maybe would need to push to the bus the tasks +updates (hard?) Or maybe the kardex proxy from c2c where we use a controller to send +the tasks? This should rely on js, if we want a proper UX.
  • +
  • Confirming the VLM tasks after the picking is confirmed makes not much sense, but +we’re dealing with the quants limitations. Anyway we shouldn’t allow to leave +operations on halt and after a real VLM task is done, the picking should be validated. +What to do with the non existing quants (inputs)… maybe we could leave the vlm task +pending assignation, so when we finally validate the picking we just have to perform +the proper links.
  • +
  • Not a requiste right now, but we could need to support batch pickings. Let’s deal +with the basics for now anyway.
  • +
  • Support inventories.
  • +
+
+
+

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 to smash it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Tecnativa
  • +
+
+
+

Contributors

+
    +
  • Tecnativa:
      +
    • David Vidal
    • +
    • Pedro M. Baeza
    • +
    +
  • +
+
+
+

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 maintainer:

+

chienandalu

+

This module is part of the OCA/stock-logistics-warehouse project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/stock_vlm_mgmt/static/src/js/stock_location_tray.js b/stock_vlm_mgmt/static/src/js/stock_location_tray.js new file mode 100644 index 000000000..05de24521 --- /dev/null +++ b/stock_vlm_mgmt/static/src/js/stock_location_tray.js @@ -0,0 +1,284 @@ +/* Copyright 2019 Camptocamp SA + Copyright 2024 Tecnativa - David Vidal + License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).*/ +odoo.define("stock_location_tray.tray", function (require) { + "use strict"; + + var basicFields = require("web.basic_fields"); + var field_registry = require("web.field_registry"); + var DebouncedField = basicFields.DebouncedField; + + /** + * Shows a canvas with the Tray's cells + * + * An action can be configured which is called when a cell is clicked. + * The action must be an action.multi, it will receive the x and y positions + * of the cell clicked (starting from 0). The action must be configured in + * the options of the field and be on the same model: + * + * + * + */ + var LocationTrayMatrixField = DebouncedField.extend({ + className: "o_field_location_tray_matrix", + tagName: "canvas", + supportedFieldTypes: ["serialized"], + events: { + click: "_onClick", + }, + + cellColorEmpty: "#ffffff", + cellColorNotEmpty: "#00A09D", + selectedColor: "#ffc107", + selectedLineWidth: 5, + globalAlpha: 0.8, + cellPadding: 2, + + init: function (parent, name, record, options) { + this._super.apply(this, arguments); + this.nodeOptions = _.defaults(this.nodeOptions, {}); + if ("clickAction" in (options || {})) { + this.clickAction = options.clickAction; + } else { + this.clickAction = this.nodeOptions.click_action; + } + this.liveCellEdit = + !this.clickAction && + this.nodeOptions.live_cell_edit && + this.getParent().mode === "edit"; + }, + + isSet: function () { + if (Object.keys(this.value).length === 0) { + return false; + } + if (this.value.cells.length === 0) { + return false; + } + return this._super.apply(this, arguments); + }, + + start: function () { + // Setup resize events to redraw the canvas + this._resizeDebounce = this._resizeDebounce.bind(this); + this._resizePromise = null; + $(window).on("resize", this._resizeDebounce); + return this._super.apply(this, arguments).then(() => { + if (this.clickAction || this.liveCellEdit) { + this.$el.css("cursor", "pointer"); + } + // _super calls _render(), but the function + // resizeCanvasToDisplaySize would resize the canvas + // to 0 because the actual canvas would still be unknown. + // Call again _render() here but through a setTimeout to + // let the js renderer thread catch up. + this._ready = true; + return this._resizeDebounce(); + }); + }, + + _onClick: function (ev) { + ev.preventDefault(); + if (!this.isSet()) { + return; + } + if (!this.clickAction && !this.liveCellEdit) { + return; + } + var width = this.canvas.width, + height = this.canvas.height, + rect = this.canvas.getBoundingClientRect(); + + var clickX = ev.clientX - rect.left, + clickY = ev.clientY - rect.top; + + var cells = this.value.cells, + cols = cells[0].length, + rows = cells.length; + + // We remove 1 to start counting from 0 + var coordX = Math.ceil((clickX * cols) / width) - 1, + coordY = Math.ceil((clickY * rows) / height) - 1; + // If we click on the last pixel on the bottom or the right + // we would get an offset index + if (coordX >= cols) { + coordX = cols - 1; + } + if (coordY >= rows) { + coordY = rows - 1; + } + // The coordinate we get when we click is from top, + // but we are looking for the coordinate from the bottom + // to match the user's expectations, invert Y + coordY = Math.abs(coordY - rows + 1); + // Allow to edit the position on the tray + if (this.liveCellEdit) { + this.value.selected = [coordX, coordY]; + this.trigger_up("field_changed", { + dataPointID: this.dataPointID, + changes: { + pos_x: coordX, + pos_y: coordY, + }, + }); + this._render(); + } + // Perform an action over the cell when it has contents + if (this.clickAction && this.value.cells[coordY][coordX] === 1) { + ev.stopPropagation(); + this.trigger_up("button_clicked", { + attrs: { + type: "object", + name: this.clickAction, + args: `[${coordX}, ${coordY}]`, + }, + record: this.record, + }); + } + }, + + /** + * Debounce the rendering on resize. + * It is useless to render on each resize event. + * + */ + _resizeDebounce: function () { + clearTimeout(this._resizePromise); + this._resizePromise = setTimeout(() => { + this._render(); + }, 20); + }, + + destroy: function () { + $(window).off("resize", this._resizeDebounce); + this._super.apply(this, arguments); + }, + + /** + * Render the widget only when it is in the DOM. + * We need the width and height of the widget to draw the canvas. + * + * @returns {Promise} + */ + _render: function () { + if (this._ready) { + return this._renderInDOM(); + } + return $.when(); + }, + + /** + * Resize the canvas width and height to the actual size. + * If we don't do that, it will automatically scale to the + * CSS size with blurry squares. + * + * @param {jQueryElement} canvas - the DOM canvas to draw + * @returns {Boolean} + */ + resizeCanvasToDisplaySize: function (canvas) { + // Look up the size the canvas is being displayed + var width = canvas.clientWidth; + var height = canvas.clientHeight; + + // If it's resolution does not match change it + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + return true; + } + return false; + }, + + /** + * Resize the canvas, clear it and redraw the cells + * Should be called only if the canvas is already in DOM + * + */ + _renderInDOM: function () { + this.canvas = this.$el[0]; + var canvas = this.canvas; + var ctx = canvas.getContext("2d"); + this.resizeCanvasToDisplaySize(ctx.canvas); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.save(); + if (this.isSet()) { + var selected = this.value.selected || []; + var cells = this.value.cells; + this._drawMatrix(canvas, ctx, cells, selected); + } + }, + + /** + * Draw the cells in the canvas. + * + * @param {jQueryElement} canvas - the DOM canvas to draw + * @param {Object} ctx - the canvas 2d context + * @param {List} cells - A 2-dimensional list of cells + * @param {List} selected - A list containing the position (x,y) of the + * selected cell (can be empty if no cell is selected) + */ + _drawMatrix: function (canvas, ctx, cells, selected) { + var colors = { + 0: this.cellColorEmpty, + 1: this.cellColorNotEmpty, + }; + var cols = cells[0].length; + var rows = cells.length; + var selectedX = null, + selectedY = null; + if (selected.length) { + selectedX = selected[0]; + // We draw top to bottom, but the highlighted cell should + // be a coordinate from bottom to top: reverse the y axis + selectedY = Math.abs(selected[1] - rows + 1); + } + var padding = this.cellPadding; + var padding_width = padding * cols; + var padding_height = padding * rows; + var w = (canvas.width - padding_width) / cols; + var h = (canvas.height - padding_height) / rows; + ctx.globalAlpha = this.globalAlpha; + // Again, our matrix is top to bottom (0 is the first line) + // but visually, we want them bottom to top + var reversed_cells = cells.slice().reverse(); + for (var y = 0; y < rows; y++) { + for (var x = 0; x < cols; x++) { + ctx.fillStyle = colors[reversed_cells[y][x]]; + var fillWidth = w; + var fillHeight = h; + // Cheat: remove the padding at bottom and right + // the cells will be a bit larger but not really noticeable + if (x === cols - 1) { + fillWidth += padding; + } + if (y === rows - 1) { + fillHeight += padding; + } + ctx.fillRect( + x * (w + padding), + y * (h + padding), + fillWidth, + fillHeight + ); + if (selected && selectedX === x && selectedY === y) { + ctx.globalAlpha = 1.0; + ctx.strokeStyle = this.selectedColor; + ctx.lineWidth = this.selectedLineWidth; + ctx.strokeRect(x * (w + padding), y * (h + padding), w, h); + ctx.globalAlpha = this.globalAlpha; + } + } + } + ctx.restore(); + }, + }); + + field_registry.add("location_tray_matrix", LocationTrayMatrixField); + + return { + LocationTrayMatrixField: LocationTrayMatrixField, + }; +}); diff --git a/stock_vlm_mgmt/static/src/js/vlm_task_tree_action_buttons.js b/stock_vlm_mgmt/static/src/js/vlm_task_tree_action_buttons.js new file mode 100644 index 000000000..6f42ddd7d --- /dev/null +++ b/stock_vlm_mgmt/static/src/js/vlm_task_tree_action_buttons.js @@ -0,0 +1,48 @@ +odoo.define("crm.leads.tree", function (require) { + "use strict"; + const ListController = require("web.ListController"); + const ListView = require("web.ListView"); + const viewRegistry = require("web.view_registry"); + const core = require("web.core"); + const qweb = core.qweb; + + function renderVlmTasksActionButton() { + if (this.$buttons) { + this.$buttons.on("click", ".o_button_perform_vlm_tasks", async () => { + const resIds = await this.getSelectedIdsWithDomain(); + this._rpc({ + model: "stock.vlm.task", + method: "action_do_tasks", + args: [resIds], + }).then((action) => { + this.do_action(action); + }); + }); + } + } + + var VlmTaskRequestListController = ListController.extend({ + _updateSelectionBox: function () { + this._super.apply(this, arguments); + if (this.$performTasksButtons) { + this.$performTasksButtons.remove(); + this.$performTasksButtons = null; + } + this.$performTasksButtons = $( + qweb.render("VlmTaskAction.perform_vlm_tasks_button") + ); + if (this.$selectionBox) { + this.$performTasksButtons.insertAfter(this.$selectionBox); + renderVlmTasksActionButton.apply(this, arguments); + } + }, + }); + + var VlmTaskRequestListView = ListView.extend({ + config: _.extend({}, ListView.prototype.config, { + Controller: VlmTaskRequestListController, + }), + }); + + viewRegistry.add("stock_vlm_task_action_tree", VlmTaskRequestListView); +}); diff --git a/stock_vlm_mgmt/static/src/scss/stock_vlm_mgmt.scss b/stock_vlm_mgmt/static/src/scss/stock_vlm_mgmt.scss new file mode 100644 index 000000000..44af648c4 --- /dev/null +++ b/stock_vlm_mgmt/static/src/scss/stock_vlm_mgmt.scss @@ -0,0 +1,19 @@ +// VLM tray matrix default colors +.o_field_location_tray_matrix { + background-color: $o-brand-lightsecondary; + border: 2px $o-main-color-muted solid; +} +// Product image hint in the wizard +.o_form_view { + .vlm_task_image { + float: right; + margin-bottom: 10px; + + > img { + max-width: 200px; + max-height: 200px; + margin: auto; + display: block; + } + } +} diff --git a/stock_vlm_mgmt/static/src/xml/vlm_task_tree_action_buttons_views.xml b/stock_vlm_mgmt/static/src/xml/vlm_task_tree_action_buttons_views.xml new file mode 100644 index 000000000..8e532582c --- /dev/null +++ b/stock_vlm_mgmt/static/src/xml/vlm_task_tree_action_buttons_views.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/stock_vlm_mgmt/views/assets.xml b/stock_vlm_mgmt/views/assets.xml new file mode 100644 index 000000000..95a563521 --- /dev/null +++ b/stock_vlm_mgmt/views/assets.xml @@ -0,0 +1,20 @@ + + +