From 11916742c4ad1bff8e2403a48a16c49a71be865d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 20 Nov 2020 14:48:24 +0100 Subject: [PATCH 01/35] Add stock_release_channel --- stock_release_channel/__init__.py | 1 + stock_release_channel/__manifest__.py | 18 + .../data/stock_release_channel_data.xml | 10 + stock_release_channel/models/__init__.py | 3 + .../models/sale_order_line.py | 19 + stock_release_channel/models/stock_picking.py | 21 + .../models/stock_release_channel.py | 445 +++++++++++++++++ .../security/stock_release_channel.xml | 34 ++ .../views/stock_picking_views.xml | 43 ++ .../views/stock_release_channel_views.xml | 452 ++++++++++++++++++ 10 files changed, 1046 insertions(+) create mode 100644 stock_release_channel/__init__.py create mode 100644 stock_release_channel/__manifest__.py create mode 100644 stock_release_channel/data/stock_release_channel_data.xml create mode 100644 stock_release_channel/models/__init__.py create mode 100644 stock_release_channel/models/sale_order_line.py create mode 100644 stock_release_channel/models/stock_picking.py create mode 100644 stock_release_channel/models/stock_release_channel.py create mode 100644 stock_release_channel/security/stock_release_channel.xml create mode 100644 stock_release_channel/views/stock_picking_views.xml create mode 100644 stock_release_channel/views/stock_release_channel_views.xml diff --git a/stock_release_channel/__init__.py b/stock_release_channel/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/stock_release_channel/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_release_channel/__manifest__.py b/stock_release_channel/__manifest__.py new file mode 100644 index 0000000000..417b201204 --- /dev/null +++ b/stock_release_channel/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2020 Camptocamp +# License OPL-1 + +{ + "name": "Stock Release Channels", + "summary": "Manage workload in WMS with release channels", + "version": "13.0.1.0.0", + "license": "OPL-1", + "author": "Camptocamp", + "website": "https://www.camptocamp.com", + "depends": ["sale_stock", "stock_available_to_promise_release", "ddmrp"], + "data": [ + "security/stock_release_channel.xml", + "views/stock_release_channel_views.xml", + "views/stock_picking_views.xml", + "data/stock_release_channel_data.xml", + ], +} diff --git a/stock_release_channel/data/stock_release_channel_data.xml b/stock_release_channel/data/stock_release_channel_data.xml new file mode 100644 index 0000000000..d062822cba --- /dev/null +++ b/stock_release_channel/data/stock_release_channel_data.xml @@ -0,0 +1,10 @@ + + + + + Default + 999 + [] + + + diff --git a/stock_release_channel/models/__init__.py b/stock_release_channel/models/__init__.py new file mode 100644 index 0000000000..c0c4c546a7 --- /dev/null +++ b/stock_release_channel/models/__init__.py @@ -0,0 +1,3 @@ +from . import sale_order_line +from . import stock_picking +from . import stock_release_channel diff --git a/stock_release_channel/models/sale_order_line.py b/stock_release_channel/models/sale_order_line.py new file mode 100644 index 0000000000..531b81fce1 --- /dev/null +++ b/stock_release_channel/models/sale_order_line.py @@ -0,0 +1,19 @@ +# Copyright 2020 Camptocamp +# License OPL-1 + +from odoo import models + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + def _action_launch_stock_rule(self, previous_product_uom_qty=False): + result = super()._action_launch_stock_rule( + previous_product_uom_qty=previous_product_uom_qty + ) + pickings = self.move_ids.picking_id + # ensure we assign a channel on any picking OUT generated for the sale, + # if moves are assigned to an existing transfer, we recompute the + # channel + pickings.assign_release_channel() + return result diff --git a/stock_release_channel/models/stock_picking.py b/stock_release_channel/models/stock_picking.py new file mode 100644 index 0000000000..b749c8a0f6 --- /dev/null +++ b/stock_release_channel/models/stock_picking.py @@ -0,0 +1,21 @@ +# Copyright 2020 Camptocamp +# License OPL-1 + +from odoo import fields, models + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + release_channel_id = fields.Many2one( + comodel_name="stock.release.channel", index=True, ondelete="restrict" + ) + + # TODO queue.job (with identity key) + def assign_release_channel(self): + self.env["stock.release.channel"].assign_release_channel(self) + + def _create_backorder(self): + backorders = super()._create_backorder() + self.env["stock.release.channel"].assign_release_channel(backorders) + return backorders diff --git a/stock_release_channel/models/stock_release_channel.py b/stock_release_channel/models/stock_release_channel.py new file mode 100644 index 0000000000..5bf7e51c4e --- /dev/null +++ b/stock_release_channel/models/stock_release_channel.py @@ -0,0 +1,445 @@ +# Copyright 2020 Camptocamp +# License OPL-1 + +import datetime +import logging +import time + +import dateutil +from pytz import timezone + +from odoo import _, api, exceptions, fields, models +from odoo.tools.safe_eval import safe_eval, test_python_expr + +_logger = logging.getLogger(__name__) + + +class StockReleaseChannel(models.Model): + _name = "stock.release.channel" + _description = "Stock Release Channels" + _order = "sequence, id" + + DEFAULT_PYTHON_CODE = ( + "# Available variables:\n" + "# - env: Odoo Environment on which the action is triggered\n" + "# - records: pickings to filter\n" + "# - time, datetime, dateutil, timezone: useful Python libraries\n" + "#\n" + "# By default, all pickings are kept.\n" + "# To filter a selection of pickings, assign: pickings = ...\n\n\n\n\n" + ) + + name = fields.Char(required=True) + sequence = fields.Integer(default=lambda self: self._default_sequence()) + color = fields.Integer() + rule_domain = fields.Char( + string="Domain", + default=[], + help="Domain based on Transfers, filter for entering the channel.", + ) + code = fields.Text( + string="Python Code", + groups="base.group_system", + default=DEFAULT_PYTHON_CODE, + help="Write Python code to filter out pickings.", + ) + active = fields.Boolean(default=True) + + picking_ids = fields.One2many( + string="Transfers", + comodel_name="stock.picking", + inverse_name="release_channel_id", + ) + + # beware not to store any value which can be changed by concurrent + # stock.picking (e.g. the state cannot be stored) + + count_picking_all = fields.Integer( + string="All Transfers", compute="_compute_picking_count" + ) + count_picking_release_ready = fields.Integer( + string="Release Ready Transfers", compute="_compute_picking_count" + ) + count_picking_released = fields.Integer( + string="Released Transfers", compute="_compute_picking_count" + ) + count_picking_assigned = fields.Integer( + string="Available Transfers", compute="_compute_picking_count" + ) + count_picking_waiting = fields.Integer( + string="Waiting Transfers", compute="_compute_picking_count" + ) + count_picking_late = fields.Integer( + string="Late Transfers", compute="_compute_picking_count" + ) + count_picking_priority = fields.Integer( + string="Priority Transfers", compute="_compute_picking_count" + ) + count_picking_done = fields.Integer( + string="Transfers Done Today", compute="_compute_picking_count" + ) + + count_move_all = fields.Integer( + string="All Moves (Estimate)", compute="_compute_picking_count" + ) + count_move_release_ready = fields.Integer( + string="Release Ready Moves (Estimate)", compute="_compute_picking_count" + ) + count_move_released = fields.Integer( + string="Released Moves (Estimate)", compute="_compute_picking_count" + ) + count_move_assigned = fields.Integer( + string="Available Moves (Estimate)", compute="_compute_picking_count" + ) + count_move_waiting = fields.Integer( + string="Waiting Moves (Estimate)", compute="_compute_picking_count" + ) + count_move_late = fields.Integer( + string="Late Moves (Estimate)", compute="_compute_picking_count" + ) + count_move_priority = fields.Integer( + string="Priority Moves (Estimate)", compute="_compute_picking_count" + ) + count_move_done = fields.Integer( + string="Moves Done Today (Estimate)", compute="_compute_picking_count" + ) + + last_done_picking_id = fields.Many2one( + string="Last Done Transfer", + comodel_name="stock.picking", + compute="_compute_last_done_picking", + ) + last_done_picking_name = fields.Char(compute="_compute_last_done_picking") + last_done_picking_date_done = fields.Datetime(compute="_compute_last_done_picking") + + def _field_picking_domains(self): + return { + "count_picking_all": [], + "count_picking_release_ready": [ + ("release_ready", "=", True), + # FIXME not TZ friendly + ( + "scheduled_date", + "<", + fields.Datetime.now().replace(hour=23, minute=59), + ), + ], + "count_picking_released": [ + ("need_release", "=", False), + ("state", "in", ("assigned", "waiting", "confirmed")), + ], + "count_picking_assigned": [("state", "=", "assigned")], + "count_picking_waiting": [ + ("need_release", "=", False), + ("state", "in", ("waiting", "confirmed")), + ], + "count_picking_late": [ + ("scheduled_date", "<", fields.Datetime.now()), + ("state", "in", ("assigned", "waiting", "confirmed")), + ("need_release", "=", False), + ], + "count_picking_priority": [ + ("priority", ">", "1"), + ("state", "in", ("assigned", "waiting", "confirmed")), + ("need_release", "=", False), + ], + "count_picking_done": [ + ("state", "=", "done"), + ("date_done", ">", fields.Datetime.now().replace(hour=0, minute=0)), + ], + } + + # TODO maybe we have to do raw SQL to include the picking + moves counts in + # a single query + def _compute_picking_count(self): + domains = self._field_picking_domains() + picking_ids_per_field = {} + for field, domain in domains.items(): + data = self.env["stock.picking"].read_group( + domain + [("release_channel_id", "in", self.ids)], + ["release_channel_id", "picking_ids:array_agg(id)"], + ["release_channel_id"], + ) + count = { + row["release_channel_id"][0]: row["release_channel_id_count"] + for row in data + if row["release_channel_id"] + } + + picking_ids_per_field.update( + { + (row["release_channel_id"][0], field): row["picking_ids"] + for row in data + if row["release_channel_id"] + } + ) + + for record in self: + record[field] = count.get(record.id, 0) + + all_picking_ids = [ + pid for picking_ids in picking_ids_per_field.values() for pid in picking_ids + ] + data = self.env["stock.move"].read_group( + # TODO for now we do estimates, later we may improve the domains per + # field, but now we can run one sql query on stock.move for all fields + [("picking_id", "in", all_picking_ids), ("state", "!=", "cancel")], + ["picking_id"], + ["picking_id"], + ) + move_count = { + row["picking_id"][0]: row["picking_id_count"] + for row in data + if row["picking_id"] + } + for field, __ in domains.items(): + move_field = field.replace("picking", "move") + for record in self: + picking_ids = picking_ids_per_field.get((record.id, field), []) + move_estimate = sum( + move_count.get(picking_id, 0) for picking_id in picking_ids + ) + record[move_field] = move_estimate + + # TODO this duplicated with shopfloor_kanban + def _compute_last_done_picking(self): + for channel in self: + # TODO we have one query per channel, could be better + domain = self._field_picking_domains()["count_picking_done"] + domain += [("release_channel_id", "=", channel.id)] + picking = self.env["stock.picking"].search( + domain, limit=1, order="date_done DESC" + ) + channel.last_done_picking_id = picking + channel.last_done_picking_name = picking.name + channel.last_done_picking_date_done = picking.date_done + + def _default_sequence(self): + default_channel = self.env.ref( + "stock_release_channel.stock_release_channel_default", + raise_if_not_found=False, + ) + domain = [] + if default_channel: + domain = [("id", "!=", default_channel.id)] + maxrule = self.search(domain, order="sequence desc", limit=1) + if maxrule: + return maxrule.sequence + 10 + else: + return 0 + + @api.constrains("code") + def _check_python_code(self): + for action in self.sudo().filtered("code"): + msg = test_python_expr(expr=action.code.strip(), mode="exec") + if msg: + raise exceptions.ValidationError(msg) + + def _prepare_domain(self): + domain = safe_eval(self.rule_domain) or [] + return domain + + def assign_release_channel(self, pickings): + pickings = pickings.filtered( + lambda picking: picking.need_release + and picking.state not in ("cancel", "done") + ) + if not pickings: + return + # do a single query rather than one for each rule*picking + for channel in self.search([]): + domain = channel._prepare_domain() + + if domain: + current = pickings.filtered_domain(domain) + else: + current = pickings + + if not current: + continue + + if channel.code: + current = channel._eval_code(current) + + if not current: + continue + + current.release_channel_id = channel + + pickings -= current + if not pickings: + break + + if pickings: + # by this point, all pickings should have been assigned + _logger.warning( + "%s transfers could not be assigned to a channel," + " you should add a final catch-all rule", + len(pickings), + ) + return True + + def _eval_context(self, pickings): + """Prepare the context used when for the python rule evaluation + + :returns: dict -- evaluation context given to (safe_)safe_eval + """ + eval_context = { + "uid": self.env.uid, + "user": self.env.user, + "time": time, + "datetime": datetime, + "dateutil": dateutil, + "timezone": timezone, + # orm + "env": self.env, + # record + "self": self, + "pickings": pickings, + } + return eval_context + + def _eval_code(self, pickings): + expr = self.code.strip() + eval_context = self._eval_context(pickings) + try: + safe_eval(expr, eval_context, mode="exec", nocopy=True) + except Exception as err: + raise exceptions.UserError( + _("Error when evaluating the channel's code:\n %s \n(%s)") + % (self.name, err) + ) + # normally "pickings" is always set as we set it in the eval context, + # but let assume the worst case with someone using "del pickings" + return eval_context.get("pickings", self.env["stock.picking"].browse()) + + def action_picking_all(self): + return self._action_picking_for_field( + "count_picking_all", context={"search_default_release_ready": 1} + ) + + def action_picking_release_ready(self): + return self._action_picking_for_field("count_picking_release_ready") + + def action_picking_released(self): + return self._action_picking_for_field("count_picking_released") + + def action_picking_assigned(self): + return self._action_picking_for_field("count_picking_assigned") + + def action_picking_waiting(self): + return self._action_picking_for_field("count_picking_waiting") + + def action_picking_late(self): + return self._action_picking_for_field("count_picking_late") + + def action_picking_priority(self): + return self._action_picking_for_field("count_picking_priority") + + def action_picking_done(self): + return self._action_picking_for_field("count_picking_done") + + def _action_picking_for_field(self, field_domain, context=None): + domain = self._field_picking_domains()[field_domain] + domain += [("release_channel_id", "in", self.ids)] + pickings = self.env["stock.picking"].search(domain) + field_descr = self._fields[field_domain]._description_string(self.env) + return self._build_action( + "stock_available_to_promise_release.stock_picking_release_action", + pickings, + field_descr, + context=context, + ) + + def action_move_all(self): + return self._action_move_for_field( + "count_picking_all", context={"search_default_release_ready": 1} + ) + + def action_move_release_ready(self): + return self._action_move_for_field( + "count_picking_release_ready", context={"search_default_release_ready": 1} + ) + + def action_move_released(self): + return self._action_move_for_field("count_picking_released") + + def action_move_assigned(self): + return self._action_move_for_field("count_picking_assigned") + + def action_move_waiting(self): + return self._action_move_for_field("count_picking_waiting") + + def action_move_late(self): + return self._action_move_for_field("count_picking_late") + + def action_move_priority(self): + return self._action_move_for_field("count_picking_priority") + + def action_move_done(self): + return self._action_move_for_field("count_picking_done") + + def _action_move_for_field(self, field_domain, context=None): + domain = self._field_picking_domains()[field_domain] + domain += [("release_channel_id", "in", self.ids)] + pickings = self.env["stock.picking"].search(domain) + field_descr = self._fields[field_domain]._description_string(self.env) + xmlid = "stock_available_to_promise_release.stock_move_release_action" + action = self.env.ref(xmlid).read()[0] + action["display_name"] = "{} ({})".format( + ", ".join(self.mapped("display_name")), field_descr + ) + action["context"] = context if context else {} + action["domain"] = [("picking_id", "in", pickings.ids)] + return action + + def _build_action(self, xmlid, records, description, context=None): + action = self.env.ref(xmlid).read()[0] + action["display_name"] = "{} ({})".format( + ", ".join(self.mapped("display_name")), description + ) + action["context"] = context if context else None + action["domain"] = [("id", "in", records.ids)] + return action + + def action_picking_all_related(self): + """Open all chained transfers for released deliveries""" + domain = self._field_picking_domains()["count_picking_released"] + domain += [("release_channel_id", "=", self.id)] + released = self.env["stock.picking"].search(domain) + all_related_ids = set() + current_moves = released.move_lines + while current_moves: + all_related_ids |= set(current_moves.picking_id.ids) + current_moves = current_moves.move_orig_ids + + all_related = self.env["stock.picking"].browse(all_related_ids) + + return self._build_action( + "stock.action_picking_tree_all", + all_related, + _("All Related Transfers"), + context={"search_default_available": 1, "search_default_picking_type": 1}, + ) + + # TODO extract to glue module with ddmrp + def action_ddmrp_buffer(self): + domain = self._field_picking_domains()["count_picking_released"] + domain += [("release_channel_id", "=", self.id)] + released = self.env["stock.picking"].search(domain) + buffers = released.move_lines.product_id.buffer_ids + return self._build_action( + "ddmrp.action_stock_buffer", buffers, _("DDMRP Buffers") + ) + + # TODO almost duplicated with stock_picking_type_kanban + def get_action_picking_form(self): + self.ensure_one() + action = self.env.ref("stock.action_picking_form").read()[0] + action["context"] = {} + if not self.last_done_picking_id: + raise exceptions.UserError( + _("Channel {} has no validated transfer yet.").format(name=self.name) + ) + action["res_id"] = self.last_done_picking_id.id + return action diff --git a/stock_release_channel/security/stock_release_channel.xml b/stock_release_channel/security/stock_release_channel.xml new file mode 100644 index 0000000000..991948df60 --- /dev/null +++ b/stock_release_channel/security/stock_release_channel.xml @@ -0,0 +1,34 @@ + + + + + stock.release.channel all users + + + + + + + + + + stock.release.channel stock users + + + + + + + + + + stock.release.channel stock managers + + + + + + + + + diff --git a/stock_release_channel/views/stock_picking_views.xml b/stock_release_channel/views/stock_picking_views.xml new file mode 100644 index 0000000000..c97452a192 --- /dev/null +++ b/stock_release_channel/views/stock_picking_views.xml @@ -0,0 +1,43 @@ + + + + + stock.picking.form + stock.picking + + + + + + + + + + stock.picking.internal.search + stock.picking + + + + + + + + + + + + + + + + diff --git a/stock_release_channel/views/stock_release_channel_views.xml b/stock_release_channel/views/stock_release_channel_views.xml new file mode 100644 index 0000000000..91acdba590 --- /dev/null +++ b/stock_release_channel/views/stock_release_channel_views.xml @@ -0,0 +1,452 @@ + + + + + stock.release.channel.form + stock.release.channel + +
+ + +
+
+ + +
+
+
+
+ + + stock.release.channel.search + stock.release.channel + + + + + + + + + + + stock.release.channel.tree + stock.release.channel + + + + + + + + + + + stock.release.channel.kanban + stock.release.channel + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ + +
+
+
+ +
+ + + [] + +
+
+ +
+ +
+ + + [] + +
+
+ + +
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+ +
+ + + [] + +
+
+ +
+ +
+ + + [] + +
+
+ +
+ +
+ + + [] + +
+
+ +
+ + +
+ + +
+ + +
+
+ + + + + + + + + Release Channels + stock.release.channel + kanban,tree,form + [] + {} + +

+ No Release Channel configured +

+
+
+ + + Release Channels + stock.release.channel + tree,form,kanban + [] + {} + +

+ Add a Release Channel +

+
+
+ + + Release Channels + + + + + + + Release Channels + + + + + + From fd745367f454b85c6df10a9693e79dbbcbc5f53b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 25 Nov 2020 12:26:35 +0100 Subject: [PATCH 02/35] Add automatic release of a batch of transfers --- .../models/stock_release_channel.py | 49 +++++++++++++++++++ .../views/stock_release_channel_views.xml | 11 +++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/stock_release_channel/models/stock_release_channel.py b/stock_release_channel/models/stock_release_channel.py index 5bf7e51c4e..7dec03d31e 100644 --- a/stock_release_channel/models/stock_release_channel.py +++ b/stock_release_channel/models/stock_release_channel.py @@ -45,6 +45,13 @@ class StockReleaseChannel(models.Model): ) active = fields.Boolean(default=True) + max_auto_release = fields.Integer( + string="Max Transfers to release", + default=10, + help="When clicking on the package icon, it releases X transfers minus " + " the work in progress transfers. This field defines X.", + ) + picking_ids = fields.One2many( string="Transfers", comodel_name="stock.picking", @@ -443,3 +450,45 @@ def get_action_picking_form(self): ) action["res_id"] = self.last_done_picking_id.id return action + + def release_next_batch(self): + self.ensure_one() + if not self.max_auto_release: + raise exceptions.UserError(_("No Max transfers to release is configured.")) + + wip_domain = self._field_picking_domains()["count_picking_released"] + wip_domain += [("release_channel_id", "=", self.id)] + released_in_progress = self.env["stock.picking"].search_count(wip_domain) + + release_limit = max(self.max_auto_release - released_in_progress, 0) + if not release_limit: + raise exceptions.UserError( + _( + "The number of released transfers in" + " progress is already at the maximum." + ) + ) + domain = self._field_picking_domains()["count_picking_release_ready"] + domain += [("release_channel_id", "=", self.id)] + next_pickings = self.env["stock.picking"].search(domain) + if not next_pickings: + return { + "effect": { + "fadeout": "fast", + "message": _("Nothing in the queue!"), + "img_url": "/web/static/src/img/smile.svg", + "type": "rainbow_man", + } + } + # We have to use a python sort and not a order + limit on the search + # because "date_priority" is computed and not stored. If needed, we + # should evaluate making it a stored field in the module + # "stock_available_to_promise_release". + next_pickings = next_pickings.sorted( + lambda picking: ( + -int(picking.priority or 1), + picking.date_priority, + picking.id, + ) + )[:release_limit] + next_pickings.release_available_to_promise() diff --git a/stock_release_channel/views/stock_release_channel_views.xml b/stock_release_channel/views/stock_release_channel_views.xml index 91acdba590..cce8e7edcd 100644 --- a/stock_release_channel/views/stock_release_channel_views.xml +++ b/stock_release_channel/views/stock_release_channel_views.xml @@ -22,6 +22,7 @@ +
-
From 7e5293d20dffd77b3db6aa41b583d6ab4f5fba44 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 25 Nov 2020 12:26:52 +0100 Subject: [PATCH 03/35] Use queue_job for assigning the release channel --- stock_release_channel/__manifest__.py | 8 +++++++- stock_release_channel/data/queue_job_data.xml | 18 ++++++++++++++++++ .../models/sale_order_line.py | 2 +- stock_release_channel/models/stock_picking.py | 14 +++++++++++--- 4 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 stock_release_channel/data/queue_job_data.xml diff --git a/stock_release_channel/__manifest__.py b/stock_release_channel/__manifest__.py index 417b201204..158a3f0b38 100644 --- a/stock_release_channel/__manifest__.py +++ b/stock_release_channel/__manifest__.py @@ -8,11 +8,17 @@ "license": "OPL-1", "author": "Camptocamp", "website": "https://www.camptocamp.com", - "depends": ["sale_stock", "stock_available_to_promise_release", "ddmrp"], + "depends": [ + "sale_stock", + "stock_available_to_promise_release", # OCA/wms + "ddmrp", # OCA/ddmrp + "queue_job", # OCA/queue + ], "data": [ "security/stock_release_channel.xml", "views/stock_release_channel_views.xml", "views/stock_picking_views.xml", "data/stock_release_channel_data.xml", + "data/queue_job_data.xml", ], } diff --git a/stock_release_channel/data/queue_job_data.xml b/stock_release_channel/data/queue_job_data.xml new file mode 100644 index 0000000000..33d79d8972 --- /dev/null +++ b/stock_release_channel/data/queue_job_data.xml @@ -0,0 +1,18 @@ + + + + + stock_release_channel + + + + + + assign_release_channel + + + + diff --git a/stock_release_channel/models/sale_order_line.py b/stock_release_channel/models/sale_order_line.py index 531b81fce1..9c8e01e1b1 100644 --- a/stock_release_channel/models/sale_order_line.py +++ b/stock_release_channel/models/sale_order_line.py @@ -15,5 +15,5 @@ def _action_launch_stock_rule(self, previous_product_uom_qty=False): # ensure we assign a channel on any picking OUT generated for the sale, # if moves are assigned to an existing transfer, we recompute the # channel - pickings.assign_release_channel() + pickings._delay_assign_release_channel() return result diff --git a/stock_release_channel/models/stock_picking.py b/stock_release_channel/models/stock_picking.py index b749c8a0f6..336e3f1bde 100644 --- a/stock_release_channel/models/stock_picking.py +++ b/stock_release_channel/models/stock_picking.py @@ -1,7 +1,9 @@ # Copyright 2020 Camptocamp # License OPL-1 -from odoo import fields, models +from odoo import _, fields, models + +from odoo.addons.queue_job.job import identity_exact class StockPicking(models.Model): @@ -11,11 +13,17 @@ class StockPicking(models.Model): comodel_name="stock.release.channel", index=True, ondelete="restrict" ) - # TODO queue.job (with identity key) + def _delay_assign_release_channel(self): + for picking in self: + picking.with_delay( + identity_key=identity_exact, + description=_("Assign release channel on {}").format(picking.name), + ).assign_release_channel() + def assign_release_channel(self): self.env["stock.release.channel"].assign_release_channel(self) def _create_backorder(self): backorders = super()._create_backorder() - self.env["stock.release.channel"].assign_release_channel(backorders) + backorders._delay_assign_release_channel() return backorders From 0c308f2fbac149053a098ccb0ce641cc2b6deef9 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 25 Nov 2020 13:30:22 +0100 Subject: [PATCH 04/35] Add release channel in transfer allocation view --- .../models/stock_release_channel.py | 7 ++++++- .../views/stock_picking_views.xml | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/stock_release_channel/models/stock_release_channel.py b/stock_release_channel/models/stock_release_channel.py index 7dec03d31e..674970331d 100644 --- a/stock_release_channel/models/stock_release_channel.py +++ b/stock_release_channel/models/stock_release_channel.py @@ -405,7 +405,12 @@ def _build_action(self, xmlid, records, description, context=None): action["display_name"] = "{} ({})".format( ", ".join(self.mapped("display_name")), description ) - action["context"] = context if context else None + # we already have this column in the view's title, save space on the + # screen + base_context = {"hide_release_channel_id": True} + if context: + base_context.update(context) + action["context"] = base_context action["domain"] = [("id", "in", records.ids)] return action diff --git a/stock_release_channel/views/stock_picking_views.xml b/stock_release_channel/views/stock_picking_views.xml index c97452a192..73f09ae130 100644 --- a/stock_release_channel/views/stock_picking_views.xml +++ b/stock_release_channel/views/stock_picking_views.xml @@ -40,4 +40,22 @@ + + stock.picking.release.tree + stock.picking + + + + + + + + From 50c4bedb0e3063e33d0d17aa53e4232044ffdec2 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 25 Nov 2020 15:22:46 +0100 Subject: [PATCH 05/35] Add cron to re-assign release channel --- stock_release_channel/__manifest__.py | 1 + stock_release_channel/data/ir_cron_data.xml | 16 ++++++++++++++++ stock_release_channel/models/stock_picking.py | 4 ++++ 3 files changed, 21 insertions(+) create mode 100644 stock_release_channel/data/ir_cron_data.xml diff --git a/stock_release_channel/__manifest__.py b/stock_release_channel/__manifest__.py index 158a3f0b38..9838d37a9b 100644 --- a/stock_release_channel/__manifest__.py +++ b/stock_release_channel/__manifest__.py @@ -20,5 +20,6 @@ "views/stock_picking_views.xml", "data/stock_release_channel_data.xml", "data/queue_job_data.xml", + "data/ir_cron_data.xml", ], } diff --git a/stock_release_channel/data/ir_cron_data.xml b/stock_release_channel/data/ir_cron_data.xml new file mode 100644 index 0000000000..3a01babcef --- /dev/null +++ b/stock_release_channel/data/ir_cron_data.xml @@ -0,0 +1,16 @@ + + + + + + Re-assign release channel + 1 + days + -1 + False + + code + model.assign_release_channel_on_all_need_release() + + + diff --git a/stock_release_channel/models/stock_picking.py b/stock_release_channel/models/stock_picking.py index 336e3f1bde..204900423b 100644 --- a/stock_release_channel/models/stock_picking.py +++ b/stock_release_channel/models/stock_picking.py @@ -27,3 +27,7 @@ def _create_backorder(self): backorders = super()._create_backorder() backorders._delay_assign_release_channel() return backorders + + def assign_release_channel_on_all_need_release(self): + need_release = self.env["stock.picking"].search([("need_release", "=", True)],) + need_release._delay_assign_release_channel() From 0bef6c0e6a81a97b10abb5ce940815c5ea6d76d3 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 25 Nov 2020 16:12:42 +0100 Subject: [PATCH 06/35] Store release channel on stock.move When using stock_dynamic_routing, the moves are reassigned to a new picking (with StockMove._assign_picking()), and the original picking is cancelled if it contains no moves anymore. As we want to keep Note: maybe we should extend the assign picking mechanism to never assign a move in a picking with a different release channel (channel is empty or identical). As we extend it in stock_picking_group_by_partner_by_carrier, it should be a new dependency/glue module on it, or better, only extract the hook added by stock_picking_group_by_partner_by_carrier for the domain in a base module, used by both. --- stock_release_channel/models/__init__.py | 1 + stock_release_channel/models/stock_move.py | 12 ++++++++++ stock_release_channel/models/stock_picking.py | 22 +++++++++++++++++-- 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 stock_release_channel/models/stock_move.py diff --git a/stock_release_channel/models/__init__.py b/stock_release_channel/models/__init__.py index c0c4c546a7..0c03017164 100644 --- a/stock_release_channel/models/__init__.py +++ b/stock_release_channel/models/__init__.py @@ -1,3 +1,4 @@ from . import sale_order_line +from . import stock_move from . import stock_picking from . import stock_release_channel diff --git a/stock_release_channel/models/stock_move.py b/stock_release_channel/models/stock_move.py new file mode 100644 index 0000000000..9b558c6d35 --- /dev/null +++ b/stock_release_channel/models/stock_move.py @@ -0,0 +1,12 @@ +# Copyright 2020 Camptocamp +# License OPL-1 + +from odoo import fields, models + + +class StockMove(models.Model): + _inherit = "stock.move" + + release_channel_id = fields.Many2one( + comodel_name="stock.release.channel", ondelete="restrict" + ) diff --git a/stock_release_channel/models/stock_picking.py b/stock_release_channel/models/stock_picking.py index 204900423b..94dea506dd 100644 --- a/stock_release_channel/models/stock_picking.py +++ b/stock_release_channel/models/stock_picking.py @@ -1,7 +1,7 @@ # Copyright 2020 Camptocamp # License OPL-1 -from odoo import _, fields, models +from odoo import _, api, fields, models from odoo.addons.queue_job.job import identity_exact @@ -10,9 +10,27 @@ class StockPicking(models.Model): _inherit = "stock.picking" release_channel_id = fields.Many2one( - comodel_name="stock.release.channel", index=True, ondelete="restrict" + comodel_name="stock.release.channel", + # we have to store it on stock.move, because when (for e.g.) + # a dynamic routing put moves in another picking (_assign_picking), + # we want the new one to keep this information + compute="_compute_release_channel_id", + inverse="_inverse_release_channel_id", + index=True, + store=True, ) + @api.depends("move_lines.release_channel_id") + def _compute_release_channel_id(self): + for picking in self: + picking.release_channel_id = fields.first( + picking.move_lines.release_channel_id + ) + + def _inverse_release_channel_id(self): + for picking in self: + picking.move_lines.write({"release_channel_id": picking.release_channel_id}) + def _delay_assign_release_channel(self): for picking in self: picking.with_delay( From 29bd61d1b9af1ffdbe9e8a9276b9c9e952a92177 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 25 Nov 2020 16:49:54 +0100 Subject: [PATCH 07/35] Re-assign release channel after release Reverts previous commit and re-assign the release channel in case the released transfer has changed / moves have been released partially. The rules result may change and we want them in the proper channel. --- stock_release_channel/models/stock_move.py | 11 ++++++---- stock_release_channel/models/stock_picking.py | 22 ++----------------- .../models/stock_release_channel.py | 2 +- 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/stock_release_channel/models/stock_move.py b/stock_release_channel/models/stock_move.py index 9b558c6d35..e93df760ed 100644 --- a/stock_release_channel/models/stock_move.py +++ b/stock_release_channel/models/stock_move.py @@ -1,12 +1,15 @@ # Copyright 2020 Camptocamp # License OPL-1 -from odoo import fields, models +from odoo import models class StockMove(models.Model): _inherit = "stock.move" - release_channel_id = fields.Many2one( - comodel_name="stock.release.channel", ondelete="restrict" - ) + def release_available_to_promise(self): + # after releasing, we re-assign a release channel, + # as we may release only partially, the channel may + # change + super().release_available_to_promise() + self.picking_id.assign_release_channel() diff --git a/stock_release_channel/models/stock_picking.py b/stock_release_channel/models/stock_picking.py index 94dea506dd..204900423b 100644 --- a/stock_release_channel/models/stock_picking.py +++ b/stock_release_channel/models/stock_picking.py @@ -1,7 +1,7 @@ # Copyright 2020 Camptocamp # License OPL-1 -from odoo import _, api, fields, models +from odoo import _, fields, models from odoo.addons.queue_job.job import identity_exact @@ -10,27 +10,9 @@ class StockPicking(models.Model): _inherit = "stock.picking" release_channel_id = fields.Many2one( - comodel_name="stock.release.channel", - # we have to store it on stock.move, because when (for e.g.) - # a dynamic routing put moves in another picking (_assign_picking), - # we want the new one to keep this information - compute="_compute_release_channel_id", - inverse="_inverse_release_channel_id", - index=True, - store=True, + comodel_name="stock.release.channel", index=True, ondelete="restrict" ) - @api.depends("move_lines.release_channel_id") - def _compute_release_channel_id(self): - for picking in self: - picking.release_channel_id = fields.first( - picking.move_lines.release_channel_id - ) - - def _inverse_release_channel_id(self): - for picking in self: - picking.move_lines.write({"release_channel_id": picking.release_channel_id}) - def _delay_assign_release_channel(self): for picking in self: picking.with_delay( diff --git a/stock_release_channel/models/stock_release_channel.py b/stock_release_channel/models/stock_release_channel.py index 674970331d..6cae00c4f5 100644 --- a/stock_release_channel/models/stock_release_channel.py +++ b/stock_release_channel/models/stock_release_channel.py @@ -248,7 +248,7 @@ def _prepare_domain(self): def assign_release_channel(self, pickings): pickings = pickings.filtered( - lambda picking: picking.need_release + lambda picking: picking.picking_type_id.code == "outgoing" and picking.state not in ("cancel", "done") ) if not pickings: From fc04841a29fd112d39fafbae151a1bc5b2252aea Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 25 Nov 2020 17:26:00 +0100 Subject: [PATCH 08/35] Add ~ in stock move counts As these numbers are estimates (all moves even if not ready) --- .../views/stock_release_channel_views.xml | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/stock_release_channel/views/stock_release_channel_views.xml b/stock_release_channel/views/stock_release_channel_views.xml index cce8e7edcd..716d8b2d43 100644 --- a/stock_release_channel/views/stock_release_channel_views.xml +++ b/stock_release_channel/views/stock_release_channel_views.xml @@ -154,7 +154,10 @@ To Do
-
+
@@ -162,7 +165,7 @@ name="action_move_release_ready" type="object" > - [] @@ -178,7 +181,10 @@ Released
-
+
@@ -186,7 +192,7 @@ name="action_move_released" type="object" > - [] @@ -202,13 +208,16 @@ Done Today
-
+
- [] @@ -244,7 +253,7 @@ t-esc="record.count_picking_assigned.value" /> To Process
- [[~ Lines] @@ -259,7 +268,10 @@ Waiting
-
+
@@ -267,7 +279,7 @@ name="action_move_waiting" type="object" > - [] @@ -287,14 +299,17 @@ Late
-
+
- [] @@ -310,7 +325,10 @@ Priority
-
+
@@ -318,7 +336,7 @@ name="action_move_priority" type="object" > - [] From 76ffc9920d8d413c066a15ee37b9de9897b40bc6 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 25 Nov 2020 17:32:54 +0100 Subject: [PATCH 09/35] Add totals in tree view --- .../views/stock_release_channel_views.xml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/stock_release_channel/views/stock_release_channel_views.xml b/stock_release_channel/views/stock_release_channel_views.xml index 716d8b2d43..1ecdd309fe 100644 --- a/stock_release_channel/views/stock_release_channel_views.xml +++ b/stock_release_channel/views/stock_release_channel_views.xml @@ -64,7 +64,14 @@ - + + + + + + + + From 58fd859e8b43dbd1db91317a3f114aa001fd224e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 25 Nov 2020 17:36:17 +0100 Subject: [PATCH 10/35] Filter on need_release is False for assigned too --- stock_release_channel/models/stock_release_channel.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/stock_release_channel/models/stock_release_channel.py b/stock_release_channel/models/stock_release_channel.py index 6cae00c4f5..b0eca48a58 100644 --- a/stock_release_channel/models/stock_release_channel.py +++ b/stock_release_channel/models/stock_release_channel.py @@ -135,20 +135,23 @@ def _field_picking_domains(self): ("need_release", "=", False), ("state", "in", ("assigned", "waiting", "confirmed")), ], - "count_picking_assigned": [("state", "=", "assigned")], + "count_picking_assigned": [ + ("need_release", "=", False), + ("state", "=", "assigned"), + ], "count_picking_waiting": [ ("need_release", "=", False), ("state", "in", ("waiting", "confirmed")), ], "count_picking_late": [ + ("need_release", "=", False), ("scheduled_date", "<", fields.Datetime.now()), ("state", "in", ("assigned", "waiting", "confirmed")), - ("need_release", "=", False), ], "count_picking_priority": [ + ("need_release", "=", False), ("priority", ">", "1"), ("state", "in", ("assigned", "waiting", "confirmed")), - ("need_release", "=", False), ], "count_picking_done": [ ("state", "=", "done"), From 290c013b5b7b4803c6d5f81266841d858a76219b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 1 Dec 2020 16:29:28 +0100 Subject: [PATCH 11/35] Improve kanban dashboard * Use new style of progress bars * Add progressbar for showing progress of transfers *before* the outgoing one (chained ones) * Improve CSS and labels --- stock_release_channel/__manifest__.py | 3 +- .../models/stock_release_channel.py | 101 +++++++++++--- .../src/scss/stock_release_channel.scss | 38 ++++++ .../views/stock_release_channel.xml | 18 +++ .../views/stock_release_channel_views.xml | 125 +++++++++++------- 5 files changed, 218 insertions(+), 67 deletions(-) create mode 100644 stock_release_channel/static/src/scss/stock_release_channel.scss create mode 100644 stock_release_channel/views/stock_release_channel.xml diff --git a/stock_release_channel/__manifest__.py b/stock_release_channel/__manifest__.py index 9838d37a9b..e0d35b9346 100644 --- a/stock_release_channel/__manifest__.py +++ b/stock_release_channel/__manifest__.py @@ -15,11 +15,12 @@ "queue_job", # OCA/queue ], "data": [ - "security/stock_release_channel.xml", + "views/stock_release_channel.xml", "views/stock_release_channel_views.xml", "views/stock_picking_views.xml", "data/stock_release_channel_data.xml", "data/queue_job_data.xml", "data/ir_cron_data.xml", + "security/stock_release_channel.xml", ], } diff --git a/stock_release_channel/models/stock_release_channel.py b/stock_release_channel/models/stock_release_channel.py index b0eca48a58..f6c6e8ff1c 100644 --- a/stock_release_channel/models/stock_release_channel.py +++ b/stock_release_channel/models/stock_release_channel.py @@ -49,7 +49,8 @@ class StockReleaseChannel(models.Model): string="Max Transfers to release", default=10, help="When clicking on the package icon, it releases X transfers minus " - " the work in progress transfers. This field defines X.", + " on-going ones not shipped (X - Waiting)." + " This field defines X.", ) picking_ids = fields.One2many( @@ -86,6 +87,21 @@ class StockReleaseChannel(models.Model): string="Transfers Done Today", compute="_compute_picking_count" ) + picking_chain_ids = fields.Many2many( + comodel_name="stock.picking", + compute="_compute_picking_chain", + help="All transfers required to bring goods to the deliveries.", + ) + count_picking_chain = fields.Integer( + string="All Related Transfers", compute="_compute_picking_chain" + ) + count_picking_chain_in_progress = fields.Integer( + string="In progress Related Transfers", compute="_compute_picking_chain" + ) + count_picking_chain_done = fields.Integer( + string="All Done Related Transfers", compute="_compute_picking_chain" + ) + count_move_all = fields.Integer( string="All Moves (Estimate)", compute="_compute_picking_count" ) @@ -211,6 +227,70 @@ def _compute_picking_count(self): ) record[move_field] = move_estimate + def _query_get_chain(self, pickings): + """Get all stock.picking before an outgoing one + + Follow recursively the move_orig_ids. + Outgoing picking ids are excluded + """ + query = """ + WITH RECURSIVE + pickings AS ( + SELECT move.picking_id, + true as outgoing, + ''::varchar as state, -- no need it, we exclude outgoing + move.id as move_orig_id + FROM stock_move move + WHERE move.picking_id in %s + + UNION + + SELECT move.picking_id, + false as outgoing, + picking.state, + rel.move_orig_id + FROM stock_move_move_rel rel + INNER JOIN pickings + ON pickings.move_orig_id = rel.move_dest_id + INNER JOIN stock_move move + ON move.id = rel.move_orig_id + INNER JOIN stock_picking picking + ON picking.id = move.picking_id + ) + SELECT DISTINCT picking_id, state FROM pickings + WHERE outgoing is false; + """ + return (query, (tuple(pickings.ids),)) + + def _compute_picking_chain(self): + self.env["stock.move"].flush(["move_dest_ids", "move_orig_ids", "picking_id"]) + self.env["stock.picking"].flush(["state"]) + for channel in self: + domain = self._field_picking_domains()["count_picking_released"] + domain += [("release_channel_id", "=", channel.id)] + released = self.env["stock.picking"].search(domain) + + if not released: + channel.picking_chain_ids = False + channel.count_picking_chain = 0 + channel.count_picking_chain_in_progress = 0 + channel.count_picking_chain_done = 0 + continue + + self.env.cr.execute(*self._query_get_chain(released)) + rows = self.env.cr.dictfetchall() + channel.picking_chain_ids = [row["picking_id"] for row in rows] + channel.count_picking_chain_in_progress = sum( + [1 for row in rows if row["state"] not in ("cancel", "done")] + ) + channel.count_picking_chain_done = sum( + [1 for row in rows if row["state"] == "done"] + ) + channel.count_picking_chain = ( + channel.count_picking_chain_done + + channel.count_picking_chain_in_progress + ) + # TODO this duplicated with shopfloor_kanban def _compute_last_done_picking(self): for channel in self: @@ -419,20 +499,9 @@ def _build_action(self, xmlid, records, description, context=None): def action_picking_all_related(self): """Open all chained transfers for released deliveries""" - domain = self._field_picking_domains()["count_picking_released"] - domain += [("release_channel_id", "=", self.id)] - released = self.env["stock.picking"].search(domain) - all_related_ids = set() - current_moves = released.move_lines - while current_moves: - all_related_ids |= set(current_moves.picking_id.ids) - current_moves = current_moves.move_orig_ids - - all_related = self.env["stock.picking"].browse(all_related_ids) - return self._build_action( "stock.action_picking_tree_all", - all_related, + self.picking_chain_ids, _("All Related Transfers"), context={"search_default_available": 1, "search_default_picking_type": 1}, ) @@ -464,9 +533,9 @@ def release_next_batch(self): if not self.max_auto_release: raise exceptions.UserError(_("No Max transfers to release is configured.")) - wip_domain = self._field_picking_domains()["count_picking_released"] - wip_domain += [("release_channel_id", "=", self.id)] - released_in_progress = self.env["stock.picking"].search_count(wip_domain) + waiting_domain = self._field_picking_domains()["count_picking_waiting"] + waiting_domain += [("release_channel_id", "=", self.id)] + released_in_progress = self.env["stock.picking"].search_count(waiting_domain) release_limit = max(self.max_auto_release - released_in_progress, 0) if not release_limit: diff --git a/stock_release_channel/static/src/scss/stock_release_channel.scss b/stock_release_channel/static/src/scss/stock_release_channel.scss new file mode 100644 index 0000000000..3381ac0755 --- /dev/null +++ b/stock_release_channel/static/src/scss/stock_release_channel.scss @@ -0,0 +1,38 @@ +.o_kanban_view.o_kanban_dashboard.o_stock_release_channel { + &.o_kanban_ungrouped { + .o_kanban_record { + width: 340px; + } + } + + .middle_block { + margin-top: $o-kanban-dashboard-vpadding; + margin-bottom: $o-kanban-dashboard-vpadding; + + border-top: 1px solid gray("300"); + border-bottom: 1px solid gray("300"); + background-color: gray("200"); + padding-top: $o-kanban-dashboard-vpadding; + padding-bottom: $o-kanban-dashboard-vpadding; + } + + .kanban_middle_title { + padding-right: 0; + padding-left: 0; + margin-bottom: $o-kanban-dashboard-vpadding; + } + + .middle_title { + @include o-kanban-record-title($font-size: 13px); + display: block; + } + + .primary_bottom_buttons { + .bottom_buttons_left, + .bottom_buttons_right { + display: flex; + align-items: center; + justify-content: center; + } + } +} diff --git a/stock_release_channel/views/stock_release_channel.xml b/stock_release_channel/views/stock_release_channel.xml new file mode 100644 index 0000000000..cd614d4025 --- /dev/null +++ b/stock_release_channel/views/stock_release_channel.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/stock_release_channel/views/stock_release_channel_views.xml b/stock_release_channel/views/stock_release_channel_views.xml index 1ecdd309fe..10e880a89a 100644 --- a/stock_release_channel/views/stock_release_channel_views.xml +++ b/stock_release_channel/views/stock_release_channel_views.xml @@ -72,6 +72,11 @@ + + @@ -81,7 +86,7 @@ stock.release.channel @@ -103,10 +108,12 @@ + +
@@ -116,10 +123,7 @@
-
+

-
-
+
+
- Released + To Do
-
-
+
+ +
+ +
+ To Do Situation +
@@ -258,7 +263,7 @@ > To Process + /> To Ship
[~ -
- + />)
-
-