diff --git a/setup/stock_vlm_mgmt/odoo/addons/stock_vlm_mgmt b/setup/stock_vlm_mgmt/odoo/addons/stock_vlm_mgmt new file mode 120000 index 000000000..9da14c6ef --- /dev/null +++ b/setup/stock_vlm_mgmt/odoo/addons/stock_vlm_mgmt @@ -0,0 +1 @@ +../../../../stock_vlm_mgmt \ No newline at end of file diff --git a/setup/stock_vlm_mgmt/setup.py b/setup/stock_vlm_mgmt/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/stock_vlm_mgmt/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_vlm_mgmt/README.rst b/stock_vlm_mgmt/README.rst new file mode 100644 index 000000000..c31772ef7 --- /dev/null +++ b/stock_vlm_mgmt/README.rst @@ -0,0 +1,110 @@ +=============================== +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/16.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-16-0/stock-logistics-warehouse-16-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=16.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. + +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..b4ab9707f --- /dev/null +++ b/stock_vlm_mgmt/__manifest__.py @@ -0,0 +1,30 @@ +# 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": "16.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", "base_sparse_field"], + "data": [ + "security/ir.model.access.csv", + "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", + ], + "assets": { + "web.assets_backend": [ + "stock_vlm_mgmt/static/src/scss/stock_vlm_mgmt.scss", + "stock_vlm_mgmt/static/src/js/**/*", + ], + }, +} 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..8e7925e92 --- /dev/null +++ b/stock_vlm_mgmt/models/stock_location.py @@ -0,0 +1,144 @@ +# 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( + help=( + "An VLM normally will be behind some propietary proxy that handles several " + "VLMs at once, so we need to set which one corresponds to this location" + ) + ) + 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..418c68a0c --- /dev/null +++ b/stock_vlm_mgmt/models/stock_location_vlm_tray.py @@ -0,0 +1,89 @@ +# 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.osv import expression + + +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", required=True + ) + 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: + # Let's be gentle with positioning errors. + try: + tray_matrix["cells"][position["pos_y"]][position["pos_x"]] = 1 + # pylint: disable=except-pass + 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 + 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 is not None) and (pos_y is not None): + domain = expression.AND( + [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..616a275a6 --- /dev/null +++ b/stock_vlm_mgmt/models/stock_location_vlm_tray_type.py @@ -0,0 +1,53 @@ +# 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 StockLocationVlmTrayType(models.Model): + _name = "stock.location.vlm.tray.type" + _description = "VLM Tray configuration" + _rec_names_search = ["name", "code"] + + 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} + + 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..30d81481c --- /dev/null +++ b/stock_vlm_mgmt/models/stock_quant.py @@ -0,0 +1,119 @@ +# 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 + quant._apply_inventory() + else: + vlm_quant.quant_id = quant.create( + { + "product_id": self.product_id.id, + "location_id": self.location_id.id, + "inventory_quantity": quant_quantity, + } + ) + vlm_quant.quant_id._apply_inventory() + + 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..3fdce5fa3 --- /dev/null +++ b/stock_vlm_mgmt/models/vlm_tray_cell_position_mixin.py @@ -0,0 +1,90 @@ +# 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(compute="_compute_pos", readonly=False, store=True) + pos_y = fields.Integer(compute="_compute_pos", readonly=False, store=True) + human_pos_x = fields.Integer( + string="X", + compute="_compute_human_pos_x", + inverse="_inverse_human_pos_x", + readonly=False, + ) + human_pos_y = fields.Integer( + string="Y", + compute="_compute_human_pos_y", + inverse="_inverse_human_pos_y", + readonly=False, + ) + + @api.depends("pos_x") + def _compute_human_pos_x(self): + for record in self: + record.human_pos_x = record.pos_x + 1 + + @api.depends("pos_y") + def _compute_human_pos_y(self): + for record in self: + record.human_pos_y = record.pos_y + 1 + + @api.depends("tray_matrix") + def _compute_pos(self): + for record in self: + if not record.tray_matrix["selected"]: + continue + record.update( + { + "pos_x": record.tray_matrix["selected"][0], + "pos_y": record.tray_matrix["selected"][1], + } + ) + + @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 + # pylint: disable=except-pass + 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..be62e684b --- /dev/null +++ b/stock_vlm_mgmt/readme/ROADMAP.rst @@ -0,0 +1,17 @@ +* 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. 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..346d0ee5a --- /dev/null +++ b/stock_vlm_mgmt/static/description/index.html @@ -0,0 +1,451 @@ + + + + + +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.
  • +
+
+
+

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/location_tray_matrix/stock_location_tray.esm.js b/stock_vlm_mgmt/static/src/js/location_tray_matrix/stock_location_tray.esm.js new file mode 100644 index 000000000..089a477bc --- /dev/null +++ b/stock_vlm_mgmt/static/src/js/location_tray_matrix/stock_location_tray.esm.js @@ -0,0 +1,62 @@ +/** @odoo-module **/ +/* Copyright 2024 Tecnativa - David Vidal + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).*/ +import {Component, useState} from "@odoo/owl"; + +import {_lt} from "@web/core/l10n/translation"; +import {registry} from "@web/core/registry"; +import {standardFieldProps} from "@web/views/fields/standard_field_props"; +import {useService} from "@web/core/utils/hooks"; + +export class LocationTrayMatrixField extends Component { + setup() { + this.state = useState(this.props.value); + this.orm = useService("orm"); + this.action = useService("action"); + } + /** + * + * @param {Event} event + * @returns {Object} Odoo action + */ + async onClickCell(event) { + const coordinates = event.currentTarget.dataset.coordinates + .split(",") + .map((x) => { + return parseInt(x, 10); + }); + if (this.props.click_action) { + const action = this.orm.call( + this.props.record.resModel, + this.props.click_action, + [[this.props.record.data.id]], + {pos_x: coordinates[0], pos_y: coordinates[1]} + ); + return this.action.doAction(action); + } + // This is for responsiveness + this.state.selected = coordinates; + // And here we propagate the changes to the field + this.props.value.selected = coordinates; + this.props.update(this.props.value); + } +} + +LocationTrayMatrixField.template = "stock_vlm_mgmt.location_tray_matrix"; +LocationTrayMatrixField.props = { + ...standardFieldProps, + click_action: { + type: String, + optional: true, + }, +}; +LocationTrayMatrixField.extractProps = ({attrs}) => { + if ("click_action" in attrs.options) { + return {click_action: attrs.options.click_action}; + } +}; + +LocationTrayMatrixField.displayName = _lt("Tray storage layout"); +LocationTrayMatrixField.supportedTypes = ["serialized"]; + +registry.category("fields").add("location_tray_matrix", LocationTrayMatrixField); diff --git a/stock_vlm_mgmt/static/src/js/location_tray_matrix/stock_location_tray.scss b/stock_vlm_mgmt/static/src/js/location_tray_matrix/stock_location_tray.scss new file mode 100644 index 000000000..7ddd0dd15 --- /dev/null +++ b/stock_vlm_mgmt/static/src/js/location_tray_matrix/stock_location_tray.scss @@ -0,0 +1,24 @@ +// Widget in form view +.o_field_location_tray_matrix { + table { + height: 200px; + tr { + td { + min-width: 250px; + } + } + } +} +// Widget in list view +.o_location_tray_matrix_cell { + .o_field_location_tray_matrix { + table { + height: 70px; + tr { + td { + min-width: 25px; + } + } + } + } +} diff --git a/stock_vlm_mgmt/static/src/js/location_tray_matrix/stock_location_tray_views.xml b/stock_vlm_mgmt/static/src/js/location_tray_matrix/stock_location_tray_views.xml new file mode 100644 index 000000000..4498fed80 --- /dev/null +++ b/stock_vlm_mgmt/static/src/js/location_tray_matrix/stock_location_tray_views.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + +
·
+
+
diff --git a/stock_vlm_mgmt/static/src/js/vlm_request_button/vlm_request_views.esm.js b/stock_vlm_mgmt/static/src/js/vlm_request_button/vlm_request_views.esm.js new file mode 100644 index 000000000..0aff4a857 --- /dev/null +++ b/stock_vlm_mgmt/static/src/js/vlm_request_button/vlm_request_views.esm.js @@ -0,0 +1,30 @@ +/** @odoo-module **/ + +import {ListController} from "@web/views/list/list_controller"; +import {listView} from "@web/views/list/list_view"; +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; + +export class VlmRequestListController extends ListController { + setup() { + super.setup(); + this.orm = useService("orm"); + this.action = useService("action"); + } + async onClickVlmRequest() { + const resIds = this.model.root.selection.map((record) => record.resId); + const action = await this.orm.call( + "stock.vlm.task", + "action_do_tasks", + [resIds], + {} + ); + this.action.doAction(action); + } +} + +registry.category("views").add("stock_vlm_task_action_tree", { + ...listView, + Controller: VlmRequestListController, + buttonTemplate: "VlmTaskRequestListView.buttons", +}); diff --git a/stock_vlm_mgmt/static/src/js/vlm_request_button/vlm_request_views.xml b/stock_vlm_mgmt/static/src/js/vlm_request_button/vlm_request_views.xml new file mode 100644 index 000000000..ee3781a64 --- /dev/null +++ b/stock_vlm_mgmt/static/src/js/vlm_request_button/vlm_request_views.xml @@ -0,0 +1,24 @@ + + + + + + + + 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..8455272e2 --- /dev/null +++ b/stock_vlm_mgmt/static/src/scss/stock_vlm_mgmt.scss @@ -0,0 +1,14 @@ +// 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/views/stock_location_tray_type_views.xml b/stock_vlm_mgmt/views/stock_location_tray_type_views.xml new file mode 100644 index 000000000..8ee1dc364 --- /dev/null +++ b/stock_vlm_mgmt/views/stock_location_tray_type_views.xml @@ -0,0 +1,99 @@ + + + + + stock.location.vlm.tray.type + +
+ +
+
+ + stock.location.vlm.tray.type + + + + + + + + + + + stock.location.vlm.tray.type + + + + + + + + + + + Location Tray Types + stock.location.vlm.tray.type + ir.actions.act_window + + + + +

+ Add a Location Tray Type +

+

+ Define the number of rows and cols on a tray, + depending of the boxes size. +

+
+
+ +
diff --git a/stock_vlm_mgmt/views/stock_location_views.xml b/stock_vlm_mgmt/views/stock_location_views.xml new file mode 100644 index 000000000..16a788895 --- /dev/null +++ b/stock_vlm_mgmt/views/stock_location_views.xml @@ -0,0 +1,102 @@ + + + + stock.location + + +
+
+ + + + + + + + + + + + + + + +
+
+ + stock.location + + + + + + + + + Vertical Lift Modules + stock.location + {} + [('is_vlm', '=', True)] + tree,form + + +
diff --git a/stock_vlm_mgmt/views/stock_location_vlm_tray_views.xml b/stock_vlm_mgmt/views/stock_location_vlm_tray_views.xml new file mode 100644 index 000000000..fc8ab6628 --- /dev/null +++ b/stock_vlm_mgmt/views/stock_location_vlm_tray_views.xml @@ -0,0 +1,88 @@ + + + + stock.location.vlm.tray + +
+ +
+
+
+
+
+
+ + stock.location.vlm.tray + + + + + + + +