[Feature] Route optimization for Delivery Trip stops (#15566)

* Minor improvements

* [Feature] Route optimization for Delivery Trip stops

* Process and optimize the entire route, with or without locks

* Form sets of routes for optimization based on applied lock positions

* Re-arrange stops based on the optimized routes and lock positions

* Set delay times between stops - offset estimation times by delays cumulatively

* Fix codacy errors

* Fix travis

* feat(route_optimization): Set estimated route distance from Google Maps

* feat(route_optimization): Add tests for route lists

* feat(route_optimization): Fix tests
This commit is contained in:
Alchez 2018-11-08 11:57:05 +05:30 committed by Rushabh Mehta
parent 3f6eb6b393
commit 068ba191e7
10 changed files with 896 additions and 234 deletions

View File

@ -1,10 +1,35 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
$.extend(cur_frm.cscript, {
validate: function(doc, cdt, cdn) {
return $c_obj(doc, 'get_defaults', '', function(r, rt){
onload: function (doc, cdt, cdn) {
cur_frm.trigger("get_distance_uoms");
},
validate: function (doc, cdt, cdn) {
return $c_obj(doc, 'get_defaults', '', function (r, rt) {
frappe.sys_defaults = r.message;
});
},
get_distance_uoms: function (frm) {
let units = [];
frappe.call({
method: "frappe.client.get_list",
args: {
doctype: "UOM Conversion Factor",
filters: { "category": "Length" },
fields: ["to_uom"],
limit_page_length: 500
},
callback: function (r) {
r.message.forEach(row => units.push(row.to_uom));
}
});
cur_frm.set_query("default_distance_unit", function (doc) {
return { filters: { "name": ["IN", units] } };
})
}
});

View File

@ -1,122 +1,183 @@
{
"allow_copy": 1,
"allow_email": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_print": 0,
"allow_rename": 0,
"allow_trash": 0,
"beta": 0,
"creation": "2013-05-02 17:53:24",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"editable_grid": 0,
"fields": [
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "default_company",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 1,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Default Company",
"length": 0,
"no_column": 0,
"no_copy": 0,
"options": "Company",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "current_fiscal_year",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Current Fiscal Year",
"length": 0,
"no_column": 0,
"no_copy": 0,
"options": "Fiscal Year",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "country",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Country",
"length": 0,
"no_column": 0,
"no_copy": 0,
"options": "Country",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "",
"fieldname": "default_distance_unit",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Default Distance Unit",
"length": 0,
"no_copy": 0,
"options": "UOM",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "column_break_8",
"fieldtype": "Column Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"length": 0,
"no_column": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "INR",
"fieldname": "default_currency",
"fieldtype": "Link",
@ -124,26 +185,32 @@
"ignore_user_permissions": 1,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Default Currency",
"length": 0,
"no_column": 0,
"no_copy": 0,
"options": "Currency",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"description": "Do not show any symbol like $ etc next to currencies.",
"fieldname": "hide_currency_symbol",
"fieldtype": "Select",
@ -151,26 +218,32 @@
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Hide Currency Symbol",
"length": 0,
"no_column": 0,
"no_copy": 0,
"options": "\nNo\nYes",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"description": "If disable, 'Rounded Total' field will not be visible in any transaction",
"fieldname": "disable_rounded_total",
"fieldtype": "Check",
@ -178,25 +251,31 @@
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Disable Rounded Total",
"length": 0,
"no_column": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"description": "If disable, 'In Words' field will not be visible in any transaction",
"fieldname": "disable_in_words",
"fieldtype": "Check",
@ -204,7 +283,9 @@
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Disable In Words",
"length": 0,
"no_copy": 0,
@ -213,26 +294,28 @@
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
}
],
"has_web_view": 0,
"hide_heading": 0,
"hide_toolbar": 0,
"icon": "fa fa-cog",
"idx": 1,
"image_view": 0,
"in_create": 1,
"is_submittable": 0,
"is_transaction_doc": 0,
"issingle": 1,
"istable": 0,
"max_attachments": 0,
"menu_index": 0,
"modified": "2016-03-03 16:14:41.260467",
"modified": "2018-10-15 03:08:19.886212",
"modified_by": "Administrator",
"module": "Setup",
"name": "Global Defaults",
@ -240,7 +323,6 @@
"permissions": [
{
"amend": 0,
"apply_user_permissions": 0,
"cancel": 0,
"create": 1,
"delete": 0,
@ -252,7 +334,6 @@
"print": 0,
"read": 1,
"report": 0,
"restrict": 0,
"role": "System Manager",
"set_user_permissions": 0,
"share": 1,
@ -260,10 +341,12 @@
"write": 1
}
],
"quick_entry": 0,
"read_only": 1,
"read_only_onload": 0,
"show_in_menu": 0,
"show_name_in_global_search": 0,
"sort_order": "DESC",
"use_template": 0,
"version": 0
"track_changes": 0,
"track_seen": 0,
"track_views": 0
}

View File

@ -0,0 +1,23 @@
/* eslint-disable */
// rename this file from _test_[name] to test_[name] to activate
// and remove above this line
QUnit.test("test: Global Defaults", function (assert) {
let done = assert.async();
// number of asserts
assert.expect(1);
frappe.run_serially([
// insert a new Global Defaults
() => frappe.tests.make('Global Defaults', [
// values to be set
{key: 'value'}
]),
() => {
assert.equal(cur_frm.doc.key, 'value');
},
() => done()
]);
});

View File

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from __future__ import unicode_literals
import frappe
import unittest
class TestGlobalDefaults(unittest.TestCase):
pass

View File

@ -143,6 +143,70 @@
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "cb_delivery",
"fieldtype": "Column Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"description": "In minutes",
"fieldname": "stop_delay",
"fieldtype": "Int",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Delay between Delivery Stops",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
}
],
"has_web_view": 0,
@ -155,7 +219,7 @@
"issingle": 1,
"istable": 0,
"max_attachments": 0,
"modified": "2018-09-05 00:16:23.569855",
"modified": "2018-09-09 23:51:34.279941",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Settings",

View File

@ -493,6 +493,38 @@
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "distance",
"fieldtype": "Float",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Distance",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "2",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
@ -525,6 +557,168 @@
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "lat",
"fieldtype": "Float",
"hidden": 1,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Latitude",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "column_break_19",
"fieldtype": "Column Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "",
"depends_on": "eval:doc.distance",
"fieldname": "uom",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "UOM",
"length": 0,
"no_copy": 0,
"options": "UOM",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "lng",
"fieldtype": "Float",
"hidden": 1,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Longitude",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "more_information_section",
"fieldtype": "Section Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "More Information",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
@ -568,7 +762,7 @@
"issingle": 0,
"istable": 1,
"max_attachments": 0,
"modified": "2018-09-05 00:51:55.275009",
"modified": "2018-10-11 22:32:27.450906",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Stop",

View File

@ -65,31 +65,43 @@ frappe.ui.form.on('Delivery Trip', {
},
calculate_arrival_time: function (frm) {
frappe.call({
method: 'erpnext.stock.doctype.delivery_trip.delivery_trip.get_arrival_times',
freeze: true,
freeze_message: __("Updating estimated arrival times."),
args: {
name: frm.doc.name,
},
callback: function (r) {
frm.reload_doc();
frappe.db.get_value("Google Maps Settings", { name: "Google Maps Settings" }, "enabled", (r) => {
if (r.enabled == 0) {
frappe.throw(__("Please enable Google Maps Settings to estimate and optimize routes"));
} else {
frappe.call({
method: 'erpnext.stock.doctype.delivery_trip.delivery_trip.get_arrival_times',
freeze: true,
freeze_message: __("Updating estimated arrival times."),
args: {
delivery_trip: frm.doc.name,
},
callback: function (r) {
frm.reload_doc();
}
});
}
});
})
},
optimize_route: function (frm) {
frappe.call({
method: 'erpnext.stock.doctype.delivery_trip.delivery_trip.optimize_route',
freeze: true,
freeze_message: __("Optimizing routes."),
args: {
name: frm.doc.name,
},
callback: function (r) {
frm.reload_doc();
frappe.db.get_value("Google Maps Settings", {name: "Google Maps Settings"}, "enabled", (r) => {
if (r.enabled == 0) {
frappe.throw(__("Please enable Google Maps Settings to estimate and optimize routes"));
} else {
frappe.call({
method: 'erpnext.stock.doctype.delivery_trip.delivery_trip.optimize_route',
freeze: true,
freeze_message: __("Optimizing routes."),
args: {
delivery_trip: frm.doc.name,
},
callback: function (r) {
frm.reload_doc();
}
});
}
});
})
},
notify_customers: function (frm) {

View File

@ -159,101 +159,7 @@
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "date",
"fieldtype": "Date",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Date",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "departure_time",
"fieldtype": "Time",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Departure Time",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "column_break_4",
"fieldtype": "Column Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Delivery Details",
"length": 0,
"no_copy": 0,
"permlevel": 0,
@ -336,6 +242,104 @@
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "total_distance",
"fieldtype": "Float",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Total Estimated Distance",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "2",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "",
"depends_on": "eval:doc.total_distance",
"fieldname": "uom",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Distance UOM",
"length": 0,
"no_copy": 0,
"options": "UOM",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "column_break_4",
"fieldtype": "Column Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
@ -369,6 +373,38 @@
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "departure_time",
"fieldtype": "Datetime",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Departure Time",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
@ -441,7 +477,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
"depends_on": "eval:!cur_frm.is_new()",
"depends_on": "eval:!doc.__islocal",
"fieldname": "calculate_arrival_time",
"fieldtype": "Button",
"hidden": 0,
@ -474,6 +510,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
"depends_on": "eval:!doc.__islocal",
"fieldname": "optimize_route",
"fieldtype": "Button",
"hidden": 0,
@ -574,7 +611,7 @@
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2018-09-05 01:20:34.165834",
"modified": "2018-10-11 22:32:04.355068",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Trip",
@ -627,5 +664,6 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 0,
"track_seen": 0
"track_seen": 0,
"track_views": 0
}

View File

@ -10,17 +10,43 @@ import frappe
from frappe import _
from frappe.contacts.doctype.address.address import get_address_display
from frappe.model.document import Document
from frappe.utils import get_datetime, get_link_to_form, cstr
from frappe.utils import cint, get_datetime, get_link_to_form
class DeliveryTrip(Document):
def __init__(self, *args, **kwargs):
super(DeliveryTrip, self).__init__(*args, **kwargs)
# Google Maps returns distances in meters by default
self.default_distance_uom = frappe.db.get_single_value("Global Defaults", "default_distance_unit") or "Meter"
self.uom_conversion_factor = frappe.db.get_value("UOM Conversion Factor",
{"from_uom": "Meter", "to_uom": self.default_distance_uom},
"value")
def validate(self):
self.validate_stop_addresses()
def on_submit(self):
self.update_delivery_notes()
def on_cancel(self):
self.update_delivery_notes(delete=True)
def validate_stop_addresses(self):
for stop in self.delivery_stops:
if not stop.customer_address:
stop.customer_address = get_address_display(frappe.get_doc("Address", stop.address).as_dict())
def update_delivery_notes(self, delete=False):
"""
Update all connected Delivery Notes with Delivery Trip details
(Driver, Vehicle, etc.). If `delete` is `True`, then details
are removed.
Args:
delete (bool, optional): Defaults to `False`. `True` if driver details need to be emptied, else `False`.
"""
delivery_notes = list(set([stop.delivery_note for stop in self.delivery_stops if stop.delivery_note]))
update_fields = {
@ -28,7 +54,7 @@ class DeliveryTrip(Document):
"driver_name": self.driver_name,
"vehicle_no": self.vehicle,
"lr_no": self.name,
"lr_date": self.date
"lr_date": self.departure_time
}
for delivery_note in delivery_notes:
@ -44,58 +70,175 @@ class DeliveryTrip(Document):
delivery_notes = [get_link_to_form("Delivery Note", note) for note in delivery_notes]
frappe.msgprint(_("Delivery Notes {0} updated".format(", ".join(delivery_notes))))
def process_route(self, optimize):
"""
Estimate the arrival times for each stop in the Delivery Trip.
If `optimize` is True, the stops will be re-arranged, based
on the optimized order, before estimating the arrival times.
Args:
optimize (bool): True if route needs to be optimized, else False
"""
if not frappe.db.get_single_value("Google Maps Settings", "enabled"):
frappe.throw(_("Cannot process route, since Google Maps Settings is disabled."))
departure_datetime = get_datetime(self.departure_time)
route_list = self.form_route_list(optimize)
# For locks, maintain idx count while looping through route list
idx = 0
for route in route_list:
directions = get_directions(route, optimize)
if directions:
if optimize and len(directions.get("waypoint_order")) > 1:
self.rearrange_stops(directions.get("waypoint_order"), start=idx)
# Avoid estimating last leg back to the home address
legs = directions.get("legs")[:-1] if route == route_list[-1] else directions.get("legs")
# Google Maps returns the legs in the optimized order
for leg in legs:
delivery_stop = self.delivery_stops[idx]
delivery_stop.lat, delivery_stop.lng = leg.get("end_location", {}).values()
delivery_stop.uom = self.default_distance_uom
distance = leg.get("distance", {}).get("value", 0.0) # in meters
delivery_stop.distance = distance * self.uom_conversion_factor
duration = leg.get("duration", {}).get("value", 0)
estimated_arrival = departure_datetime + datetime.timedelta(seconds=duration)
delivery_stop.estimated_arrival = estimated_arrival
stop_delay = frappe.db.get_single_value("Delivery Settings", "stop_delay")
departure_datetime = estimated_arrival + datetime.timedelta(minutes=cint(stop_delay))
idx += 1
# Include last leg in the final distance calculation
self.uom = self.default_distance_uom
total_distance = sum([leg.get("distance", {}).get("value", 0.0)
for leg in directions.get("legs")]) # in meters
self.total_distance = total_distance * self.uom_conversion_factor
else:
idx += len(route) - 1
self.save()
def form_route_list(self, optimize):
"""
Form a list of address routes based on the delivery stops. If locks
are present, and the routes need to be optimized, then they will be
split into sublists at the specified lock position(s).
Args:
optimize (bool): `True` if route needs to be optimized, else `False`
Returns:
(list of list of str): List of address routes split at locks, if optimize is `True`
"""
settings = frappe.get_single("Google Maps Settings")
home_address = get_address_display(frappe.get_doc("Address", settings.home_address).as_dict())
route_list = []
# Initialize first leg with origin as the home address
leg = [home_address]
for stop in self.delivery_stops:
leg.append(stop.customer_address)
if optimize and stop.lock:
route_list.append(leg)
leg = [stop.customer_address]
# For last leg, append home address as the destination
# only if lock isn't on the final stop
if len(leg) > 1:
leg.append(home_address)
route_list.append(leg)
route_list = [[sanitize_address(address) for address in route] for route in route_list]
return route_list
def rearrange_stops(self, optimized_order, start):
"""
Re-arrange delivery stops based on order optimized
for vehicle routing problems.
Args:
optimized_order (list of int): The index-based optimized order of the route
start (int): The index at which to start the rearrangement
"""
stops_order = []
# Child table idx starts at 1
for new_idx, old_idx in enumerate(optimized_order, 1):
new_idx = start + new_idx
old_idx = start + old_idx
self.delivery_stops[old_idx].idx = new_idx
stops_order.append(self.delivery_stops[old_idx])
self.delivery_stops[start:start + len(stops_order)] = stops_order
@frappe.whitelist()
def get_contact_and_address(name):
out = frappe._dict()
get_default_contact(out, name)
get_default_address(out, name)
return out
def get_default_contact(out, name):
contact_persons = frappe.db.sql(
"""
select parent,
(select is_primary_contact from tabContact c where c.name = dl.parent)
as is_primary_contact
from
SELECT parent,
(SELECT is_primary_contact FROM tabContact c WHERE c.name = dl.parent) AS is_primary_contact
FROM
`tabDynamic Link` dl
where
dl.link_doctype="Customer" and
dl.link_name=%s and
dl.parenttype = 'Contact'
WHERE
dl.link_doctype="Customer"
AND dl.link_name=%s
AND dl.parenttype = "Contact"
""", (name), as_dict=1)
if contact_persons:
for out.contact_person in contact_persons:
if out.contact_person.is_primary_contact:
return out.contact_person
out.contact_person = contact_persons[0]
return out.contact_person
else:
return None
def get_default_address(out, name):
shipping_addresses = frappe.db.sql(
"""
select parent,
(select is_shipping_address from tabAddress a where a.name=dl.parent) as is_shipping_address
from `tabDynamic Link` dl
where link_doctype="Customer"
and link_name=%s
and parenttype = 'Address'
SELECT parent,
(SELECT is_shipping_address FROM tabAddress a WHERE a.name=dl.parent) AS is_shipping_address
FROM
`tabDynamic Link` dl
WHERE
dl.link_doctype="Customer"
AND dl.link_name=%s
AND dl.parenttype = "Address"
""", (name), as_dict=1)
if shipping_addresses:
for out.shipping_address in shipping_addresses:
if out.shipping_address.is_shipping_address:
return out.shipping_address
out.shipping_address = shipping_addresses[0]
return out.shipping_address
else:
return None
@frappe.whitelist()
def get_contact_and_address(name):
out = frappe._dict()
get_default_contact(out, name)
get_default_address(out, name)
return out
@frappe.whitelist()
@ -103,67 +246,83 @@ def get_contact_display(contact):
contact_info = frappe.db.get_value(
"Contact", contact,
["first_name", "last_name", "phone", "mobile_no"],
as_dict=1)
as_dict=1)
contact_info.html = """ <b>%(first_name)s %(last_name)s</b> <br> %(phone)s <br> %(mobile_no)s""" % {
"first_name": contact_info.first_name,
"last_name": contact_info.last_name or "",
"phone": contact_info.phone or "",
"mobile_no": contact_info.mobile_no or "",
"mobile_no": contact_info.mobile_no or ""
}
return contact_info.html
def process_route(name, optimize):
doc = frappe.get_doc("Delivery Trip", name)
settings = frappe.get_single("Google Maps Settings")
gmaps_client = settings.get_client()
@frappe.whitelist()
def optimize_route(delivery_trip):
delivery_trip = frappe.get_doc("Delivery Trip", delivery_trip)
delivery_trip.process_route(optimize=True)
if not settings.enabled:
frappe.throw(_("Google Maps integration is not enabled"))
home_address = get_address_display(frappe.get_doc("Address", settings.home_address).as_dict())
address_list = []
@frappe.whitelist()
def get_arrival_times(delivery_trip):
delivery_trip = frappe.get_doc("Delivery Trip", delivery_trip)
delivery_trip.process_route(optimize=False)
for stop in doc.delivery_stops:
address_list.append(stop.customer_address)
# Cannot add datetime.date to datetime.timedelta
departure_datetime = get_datetime(doc.date) + doc.departure_time
def sanitize_address(address):
"""
Remove HTML breaks in a given address
try:
directions = gmaps_client.directions(origin=home_address,
destination=home_address, waypoints=address_list,
optimize_waypoints=optimize, departure_time=departure_datetime)
except Exception as e:
frappe.throw((e.message))
Args:
address (str): Address to be sanitized
if not directions:
Returns:
(str): Sanitized address
"""
if not address:
return
directions = directions[0]
duration = 0
address = address.split('<br>')
# Google Maps returns the optimized order of the waypoints that were sent
for idx, order in enumerate(directions.get("waypoint_order")):
# We accordingly rearrange the rows
doc.delivery_stops[order].idx = idx + 1
# Google Maps returns the "legs" in the optimized order, so we loop through it
duration += directions.get("legs")[idx].get("duration").get("value")
arrival_datetime = departure_datetime + datetime.timedelta(seconds=duration)
doc.delivery_stops[order].estimated_arrival = arrival_datetime
doc.save()
frappe.db.commit()
# Only get the first 3 blocks of the address
return ', '.join(address[:3])
@frappe.whitelist()
def optimize_route(name):
process_route(name, optimize=True)
def get_directions(route, optimize):
"""
Retrieve map directions for a given route and departure time.
If optimize is `True`, Google Maps will return an optimized
order for the intermediate waypoints.
NOTE: Google's API does take an additional `departure_time` key,
but it only works for routes without any waypoints.
@frappe.whitelist()
def get_arrival_times(name):
process_route(name, optimize=False)
Args:
route (list of str): Route addresses (origin -> waypoint(s), if any -> destination)
optimize (bool): `True` if route needs to be optimized, else `False`
Returns:
(dict): Route legs and, if `optimize` is `True`, optimized waypoint order
"""
settings = frappe.get_single("Google Maps Settings")
maps_client = settings.get_client()
directions_data = {
"origin": route[0],
"destination": route[-1],
"waypoints": route[1: -1],
"optimize_waypoints": optimize
}
try:
directions = maps_client.directions(**directions_data)
except Exception as e:
frappe.throw(_(e.message))
return directions[0] if directions else False
@frappe.whitelist()
@ -171,10 +330,6 @@ def notify_customers(delivery_trip):
delivery_trip = frappe.get_doc("Delivery Trip", delivery_trip)
context = delivery_trip.as_dict()
context.update({
"departure_time": cstr(context.get("departure_time")),
"estimated_arrival": cstr(context.get("estimated_arrival"))
})
if delivery_trip.driver:
context.update(frappe.db.get_value("Driver", delivery_trip.driver, "cell_number", as_dict=1))

View File

@ -9,7 +9,7 @@ import erpnext
import frappe
from erpnext.stock.doctype.delivery_trip.delivery_trip import get_contact_and_address, notify_customers
from erpnext.tests.utils import create_test_contact_and_address
from frappe.utils import add_days, now_datetime, nowdate
from frappe.utils import add_days, now_datetime
class TestDeliveryTrip(unittest.TestCase):
@ -19,28 +19,58 @@ class TestDeliveryTrip(unittest.TestCase):
create_delivery_notification()
create_test_contact_and_address()
def test_delivery_trip(self):
contact = get_contact_and_address("_Test Customer")
settings = frappe.get_single("Google Maps Settings")
settings.home_address = frappe.get_last_doc("Address").name
settings.save()
if not frappe.db.exists("Delivery Trip", "TOUR-00000"):
delivery_trip = frappe.get_doc({
"doctype": "Delivery Trip",
"company": erpnext.get_default_company(),
"date": add_days(nowdate(), 5),
"departure_time": add_days(now_datetime(), 5),
"driver": frappe.db.get_value('Driver', {"full_name": "Newton Scmander"}),
"vehicle": "JB 007",
"delivery_stops": [{
"customer": "_Test Customer",
"address": contact.shipping_address.parent,
"contact": contact.contact_person.parent
}]
})
delivery_trip.insert()
self.delivery_trip = create_delivery_trip()
notify_customers(delivery_trip=delivery_trip.name)
delivery_trip.load_from_db()
self.assertEqual(delivery_trip.email_notification_sent, 1)
def tearDown(self):
frappe.db.sql("delete from `tabDriver`")
frappe.db.sql("delete from `tabVehicle`")
frappe.db.sql("delete from `tabEmail Template`")
frappe.db.sql("delete from `tabDelivery Trip`")
def test_delivery_trip_notify_customers(self):
notify_customers(delivery_trip=self.delivery_trip.name)
self.delivery_trip.load_from_db()
self.assertEqual(self.delivery_trip.email_notification_sent, 1)
def test_unoptimized_route_list_without_locks(self):
route_list = self.delivery_trip.form_route_list(optimize=False)
# Return a single list of destinations, from home address and back
self.assertEqual(len(route_list), 1)
self.assertEqual(len(route_list[0]), 4)
def test_unoptimized_route_list_with_locks(self):
self.delivery_trip.delivery_stops[0].lock = 1
self.delivery_trip.save()
route_list = self.delivery_trip.form_route_list(optimize=False)
# Return a single list of destinations, from home address and back,
# since the stops don't need to optimized and simple time
# estimation is enough
self.assertEqual(len(route_list), 1)
self.assertEqual(len(route_list[0]), 4)
def test_optimized_route_list_without_locks(self):
route_list = self.delivery_trip.form_route_list(optimize=True)
# Return a single list of destinations, from home address and back,
# since the route doesn't have any locks to be optimized against
self.assertEqual(len(route_list), 1)
self.assertEqual(len(route_list[0]), 4)
def test_optimized_route_list_with_locks(self):
self.delivery_trip.delivery_stops[0].lock = 1
self.delivery_trip.save()
route_list = self.delivery_trip.form_route_list(optimize=True)
# Return multiple route lists, taking the home address as start and end
self.assertEqual(len(route_list), 2)
self.assertEqual(len(route_list[0]), 2) # [home_address, locked_stop]
self.assertEqual(len(route_list[1]), 3) # [locked_stop, second_stop, home_address]
def create_driver():
@ -67,6 +97,7 @@ def create_delivery_notification():
delivery_settings = frappe.get_single("Delivery Settings")
delivery_settings.dispatch_template = 'Delivery Notification'
delivery_settings.save()
def create_vehicle():
@ -84,3 +115,30 @@ def create_vehicle():
"vehicle_value": frappe.utils.flt(500000)
})
vehicle.insert()
def create_delivery_trip(contact=None):
if not contact:
contact = get_contact_and_address("_Test Customer")
delivery_trip = frappe.new_doc("Delivery Trip")
delivery_trip.update({
"doctype": "Delivery Trip",
"company": erpnext.get_default_company(),
"departure_time": add_days(now_datetime(), 5),
"driver": frappe.db.get_value('Driver', {"full_name": "Newton Scmander"}),
"vehicle": "JB 007",
"delivery_stops": [{
"customer": "_Test Customer",
"address": contact.shipping_address.parent,
"contact": contact.contact_person.parent
},
{
"customer": "_Test Customer",
"address": contact.shipping_address.parent,
"contact": contact.contact_person.parent
}]
})
delivery_trip.insert()
return delivery_trip