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