diff --git a/joint_buying_product/__manifest__.py b/joint_buying_product/__manifest__.py index d80887aa..61103e11 100644 --- a/joint_buying_product/__manifest__.py +++ b/joint_buying_product/__manifest__.py @@ -20,6 +20,7 @@ "product_uom_package", ], "pre_init_hook": "pre_init_product_db", + "external_dependencies": {"python": ["treelib"]}, "data": [ "security/ir.model.access.csv", "views/menu.xml", diff --git a/joint_buying_product/models/joint_buying_transport_request.py b/joint_buying_product/models/joint_buying_transport_request.py index 3e1802b5..748d76b1 100644 --- a/joint_buying_product/models/joint_buying_transport_request.py +++ b/joint_buying_product/models/joint_buying_transport_request.py @@ -100,7 +100,7 @@ class JointBuyingTransportRequest(models.Model): tour_line_ids = fields.Many2many( comodel_name="joint.buying.tour.line", - string="Route Lines", + string="Tour Lines", ) arrival_date = fields.Datetime(string="Arrival Date", readonly=True) diff --git a/joint_buying_product/tests/__init__.py b/joint_buying_product/tests/__init__.py index 9ee38a16..b754c1df 100644 --- a/joint_buying_product/tests/__init__.py +++ b/joint_buying_product/tests/__init__.py @@ -1,6 +1,7 @@ from . import test_abstract from . import test_joint_buying_purchase_order from . import test_joint_buying_transport_request +from . import test_joint_buying_wizard_find_route from . import test_product from . import test_check_access_product from . import test_check_access_joint_buying_purchase diff --git a/joint_buying_product/tests/test_joint_buying_wizard_find_route.py b/joint_buying_product/tests/test_joint_buying_wizard_find_route.py new file mode 100644 index 00000000..40b59524 --- /dev/null +++ b/joint_buying_product/tests/test_joint_buying_wizard_find_route.py @@ -0,0 +1,68 @@ +# Copyright (C) 2023 - Today: GRAP (http://www.grap.coop) +# @author: Sylvain LE GAL (https://twitter.com/legalsylvain) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +from odoo.tests import tagged + +from .test_abstract import TestAbstract + + +@tagged("post_install", "-at_install") +class TestJointBuyingWizardFindRoute(TestAbstract): + def setUp(self): + super().setUp() + + def test_20_transport_request_vev_cda(self): + self._verify_tour_lines_computation( + "joint_buying_product.request_vev_cda", + ["joint_buying_base.tour_lyon_loire_1_line_4"], + "computed", + ) + + def test_21_transport_request_vev_che(self): + self._verify_tour_lines_computation( + "joint_buying_product.request_vev_che", + [ + "joint_buying_base.tour_lyon_loire_1_line_4", + "joint_buying_base.tour_lyon_loire_1_line_6", + "joint_buying_base.tour_lyon_drome_1_line_2", + "joint_buying_base.tour_lyon_drome_1_line_4", + ], + "computed", + ) + + def test_22_transport_request_vev_edc(self): + self._verify_tour_lines_computation( + "joint_buying_product.request_vev_edc", + [ + "joint_buying_base.tour_lyon_loire_1_line_4", + "joint_buying_base.tour_lyon_loire_1_line_6", + "joint_buying_base.tour_lyon_savoie_1_line_2", + "joint_buying_base.tours_savoie_1_line_2", + ], + "computed", + ) + + def test_23_transport_request_vev_fumet(self): + self._verify_tour_lines_computation( + "joint_buying_product.request_vev_fumet_dombes", [], "not_computable" + ) + + def _verify_tour_lines_computation( + self, request_xml_id, tour_line_xml_ids, expected_state + ): + transport_request = self.env.ref(request_xml_id) + wizard = ( + self.env["joint.buying.wizard.find.route"] + .with_context(active_id=transport_request.id) + .create({}) + ) + wizard.button_apply() + + tour_line_ids = [] + for line_xml_id in tour_line_xml_ids: + tour_line_ids.append(self.env.ref(line_xml_id).id) + + self.assertEqual(transport_request.tour_line_ids.ids, tour_line_ids) + self.assertEqual(transport_request.state, expected_state) diff --git a/joint_buying_product/wizards/joint_buying_wizard_find_route.py b/joint_buying_product/wizards/joint_buying_wizard_find_route.py index f345c6a8..4fbd957f 100644 --- a/joint_buying_product/wizards/joint_buying_wizard_find_route.py +++ b/joint_buying_product/wizards/joint_buying_wizard_find_route.py @@ -2,6 +2,11 @@ # @author: Sylvain LE GAL (https://twitter.com/legalsylvain) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import datetime +from types import SimpleNamespace + +from treelib import Tree + from odoo import api, fields, models from odoo.addons.joint_buying_base.models.res_partner import ( @@ -13,6 +18,9 @@ class JointBuyingWizardFindRoute(models.TransientModel): _name = "joint.buying.wizard.find.route" _description = "Joint Buying Wizard Find Route" + # 30 Days + _MAX_TRANSPORT_DURATION = 30 + transport_request_id = fields.Many2one( string="Transport Request", comodel_name="joint.buying.transport.request", @@ -41,7 +49,17 @@ class JointBuyingWizardFindRoute(models.TransientModel): readonly=True, ) - simulation = fields.Text( + tour_line_ids = fields.Many2many( + comodel_name="joint.buying.tour.line", + string="Tour Lines", + compute="_compute_simulation", + ) + + tree_text = fields.Text( + compute="_compute_simulation", + ) + + is_different_simulation = fields.Boolean( compute="_compute_simulation", ) @@ -52,27 +70,165 @@ def _default_transport_reqquest_id(self): @api.depends("transport_request_id") def _compute_simulation(self): self.ensure_one() - self.compute_results(self) + results = self.compute_results(self.transport_request_id) + result = results.get(self.transport_request_id) + if result: + tree, self.tour_line_ids = result + self.tree_text = tree.show(stdout=False, line_type="ascii-em") + self.is_different_simulation = ( + self.transport_request_id.tour_line_ids.ids != self.tour_line_ids.ids + ) def compute_results(self, transport_requests): """Endtry point to compute the best way for a RecordSet of Transport Requests""" - sections = self.env["joint.buying.tour.line"].search_read( - domain=[ - ("sequence_type", "=", "journey"), - ("start_date", ">=", min(transport_requests.mapped("start_date"))), - ], - fields=[ - "starting_point_id", - "tour_id", - "distance", - "start_date", - "arrival_date", - "arrival_point_id", + results = {} + for transport_request in transport_requests: + tree = self._populate_tree(transport_request) + lines = self._extract_tour_lines_from_tree(tree, transport_request) + results[transport_request] = tree, lines + + return results + + @api.model + def _extract_tour_lines_from_tree(self, tree, transport_request): + # For the time being, there is only one valid way + destination_node = [ + x + for x in tree.all_nodes() + if x.data.partner == transport_request.destination_partner_id + ] + if not destination_node: + return self.env["joint.buying.tour.line"] + destination_node = destination_node[0] + + line_ids = [] + + for x in tree.rsearch(destination_node.identifier): + if tree[x].data.line: + line_ids.append(tree[x].data.line.id) + + return self.env["joint.buying.tour.line"].search( + [("id", "in", line_ids)], order="start_date" + ) + + @api.model + def _populate_tree(self, transport_request): + # Get all the tours subsequent to the transport request for a given period of time + max_date = transport_request.start_date + datetime.timedelta( + days=self._MAX_TRANSPORT_DURATION + ) + tours = self.env["joint.buying.tour"].search( + [ + ("start_date", ">=", transport_request.start_date), + ("start_date", "<=", max_date), ], order="start_date", ) - sections = sections + + # Initialize Tree + tree = Tree() + self._create_initial_node( + tree, + transport_request.origin_partner_id, + transport_request.start_date, + ) + + # We go through all the tours, in ascending date order + for tour in tours: + # we select the places (nodes) where the merchandise can be + startable_nodes = [x for x in tree.all_nodes() if x.data.durable_storable] + for startable_node in startable_nodes: + for line in tour.line_ids.filtered( + lambda x: x.sequence_type == "journey" + and startable_node.data.date <= x.start_date + and startable_node.data.partner == x.starting_point_id + ): + found_lines = self._get_interesting_route( + tour=tour, + from_line=line, + destination=transport_request.destination_partner_id, + excludes=[x.data.partner for x in startable_nodes], + ) + if found_lines: + current_node = startable_node + for found_line in found_lines: + current_node = self._create_following_node( + tree, + current_node, + found_line, + transport_request.destination_partner_id, + ) + + # we've arrived at our destination! + if ( + found_line.arrival_point_id + == transport_request.destination_partner_id + ): + return tree + + return tree + + @api.model + def _create_initial_node(self, tree, partner, date): + durable_storable = True + return tree.create_node( + tag=f"{partner.joint_buying_code}-{date}-{durable_storable}", + data=SimpleNamespace( + partner=partner, + date=date, + durable_storable=durable_storable, + line=False, + ), + ) + + @api.model + def _create_following_node(self, tree, parent, line, destination): + partner = line.arrival_point_id + date = line.arrival_date + durable_storable = ( + line.arrival_point_id == destination + or line.arrival_point_id.joint_buying_is_durable_storage + ) + return tree.create_node( + parent=parent, + tag=f"{partner.joint_buying_code}-{date}-{durable_storable}-{line.id}", + data=SimpleNamespace( + partner=partner, date=date, durable_storable=durable_storable, line=line + ), + ) + + @api.model + def _get_interesting_route(self, tour, from_line, destination, excludes): + # we try to go the destination + available_lines = [ + x + for x in tour.line_ids.filtered( + lambda x: x.start_date >= from_line.start_date + and x.sequence_type == "journey" + ) + ] + + # The destination is present in the available lines, directly return + if destination in [x.arrival_point_id for x in available_lines]: + return available_lines + + # We start from the end, and we remove uninteresting lines + # If an arrival is interesting, we keep all the journey + # from the from_line to the arrival + available_lines.reverse() + + result = [] + to_select = False + for line in available_lines: + if to_select or ( + line.arrival_point_id.joint_buying_is_durable_storage + and line.arrival_point_id not in excludes + ): + result.append(line) + to_select = True + result.reverse() + return result def button_apply(self): self.ensure_one() - raise NotImplementedError() + self.transport_request_id._set_tour_lines(self.tour_line_ids) diff --git a/joint_buying_product/wizards/joint_buying_wizard_find_route.xml b/joint_buying_product/wizards/joint_buying_wizard_find_route.xml index bf42b0b0..b2ff45a0 100644 --- a/joint_buying_product/wizards/joint_buying_wizard_find_route.xml +++ b/joint_buying_product/wizards/joint_buying_wizard_find_route.xml @@ -15,14 +15,29 @@ License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). - - - + + + + + + + + + + + + + + + + diff --git a/requirements.txt b/requirements.txt index 27170de5..012a43a9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ bokeh==1.1.0 geopy openupgradelib pandas +treelib