diff --git a/CHANGELOG.md b/CHANGELOG.md index b199acad2..81ab98715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Added +- CSV headers adapts to the language provided through HTTP_ACCEPT_LANGUAGE header to facilitate import in Mapotempo-Web [#196](https://github.com/Mapotempo/optimizer-api/pull/196) +- Return route day/date and visits' index in result [#196](https://github.com/Mapotempo/optimizer-api/pull/196) + ### Changed ### Removed diff --git a/api/v01/api.rb b/api/v01/api.rb index 1426c538c..d32b061fd 100644 --- a/api/v01/api.rb +++ b/api/v01/api.rb @@ -39,7 +39,13 @@ class Api < Grape::API helpers do def set_locale - I18n.locale = env.http_accept_language.compatible_language_from(I18n.available_locales.map(&:to_s)) || I18n.default_locale + I18n.locale = + if env.http_accept_language.header.nil? || env.http_accept_language.header == :unspecified + # we keep previous behaviour + :legacy + else + env.http_accept_language.compatible_language_from(I18n.available_locales.map(&:to_s)) || I18n.default_locale + end end def redis_count diff --git a/api/v01/entities/vrp_input.rb b/api/v01/entities/vrp_input.rb index 2f3dbf1ab..3597e49d5 100644 --- a/api/v01/entities/vrp_input.rb +++ b/api/v01/entities/vrp_input.rb @@ -200,6 +200,7 @@ module VrpConfiguration optional(:geometry_polyline, type: Boolean, documentation: { hidden: true }, desc: '[DEPRECATED] Use geometry instead, with :polylines or :encoded_polylines') optional(:intermediate_solutions, type: Boolean, desc: 'Return intermediate solutions if available') optional(:csv, type: Boolean, desc: 'The output is a CSV file if you do not specify api format') + optional(:use_deprecated_csv_headers, type: Boolean, desc: 'Forces API to ignore provided language to return old CSV headers') optional(:allow_empty_result, type: Boolean, desc: 'Allow no solution from the solver used') end diff --git a/api/v01/entities/vrp_result.rb b/api/v01/entities/vrp_result.rb index 578aa3032..3603cd368 100644 --- a/api/v01/entities/vrp_result.rb +++ b/api/v01/entities/vrp_result.rb @@ -74,10 +74,14 @@ class VrpResultSolutionRouteActivities < Grape::Entity expose :type, documentation: { type: String, desc: 'depot, rest, service, pickup or delivery' } expose :current_distance, documentation: { type: Integer, desc: 'Travel distance from route start to current point (in m)' } expose :alternative, documentation: { type: Integer, desc: 'When one service has alternative activities, index of the chosen one' } + expose :visit_index, documentation: { type: Integer, desc: 'Index of the visit' } end class VrpResultSolutionRoute < Grape::Entity - expose :vehicle_id, documentation: { type: String, desc: 'Internal reference of the vehicule used for the current route' } + expose :day, documentation: { type: [Integer, Date], + desc: 'Day index or date (if provided within schedule) where route takes place' } + expose :vehicle_id, documentation: { type: String, + desc: 'Internal reference of vehicule corresponding to this route' } expose :activities, using: VrpResultSolutionRouteActivities, documentation: { is_array: true, desc: 'Every step of the route' } expose :total_travel_time, documentation: { type: Integer, desc: 'Sum of every travel time within the route (in s)' } expose :total_distance, documentation: { type: Integer, desc: 'Sum of every distance within the route (in m)' } diff --git a/api/v01/vrp.rb b/api/v01/vrp.rb index 61991528b..3af461d02 100644 --- a/api/v01/vrp.rb +++ b/api/v01/vrp.rb @@ -117,7 +117,7 @@ class Vrp < APIBase elsif ret.is_a?(Hash) status 200 if vrp.restitution_csv - present(OptimizerWrapper.build_csv([ret]), type: CSV) + present(OutputHelper::Result.build_csv([ret]), type: CSV) else present({ solutions: [ret], job: { status: :completed }}, with: VrpResult) end @@ -172,7 +172,7 @@ class Vrp < APIBase end if output_format == :csv && (job.nil? || job.completed?) # At this step, if the job is nil then it has already been retrieved into the result store - present(OptimizerWrapper.build_csv(solution[:result]), type: CSV) + present(OutputHelper::Result.build_csv(solution[:result]), type: CSV) else present({ solutions: solution[:result], @@ -226,7 +226,7 @@ class Vrp < APIBase if solution && !solution.empty? output_format = params[:format]&.to_sym || (solution[:configuration] && solution[:configuration][:csv] ? :csv : env['api.format']) if output_format == :csv - present(OptimizerWrapper.build_csv(solution[:result]), type: CSV) + present(OutputHelper::Result.build_csv(solution[:result]), type: CSV) else present({ solutions: [solution[:result]], diff --git a/config/environments/development.rb b/config/environments/development.rb index 2202468be..aeb62ea0d 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -56,9 +56,6 @@ module OptimizerWrapper OptimizerLogger.with_datetime = true OptimizerLogger.caller_location = :relative - I18n.available_locales = [:en, :fr] - I18n.default_locale = :en - @@c = { product_title: 'Optimizers API', product_contact_email: 'tech@mapotempo.com', diff --git a/config/environments/production.rb b/config/environments/production.rb index 4c08926df..7ca85a67b 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -53,9 +53,6 @@ module OptimizerWrapper OptimizerLogger.with_datetime = true # OptimizerLogger.caller_location = nil => nil is default - I18n.available_locales = [:en, :fr] - I18n.default_locale = :en - @@c = { product_title: 'Optimizers API', product_contact_email: 'tech@mapotempo.com', diff --git a/config/environments/test.rb b/config/environments/test.rb index d9077c5d0..d35adeebd 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -55,9 +55,6 @@ module OptimizerWrapper OptimizerLogger.with_datetime = true # OptimizerLogger.caller_location = nil => nil is default - I18n.available_locales = [:en, :fr] - I18n.default_locale = :en - @@c = { product_title: 'Optimizers API', product_contact_email: 'tech@mapotempo.com', diff --git a/config/initializers/i18n.rb b/config/initializers/i18n.rb index cb071d80a..5c6772db6 100644 --- a/config/initializers/i18n.rb +++ b/config/initializers/i18n.rb @@ -2,3 +2,5 @@ I18n.load_path += Dir['lib/grape/locale/*.yml'] I18n::Backend::Simple.include(I18n::Backend::Fallbacks) I18n.enforce_available_locales = false +I18n.available_locales = [:en, :fr, :es, :legacy] +I18n.default_locale = :en diff --git a/config/locales/en.yml b/config/locales/en.yml index e14eddbbb..6c2a9f9f4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -17,3 +17,37 @@ # en: + export_file: + comment: comment + plan: + name: plan + ref: reference plan + route: + day: day + id: route + original_id: vehicle + total_travel_distance: total travel distance + total_travel_time: total travel time + total_wait_time: total waiting time + stop: + additional_value: additional_value + duration: visit duration + end_time: end time + lat: lat + lon: lng + name: name + point_id: reference + quantity: "quantity[%{unit}]" + reference: reference visit + setup: duration per destination + skills: tags visit + start_time: time + tw_end: "time window end %{index}" + tw_start: "time window start %{index}" + type: stop type + type_rest: rest + type_store: store + type_visit: visit + visit_index: visit index + wait_time: waiting time + tags: tags diff --git a/config/locales/es.yml b/config/locales/es.yml new file mode 100644 index 000000000..40785b087 --- /dev/null +++ b/config/locales/es.yml @@ -0,0 +1,53 @@ +# Copyright © Mapotempo, 2021 +# +# This file is part of Mapotempo. +# +# Mapotempo is free software. You can redistribute it and/or +# modify since you respect the terms of the GNU Affero General +# Public License as published by the Free Software Foundation, +# either version 3 of the License, or (at your option) any later version. +# +# Mapotempo is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the Licenses for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Mapotempo. If not, see: +# +# + +es: + export_file: + comment: comentario + plan: + name: plan + ref: referencia del plan + route: + day: día + id: gira + original_id: vehículo + total_travel_distance: recorrido total + total_travel_time: duración total + total_wait_time: tiempo de espera total + stop: + additional_value: valor adicional + duration: duración visita + end_time: fin + lat: lat + lon: lng + name: nombre + point_id: referencia + quantity: "cantidad[%{unit}]" + reference: referencia visita + setup: duración de preparación + skills: etiquetas visita + start_time: hora + tw_end: "horario fin %{index}" + tw_start: "horario inicio %{index}" + type: tipo parada + type_rest: descanso + type_store: depósito + type_visit: visita + visit_index: índice de la visita + wait_time: tiempo de espera + tags: etiquetas diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 9d2910646..1dc702bfd 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -17,3 +17,37 @@ # fr: + export_file: + comment: commentaire + plan: + name: plan + ref: référence plan + route: + day: jour + id: tournée + original_id: véhicule + total_travel_distance: distance totale + total_travel_time: temps de trajet total + total_wait_time: temps d'attente total + stop: + additional_value: valeur additionnelle + duration: durée visite + end_time: fin de la mission + lat: lat + lon: lng + name: nom + point_id: référence + quantity: "quantité[%{unit}]" + reference: référence visite + setup: durée client + skills: libellés visite + start_time: heure + tw_end: "horaire fin %{index}" + tw_start: "horaire début %{index}" + type: type arrêt + type_rest: pause + type_store: dépôt + type_visit: visite + visit_index: indice de la visite + wait_time: attente + tags: libellés diff --git a/config/locales/legacy.yml b/config/locales/legacy.yml new file mode 100644 index 000000000..4c5034deb --- /dev/null +++ b/config/locales/legacy.yml @@ -0,0 +1,53 @@ +# Copyright © Mapotempo, 2021 +# +# This file is part of Mapotempo. +# +# Mapotempo is free software. You can redistribute it and/or +# modify since you respect the terms of the GNU Affero General +# Public License as published by the Free Software Foundation, +# either version 3 of the License, or (at your option) any later version. +# +# Mapotempo is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the Licenses for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Mapotempo. If not, see: +# +# + +legacy: + export_file: + comment: unassigned_reason + plan: + name: day_week_num + ref: day_week + route: + day: day + id: vehicle_id + original_id: original_vehicle_id + total_travel_distance: total_travel_distance + total_travel_time: total_travel_time + total_wait_time: total_waiting_time + stop: + additional_value: additional_value + duration: duration + end_time: end_time + lat: lat + lon: lon + name: original_id + point_id: point_id + quantity: "quantity_%{unit}" + reference: id + setup: setup_duration + skills: skills + start_time: begin_time + tw_end: "timewindow_end_%{index}" + tw_start: "timewindow_start_%{index}" + type: type + type_rest: rest + type_store: store + type_visit: visit + visit_index: visit_index + wait_time: waiting_time + tags: tags diff --git a/lib/helper.rb b/lib/helper.rb index 79dd6714c..0556ae638 100644 --- a/lib/helper.rb +++ b/lib/helper.rb @@ -65,19 +65,21 @@ def self.euclidean_distance(loc_a, loc_b) def self.merge_results(results, merge_unassigned = true) results.flatten! + results.compact! { - solvers: results.flat_map{ |r| r && r[:solvers] }.compact, - cost: results.map{ |r| r && r[:cost] }.compact.reduce(&:+), - cost_details: results.map{ |r| r && r[:cost_details] }.compact.sum, - iterations: (results.size != 1) ? nil : results[0] && results[0][:iterations], - heuristic_synthesis: (results.size != 1) ? nil : results[0] && results[0][:heuristic_synthesis], - routes: results.flat_map{ |r| r && r[:routes] }.compact.uniq, - unassigned: merge_unassigned ? results.flat_map{ |r| r && r[:unassigned] }.compact.uniq : results.map{ |r| r && r[:unassigned] }.compact.last, - elapsed: results.map{ |r| r && r[:elapsed] || 0 }.reduce(&:+), - total_time: results.map{ |r| r && r[:total_time] }.compact.reduce(&:+), - total_travel_time: results.map{ |r| r && r[:total_travel_time] }.compact.reduce(&:+), - total_value: results.map{ |r| r && r[:total_travel_value] }.compact.reduce(&:+), - total_distance: results.map{ |r| r && r[:total_distance] }.compact.reduce(&:+) + solvers: results.flat_map{ |r| r[:solvers] }.compact, + cost: results.map{ |r| r[:cost] }.compact.reduce(&:+), + cost_details: results.map{ |r| r[:cost_details] }.compact.sum, + iterations: results.size != 1 ? nil : results[0][:iterations], + heuristic_synthesis: results.size != 1 ? nil : results[0][:heuristic_synthesis], + routes: results.flat_map{ |r| r[:routes] }.compact.uniq, + unassigned: merge_unassigned ? results.flat_map{ |r| r[:unassigned] }.compact.uniq : results.map{ |r| r[:unassigned] }.compact.last, + elapsed: results.map{ |r| r[:elapsed] || 0 }.reduce(&:+), + total_time: results.map{ |r| r[:total_time] }.compact.reduce(&:+), + total_travel_time: results.map{ |r| r[:total_travel_time] }.compact.reduce(&:+), + total_value: results.map{ |r| r[:total_travel_value] }.compact.reduce(&:+), + total_distance: results.map{ |r| r[:total_distance] }.compact.reduce(&:+), + use_deprecated_csv_headers: results.any?{ |r| r[:use_deprecated_csv_headers] }, } end diff --git a/lib/heuristics/scheduling_heuristic.rb b/lib/heuristics/scheduling_heuristic.rb index fafdb254d..2e7a1047f 100644 --- a/lib/heuristics/scheduling_heuristic.rb +++ b/lib/heuristics/scheduling_heuristic.rb @@ -18,7 +18,6 @@ require './lib/helper.rb' require './wrappers/wrapper.rb' -require './lib/output_helper.rb' require './lib/heuristics/concerns/scheduling_data_initialisation' require './lib/heuristics/concerns/scheduling_end_phase' @@ -1210,6 +1209,7 @@ def construct_sub_vrp(vrp, vehicle, current_route) # configuration route_vrp.schedule_range_indices = nil + route_vrp.schedule_start_date = nil route_vrp.resolution_minimum_duration = 100 route_vrp.resolution_time_out_multiplier = 5 diff --git a/lib/interpreters/split_clustering.rb b/lib/interpreters/split_clustering.rb index 49acbfb2b..d239a4786 100644 --- a/lib/interpreters/split_clustering.rb +++ b/lib/interpreters/split_clustering.rb @@ -24,7 +24,6 @@ require './lib/clusterers/average_tree_linkage.rb' require './lib/helper.rb' require './lib/interpreters/periodic_visits.rb' -require './lib/output_helper.rb' module Interpreters class SplitClustering diff --git a/lib/output_helper.rb b/lib/output_helper.rb index d774e3032..34458eee9 100644 --- a/lib/output_helper.rb +++ b/lib/output_helper.rb @@ -18,6 +18,218 @@ require './lib/hull.rb' module OutputHelper + # For result output + class Result + def self.generate_header(solutions_set) + scheduling, scheduling_header = generate_scheduling_header(solutions_set) + unit_ids, quantities_header = generate_quantities_header(solutions_set) + max_timewindows_size, timewindows_header = generate_timewindows_header(solutions_set) + unassigned_header = + if solutions_set.any?{ |solution| solution[:unassigned].size.positive? } + [I18n.t('export_file.comment')] + else + [] + end + + header = scheduling_header + + basic_header + + quantities_header + + timewindows_header + + unassigned_header + + complementary_header(solutions_set.any?{ |solution| solution[:routes].any?{ |route| route[:day] } }) + + [header, unit_ids, max_timewindows_size, scheduling, !unassigned_header.empty?] + end + + def self.generate_scheduling_header(solutions_set) + if solutions_set.any?{ |solution| + solution[:routes].any?{ |route| route[:original_vehicle_id] != route[:vehicle_id] } + } + [true, [I18n.t('export_file.plan.name'), I18n.t('export_file.plan.ref')]] + else + [false, []] + end + end + + def self.basic_header + [I18n.t('export_file.route.id'), + I18n.t('export_file.stop.reference'), + I18n.t('export_file.stop.point_id'), + I18n.t('export_file.stop.lat'), + I18n.t('export_file.stop.lon'), + I18n.t('export_file.stop.type'), + I18n.t('export_file.stop.wait_time'), + I18n.t('export_file.stop.start_time'), + I18n.t('export_file.stop.end_time'), + I18n.t('export_file.stop.setup'), + I18n.t('export_file.stop.duration'), + I18n.t('export_file.stop.additional_value'), + I18n.t('export_file.stop.skills'), + I18n.t('export_file.tags'), + I18n.t('export_file.route.total_travel_time'), + I18n.t('export_file.route.total_travel_distance'), + I18n.t('export_file.route.total_wait_time')] + end + + def self.generate_quantities_header(solutions_set) + unit_ids, quantities_header = [], [] + + solutions_set.collect{ |solution| + solution[:routes].each{ |route| + route[:activities].each{ |activity| + next if activity[:detail].nil? + + (activity[:detail][:quantities] || []).each{ |quantity| + unit_ids << quantity[:unit] + quantities_header << I18n.t('export_file.stop.quantity', unit: (quantity[:label] || quantity[:unit])) + } + } + } + } + + [unit_ids.uniq, quantities_header.uniq] + end + + def self.generate_timewindows_header(solutions_set) + max_timewindows_size = solutions_set.collect{ |solution| + solution[:routes].collect{ |route| + route[:activities].collect{ |activity| + if activity[:detail] + (activity[:detail][:timewindows] || []).collect{ |tw| [tw[:start], tw[:end]] }.uniq.size + end + } + } + solution[:unassigned].collect{ |activity| + if activity[:detail] + (activity[:detail][:timewindows] || []).collect{ |tw| [tw[:start], tw[:end]] }.uniq.size + end + } + }.flatten.compact.max + + timewindows_header = (0..max_timewindows_size.to_i - 1).collect{ |index| + tw_index = index + (I18n.locale == :legacy ? 0 : 1) + [I18n.t('export_file.stop.tw_start', index: tw_index), I18n.t('export_file.stop.tw_end', index: tw_index)] + }.flatten + + [max_timewindows_size, timewindows_header] + end + + def self.complementary_header(any_day_index) + [ + I18n.t('export_file.stop.name'), + I18n.t('export_file.route.original_id'), + any_day_index ? I18n.t('export_file.route.day') : nil, + any_day_index ? I18n.t('export_file.stop.visit_index') : nil + ] + end + + def self.complementary_data(route, activity) + [ + activity[:original_service_id] || activity[:original_shipment_id] || activity[:original_rest_id], + route && route[:original_vehicle_id], + route && route[:day], + activity[:visit_index] + ] + end + + def self.activity_line(activity, route, name, unit_ids, max_timewindows_size, scheduling, reason) + days_info = scheduling ? [activity[:day_week_num], activity[:day_week]] : [] + common = build_csv_activity(name, route, activity) + timewindows = build_csv_timewindows(activity, max_timewindows_size) + quantities = unit_ids.collect{ |unit_id| + quantity = activity[:detail][:quantities]&.find{ |qty| qty[:unit] == unit_id } + quantity[:value] if quantity + } + (days_info + common + quantities + timewindows + reason + complementary_data(route, activity)) + end + + def self.find_type_and_complete_id(activity) + if activity[:service_id] + [I18n.t('export_file.stop.type_visit'), activity[:service_id]] + elsif activity[:pickup_shipment_id] + [I18n.t('export_file.stop.type_visit'), "#{activity[:pickup_shipment_id]}_pickup"] + elsif activity[:delivery_shipment_id] + [I18n.t('export_file.stop.type_visit'), "#{activity[:delivery_shipment_id]}_delivery"] + elsif activity[:shipment_id] + [I18n.t('export_file.stop.type_visit'), "#{activity[:shipment_id]}_#{activity[:type]}"] + elsif activity[:rest_id] + [I18n.t('export_file.stop.type_rest'), activity[:rest_id]] + else + [I18n.t('export_file.stop.type_store'), activity[:point_id]] + end + end + + def self.build_csv_activity(name, route, activity) + type, complete_id = find_type_and_complete_id(activity) + [ + route && route[:vehicle_id], + complete_id, + activity[:point_id], + activity[:detail][:lat], + activity[:detail][:lon], + type, + formatted_duration(activity[:waiting_time]), + formatted_duration(activity[:begin_time]), + formatted_duration(activity[:end_time]), + formatted_duration(activity[:detail][:setup_duration] || 0), + formatted_duration(activity[:detail][:duration] || 0), + activity[:detail][:additional_value] || 0, + activity[:detail][:skills].to_a.empty? ? nil : activity[:detail][:skills].to_a.flatten.join(','), + name, + route && formatted_duration(route[:total_travel_time]), + route && route[:total_distance], + route && formatted_duration(route[:total_waiting_time]), + ].flatten + end + + def self.build_csv_timewindows(activity, max_timewindows_size) + tws = activity[:detail][:timewindows]&.collect{ |tw| { start: tw[:start], end: tw[:end] } }.to_a.uniq + + (0..max_timewindows_size - 1).collect{ |index| + if index < tws.size + timewindow = tws.sort_by{ |tw| tw[:start] || 0 }[index] + + [timewindow[:start] && formatted_duration(timewindow[:start]), + timewindow[:end] && formatted_duration(timewindow[:end])] + else + [nil, nil] + end + }.flatten + end + + def self.formatted_duration(duration) + return unless duration + + h = (duration / 3600).to_i + m = (duration / 60).to_i % 60 + s = duration.to_i % 60 + [h, m, s].map { |t| t.to_s.rjust(2, '0') }.join(':') + end + + def self.build_csv(solutions) + return unless solutions + + I18n.locale = :legacy if solutions.any?{ |s| s[:use_deprecated_csv_headers] } + header, unit_ids, max_timewindows_size, scheduling, any_unassigned = generate_header(solutions) + + CSV.generate{ |output_csv| + output_csv << header + solutions.collect{ |solution| + solution[:routes].each{ |route| + route[:activities].each{ |activity| + reason = any_unassigned ? [nil] : [] + output_csv << activity_line( + activity, route, solution[:name], unit_ids, max_timewindows_size, scheduling, reason) + } + } + solution[:unassigned].each{ |activity| + output_csv << activity_line( + activity, nil, solution[:name], unit_ids, max_timewindows_size, scheduling, [activity[:reason]]) + } + } + } + end + end + # To output clusters generated class Clustering def self.generate_files(all_service_vrps, two_stages = false, job = nil) diff --git a/models/configuration.rb b/models/configuration.rb index 7f4511572..cfc17a04d 100644 --- a/models/configuration.rb +++ b/models/configuration.rb @@ -79,11 +79,13 @@ class Restitution < Base field :geometry, default: [] field :intermediate_solutions, default: true field :csv, default: false + field :use_deprecated_csv_headers, default: false field :allow_empty_result, default: false end class Schedule < Base field :range_indices, default: nil # extends schedule_range_date + field :start_date, default: nil field :unavailable_days, default: Set[] # extends unavailable_date field :months_indices, default: [] end diff --git a/models/vrp.rb b/models/vrp.rb index bd63fcbff..d054dad6d 100644 --- a/models/vrp.rb +++ b/models/vrp.rb @@ -79,9 +79,11 @@ class Vrp < Base field :restitution_geometry, default: [] field :restitution_intermediate_solutions, default: true field :restitution_csv, default: false + field :restitution_use_deprecated_csv_headers, default: false field :restitution_allow_empty_result, default: false field :schedule_range_indices, default: nil # extends schedule_range_date + field :schedule_start_date, default: nil field :schedule_unavailable_days, default: Set[] # extends unavailable_date and schedule_unavailable_indices field :schedule_months_indices, default: [] @@ -435,6 +437,7 @@ def self.generate_schedule_indices_from_date(hash) start: start_index, end: end_index } + hash[:configuration][:schedule][:start_date] = hash[:configuration][:schedule][:range_date][:start] hash[:configuration][:schedule].delete(:range_date) hash @@ -460,6 +463,7 @@ def restitution=(restitution) self.restitution_geometry = restitution[:geometry] self.restitution_intermediate_solutions = restitution[:intermediate_solutions] self.restitution_csv = restitution[:csv] + self.restitution_use_deprecated_csv_headers = restitution[:use_deprecated_csv_headers] self.restitution_allow_empty_result = restitution[:allow_empty_result] end @@ -504,6 +508,7 @@ def preprocessing=(preprocessing) def schedule=(schedule) self.schedule_range_indices = schedule[:range_indices] + self.schedule_start_date = schedule[:start_date] self.schedule_unavailable_days = schedule[:unavailable_days] self.schedule_months_indices = schedule[:months_indices] end diff --git a/optimizer_wrapper.rb b/optimizer_wrapper.rb index 6d806536e..58a4e5cc7 100644 --- a/optimizer_wrapper.rb +++ b/optimizer_wrapper.rb @@ -32,6 +32,7 @@ require './lib/heuristics/dichotomious_approach.rb' require './lib/filters.rb' require './lib/cleanse.rb' +require './lib/output_helper.rb' require 'ai4r' include Ai4r::Data @@ -189,6 +190,7 @@ def self.define_process(service_vrp, job = nil, &block) check_result_consistency(expected_activity_count, result) if service_vrp[:service] != :demo # demo solver returns a fixed solution log "<-- define_process levels (dicho: #{dicho_level}, split: #{split_level}) elapsed: #{(Time.now - tic).round(2)} sec", level: :info + result[:use_deprecated_csv_headers] = vrp.restitution_use_deprecated_csv_headers result end @@ -498,90 +500,6 @@ def self.job_remove(api_key, id) end end - def self.find_type(activity) - if activity[:service_id] || activity[:pickup_shipment_id] || activity[:delivery_shipment_id] || activity[:shipment_id] - 'visit' - elsif activity[:rest_id] - 'rest' - elsif activity[:point_id] - 'store' - end - end - - def self.build_csv(solutions) - header = ['vehicle_id', 'id', 'point_id', 'lat', 'lon', 'type', 'waiting_time', 'begin_time', 'end_time', 'setup_duration', 'duration', 'additional_value', 'skills', 'tags', 'total_travel_time', 'total_travel_distance', 'total_waiting_time'] - quantities_header = [] - unit_ids = [] - optim_planning_output = nil - max_timewindows_size = 0 - reasons = nil - solutions&.collect{ |solution| - solution[:routes].each{ |route| - route[:activities].each{ |activity| - next if activity[:detail].nil? || !activity[:detail][:quantities] - - activity[:detail][:quantities].each{ |quantity| - unit_ids << quantity[:unit] - quantities_header << "quantity_#{quantity['label'] || quantity[:unit]}" - } - } - } - quantities_header.uniq! - unit_ids.uniq! - - max_timewindows_size = ([max_timewindows_size] + solution[:routes].collect{ |route| - route[:activities].collect{ |activity| - next if activity[:detail].nil? || !activity[:detail][:timewindows] - - activity[:detail][:timewindows].collect{ |tw| [tw[:start], tw[:end]] }.uniq.size - }.compact - }.flatten + - solution[:unassigned].collect{ |activity| - next if activity[:detail].nil? || !activity[:detail][:timewindows] - - activity[:detail][:timewindows].collect{ |tw| [tw[:start], tw[:end]] }.uniq.size - }.compact).max - timewindows_header = (0..max_timewindows_size.to_i - 1).collect{ |index| - ["timewindow_start_#{index}", "timewindow_end_#{index}"] - }.flatten - header += quantities_header + timewindows_header - reasons = true if solution[:unassigned].size.positive? - - optim_planning_output = solution[:routes].any?{ |route| route[:activities].any?{ |stop| stop[:day_week] } } - } - CSV.generate{ |out_csv| - if optim_planning_output - header = ['day_week_num', 'day_week'] + header - end - if reasons - header << 'unassigned_reason' - end - out_csv << header - (solutions.is_a?(Array) ? solutions : [solutions]).collect{ |solution| - solution[:routes].each{ |route| - route[:activities].each{ |activity| - days_info = optim_planning_output ? [activity[:day_week_num], activity[:day_week]] : [] - common = build_csv_activity(solution[:name], route, activity) - timewindows = build_csv_timewindows(activity, max_timewindows_size) - quantities = unit_ids.collect{ |unit_id| - activity[:detail][:quantities]&.find{ |quantity| quantity[:unit] == unit_id } && activity[:detail][:quantities]&.find{ |quantity| quantity[:unit] == unit_id }[:value] - } - out_csv << (days_info + common + quantities + timewindows + [nil]) - } - } - solution[:unassigned].each{ |activity| - days_info = optim_planning_output ? [activity[:day_week_num], activity[:day_week]] : [] - common = build_csv_activity(solution[:name], nil, activity) - timewindows = build_csv_timewindows(activity, max_timewindows_size) - quantities = unit_ids.collect{ |unit_id| - activity[:detail][:quantities]&.find{ |quantity| quantity[:unit] == unit_id } && activity[:detail][:quantities]&.find{ |quantity| quantity[:unit] == unit_id }[:value] - } - out_csv << (days_info + common + quantities + timewindows + [activity[:reason]]) - } - } - } - end - private def self.check_result_consistency(expected_value, results) @@ -605,15 +523,6 @@ def self.adjust_vehicles_duration(vrp) } end - def self.formatted_duration(duration) - if duration - h = (duration / 3600).to_i - m = (duration / 60).to_i % 60 - s = duration.to_i % 60 - [h, m, s].map { |t| t.to_s.rjust(2, '0') }.join(':') - end - end - def self.round_route_stats(route) [:end_time, :start_time].each{ |key| next unless route[key] @@ -706,50 +615,30 @@ def self.compute_route_waiting_times(route) end end - def self.build_csv_activity(name, route, activity) - type = find_type(activity) - [ - route && route[:vehicle_id], - build_complete_id(activity), - activity[:point_id], - activity[:detail][:lat], - activity[:detail][:lon], - type, - formatted_duration(activity[:waiting_time]), - formatted_duration(activity[:begin_time]), - formatted_duration(activity[:end_time]), - formatted_duration(activity[:detail][:setup_duration] || 0), - formatted_duration(activity[:detail][:duration] || 0), - activity[:detail][:additional_value] || 0, - activity[:detail][:skills].to_a.empty? ? nil : activity[:detail][:skills].to_a.flatten.join(','), - name, - route && formatted_duration(route[:total_travel_time]), - route && route[:total_distance], - route && formatted_duration(route[:total_waiting_time]), - ].flatten - end + def self.provide_day(vrp, route) + return unless vrp.scheduling? - def self.build_complete_id(activity) - return activity[:service_id] if activity[:service_id] + route_index = route[:vehicle_id].split('_').last.to_i - return "#{activity[:pickup_shipment_id]}_pickup" if activity[:pickup_shipment_id] + if vrp.schedule_start_date + days_from_start = route_index - vrp.schedule_range_indices[:start] + route[:day] = vrp.schedule_start_date.to_date + days_from_start + else + route_index + end + end - return "#{activity[:delivery_shipment_id]}_delivery" if activity[:delivery_shipment_id] + def self.provide_visits_index(vrp, set) + return unless vrp.scheduling? - return "#{activity[:shipment_id]}_#{activity['type']}" if activity[:shipment_id] + set.each{ |activity| + id = activity[:service_id] || activity[:rest_id] || + activity[:pickup_shipment_id] || activity[:delivery_shipment_id] - activity[:rest_id] || activity[:point_id] - end + next unless id - def self.build_csv_timewindows(activity, max_timewindows_size) - (0..max_timewindows_size - 1).collect{ |index| - if activity[:detail][:timewindows] && index < activity[:detail][:timewindows].collect{ |tw| [tw[:start], tw[:end]] }.uniq.size - timewindow = activity[:detail][:timewindows].select{ |tw| [tw[:start], tw[:end]] }.uniq.sort_by{ |t| t[:start] }[index] - [timewindow[:start] && formatted_duration(timewindow[:start]), timewindow[:end] && formatted_duration(timewindow[:end])] - else - [nil, nil] - end - }.flatten + activity[:visit_index] = id.split('_').last.to_i + } end def self.route_details(vrp, route, vehicle) @@ -798,7 +687,8 @@ def self.compute_route_travel_distances(vrp, matrix, route, vehicle) def self.fill_missing_route_data(vrp, route, matrix, vehicle, solvers) route[:original_vehicle_id] = vrp.vehicles.find{ |v| v.id == route[:vehicle_id] }.original_id - route[:day] = route[:vehicle_id].split('_').last.to_i unless route[:original_vehicle_id] == route[:vehicle_id] + route[:day] = provide_day(vrp, route) + provide_visits_index(vrp, route[:activities]) details = compute_route_travel_distances(vrp, matrix, route, vehicle) compute_route_waiting_times(route) unless route[:activities].empty? || solvers.include?('vroom') @@ -822,6 +712,7 @@ def self.parse_result(vrp, result) matrix = vrp.matrices.find{ |mat| mat.id == vehicle.matrix_id } fill_missing_route_data(vrp, route, matrix, vehicle, result[:solvers]) } + provide_visits_index(vrp, result[:unassigned]) compute_result_total_dimensions_and_round_route_stats(result) log "result - unassigned rate: #{result[:unassigned].size} of (ser: #{vrp.visits}, ship: #{vrp.shipments.size}) (#{(result[:unassigned].size.to_f / (vrp.visits + 2 * vrp.shipments.size) * 100).round(1)}%)" diff --git a/test/api/v01/output_test.rb b/test/api/v01/output_test.rb index 7260005bc..20f234425 100644 --- a/test/api/v01/output_test.rb +++ b/test/api/v01/output_test.rb @@ -54,14 +54,50 @@ def around # tmpdir and generated files are already deleted end - def test_day_week_num + def test_day_week_num_and_other_scheduling_fields vrp = VRP.scheduling + vrp[:services].first[:visits_number] = 2 vrp[:configuration][:restitution] = { csv: true } csv_data = submit_csv api_key: 'demo', vrp: vrp assert_equal csv_data.collect(&:size).max, csv_data.collect(&:size).first assert_includes csv_data.first, 'day_week' assert_includes csv_data.first, 'day_week_num' + + day_index = csv_data.first.find_index('day') + assert_equal ['day', '0', '1', '2', '3'], csv_data.collect{ |l| l[day_index] }.compact.uniq + + visit_index = csv_data.first.find_index('visit_index') + assert_equal ['1', '2', 'visit_index'], csv_data.collect{ |l| l[visit_index] }.compact.uniq.sort + end + + def test_check_returned_day_and_visit_index + vrp = VRP.basic + response = post '/0.1/vrp/submit', { api_key: 'solvers', vrp: vrp }.to_json, 'CONTENT_TYPE' => 'application/json' + result = JSON.parse(response.body) + assert(result['solutions'].first['routes'].none?{ |route| route['day'] }) + assert(result['solutions'].first['routes'].none?{ |route| route['activities'].any?{ |a| a['visit_index'] } }) + + vrp = VRP.scheduling + response = post '/0.1/vrp/submit', { api_key: 'solvers', vrp: vrp }.to_json, 'CONTENT_TYPE' => 'application/json' + result = JSON.parse(response.body) + assert_equal [0, 1, 2, 3], result['solutions'].first['routes'].collect{ |route| route['day'] }.sort + visit_indices = result['solutions'].first['routes'].flat_map{ |r| + r['activities'].flat_map{ |a| a['visit_index'] } + }.compact + assert_equal [1], visit_indices.uniq + + vrp = VRP.scheduling + vrp[:configuration][:schedule] = { + range_date: { + start: Date.new(2021, 2, 10), + end: Date.new(2021, 2, 15) + } + } + response = post '/0.1/vrp/submit', { api_key: 'solvers', vrp: vrp }.to_json, 'CONTENT_TYPE' => 'application/json' + result = JSON.parse(response.body) + assert_equal %w[2021-02-10 2021-02-11 2021-02-12 2021-02-13 2021-02-14 2021-02-15], + result['solutions'].first['routes'].collect{ |route| route['day'] }.sort end def test_no_day_week_num @@ -226,32 +262,65 @@ def test_provided_language vrp = VRP.basic vrp[:configuration][:restitution] = { csv: true } - OptimizerWrapper.stub( - :build_csv, - lambda { |_solutions| - assert_equal :en, I18n.locale - } - ) do - post '/0.1/vrp/submit', { api_key: 'demo', vrp: vrp }.to_json, 'CONTENT_TYPE' => 'application/json' - end + [[nil, :legacy], + ['fr', :fr], + ['en', :en], + ['de', :en]].each{ |provided, expected| + OutputHelper::Result.stub( + :build_csv, + lambda { |_solutions| + assert_equal expected, I18n.locale + } + ) do + submit_csv api_key: 'demo', vrp: vrp, http_accept_language: provided + end + } - OptimizerWrapper.stub( - :build_csv, - lambda { |_solutions| - assert_equal :fr, I18n.locale - } - ) do - post '/0.1/vrp/submit', { api_key: 'demo', vrp: vrp }.to_json, 'CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT_LANGUAGE' => 'fr' - end + [true, false].each{ |parameter_value| + vrp[:configuration][:restitution][:use_deprecated_csv_headers] = parameter_value + OutputHelper::Result.stub( + :build_csv, + lambda { |solutions| + assert_equal parameter_value, solutions.first[:use_deprecated_csv_headers] + } + ) do + submit_csv api_key: 'demo', vrp: vrp, http_accept_language: 'fr' + end + } + end - OptimizerWrapper.stub( - :build_csv, - lambda { |_solutions| - assert_equal :en, I18n.locale + def test_returned_types + complete_vrp = VRP.pud + complete_vrp[:rests] = [{ + id: 'rest_0', + duration: 1, + timewindows: [{ + day_index: 0 + }] + }] + complete_vrp[:vehicles].first[:rest_ids] = ['rest_0'] + complete_vrp[:services] = [{ + id: 'service_0', + activity: { + point_id: 'point_0', + timewindows: [{ start: 0, end: 1000 }] } - ) do - post '/0.1/vrp/submit', { api_key: 'demo', vrp: vrp }.to_json, 'CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT_LANGUAGE' => 'bad_value' - end + }] + complete_vrp[:configuration][:restitution] = { csv: true } + + type_index = nil + + [['en', ['stop type', 'rest', 'store', 'visit']], + ['fr', ['type arrêt', 'pause', 'dépôt', 'visite']], + ['es', ['tipo parada', 'descanso', 'depósito', 'visita']]].each{ |provided, translations| + result = submit_csv api_key: 'ortools', vrp: complete_vrp, http_accept_language: provided + type_index = result.first.find_index(translations[0]) + types = result.collect{ |line| line[type_index] }.compact - [translations[0]] + assert_equal 3, types.uniq.size + assert_equal 1, types.count(translations[1]) + assert_equal 2, types.count(translations[2]) + assert_equal 5, types.count(translations[3]) + } end def test_csv_configuration @@ -315,17 +384,17 @@ def test_returned_keys_csv periodic_ortools: { problem: VRP.lat_lon, solver_name: 'ortools', - scheduling_keys: %w[day_week_num day_week] + scheduling_keys: %w[day_week_num day_week day visit_index] }, periodic_heuristic: { problem: VRP.lat_lon_scheduling, solver_name: 'heuristic', - scheduling_keys: %w[day_week_num day_week] + scheduling_keys: %w[day_week_num day_week day visit_index] } } - expected_route_keys = %w[vehicle_id total_travel_time total_travel_distance total_waiting_time] - expected_activities_keys = %w[point_id waiting_time begin_time end_time id lat lon duration setup_duration additional_value skills tags] + expected_route_keys = %w[vehicle_id original_vehicle_id total_travel_time total_travel_distance total_waiting_time] + expected_activities_keys = %w[point_id waiting_time begin_time end_time id original_id lat lon duration setup_duration additional_value skills tags] expected_unassigned_keys = %w[point_id id type unassigned_reason] [:ortools, :periodic_ortools].each{ |method| methods[method][:problem][:vehicles].first[:timewindow] = { start: 28800, end: 61200 } } @@ -406,4 +475,60 @@ def test_geojsons_returned assert(result['geojsons'].first['partitions']['work_day']['features'].all?{ |f| f['properties']['color'] }) end end + + def test_csv_headers_compatible_with_import_according_to_language + vrp = VRP.lat_lon_capacitated + vrp[:services].first[:activity][:timewindows] = [{ start: 0, end: 10 }] + vrp[:vehicles].each{ |v| v[:timewindow] = { start: 0, end: 10000 } } + vrp[:configuration][:preprocessing] = { first_solution_strategy: ['periodic'] } + vrp[:configuration][:schedule] = { range_indices: { start: 0, end: 2 }} + vrp[:configuration][:restitution] = { csv: true } + + # checks columns headers when required for import + expected_headers = { + en: ['plan', 'reference plan', 'route', 'name', 'lat', 'lng', 'stop type', 'time', 'end time', + 'duration per destination', 'visit duration', 'tags visit', 'tags', 'quantity[kg]', + 'time window start 1', 'time window end 1', 'vehicle', 'reference'], + es: [ + 'plan', 'referencia del plan', 'gira', 'nombre', 'tipo parada', 'lat', 'lng', 'hora', 'fin', + 'horario inicio 1', 'horario fin 1', 'duración de preparación', 'duración visita', 'cantidad[kg]', + 'etiquetas visita', 'etiquetas', 'vehículo', 'referencia visita'], + fr: ['plan', 'référence plan', 'tournée', 'nom', 'lat', 'lng', 'type arrêt', 'heure', + 'fin de la mission', 'durée client', 'durée visite', 'libellés visite', 'libellés', + 'quantité[kg]', 'horaire début 1', 'horaire fin 1', 'véhicule', 'référence visite'] + } + + asynchronously start_worker: true do + expected_headers.each{ |languague, expected_list| + @job_id = submit_csv api_key: 'ortools', vrp: vrp + wait_status_csv @job_id, 200, api_key: 'ortools', http_accept_language: languague + current_headers = last_response.body.split("\n").first.split(',') + assert_empty expected_list - current_headers + + delete_completed_job @job_id, api_key: 'ortools' + } + end + end + + def test_use_deprecated_csv_headers_asynchronously + vrp = VRP.lat_lon + vrp[:configuration][:restitution] = { csv: true } + + legacy_basic_headers = %w[vehicle_id id point_id type begin_time end_time setup_duration duration skills] + french_basic_headers = ['tournée', 'référence', 'heure', 'fin de la mission', 'durée client', 'durée visite', 'libellés'] + [[true, legacy_basic_headers], + [false, french_basic_headers]].each{ |parameter, expected| + + vrp[:configuration][:restitution][:use_deprecated_csv_headers] = parameter + + asynchronously start_worker: true do + @job_id = submit_csv api_key: 'demo', vrp: vrp, http_accept_language: 'fr' + wait_status_csv @job_id, 200, api_key: 'demo', http_accept_language: 'fr' + current_headers = last_response.body.split("\n").first.split(',') + assert_empty expected - current_headers + + delete_completed_job @job_id, api_key: 'ortools' + end + } + end end diff --git a/test/api/v01/request_helper.rb b/test/api/v01/request_helper.rb index 65850f2cc..74fa665b7 100644 --- a/test/api/v01/request_helper.rb +++ b/test/api/v01/request_helper.rb @@ -39,9 +39,11 @@ def wait_status(job_id, status, options) end def wait_status_csv(job_id, status, options) + language = options[:http_accept_language] || :legacy + options.delete(:http_accept_language) puts "#{job_id} #{Time.now} waiting #{status} status_csv" loop do - get "0.1/vrp/jobs/#{job_id}", options + get "0.1/vrp/jobs/#{job_id}", options, 'HTTP_ACCEPT_LANGUAGE' => language assert_equal 200, last_response.status, last_response.body @@ -78,9 +80,11 @@ def submit_vrp(params) end def submit_csv(params) + language = params[:http_accept_language] || :legacy + params.delete(:http_accept_language) hex = Digest::MD5.hexdigest params.to_s puts "#{hex} #{Time.now} submiting_csv #{hex}" - post '/0.1/vrp/submit', params.to_json, 'CONTENT_TYPE' => 'application/json' + post '/0.1/vrp/submit', params.to_json, 'CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT_LANGUAGE' => language assert_includes [200, 201], last_response.status assert last_response.body if last_response.status == 201