Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## Dev
### Added
- Import: Order destination/visits in planning routes using index field [#626](https://github.com/cartoway/planner-web/pull/626)
- API: Expose route ref [#626](https://github.com/cartoway/planner-web/pull/626)

### Changed
- Improve planning summary CSV export performance [#626](https://github.com/cartoway/planner-web/pull/626)
- JSON Import: visits nested within a single destination belongs to the same destination [#626](https://github.com/cartoway/planner-web/pull/626)
- Routes without depot, with a single visit are now counted as active routes [#626](https://github.com/cartoway/planner-web/pull/626)

### Fixed
- Rest stop validation use the default rest_start value for comparison [#626](https://github.com/cartoway/planner-web/pull/626)

## V108.2.1
### Fixed
- StaleUpdate on destination import with existing planning [#625](https://github.com/cartoway/planner-web/pull/625)
Expand Down
1 change: 1 addition & 0 deletions app/api/v01/entities/route.rb
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ def self.entity_name
expose(:end, documentation: { type: DateTime }) { |m|
(m.planning.date || Time.zone.today).beginning_of_day + m.end if m.end
}
expose(:ref, documentation: { type: String })
expose(:hidden, documentation: { type: 'Boolean' })
expose(:locked, documentation: { type: 'Boolean' })
expose(:color, documentation: { type: String, desc: 'Color code with #. For instance: #FF0000.' })
Expand Down
1 change: 1 addition & 0 deletions app/api/v01/helper/shared_params.rb
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ def filter_tag_ids_belong_to_customer(tag_ids, customer)

params :request_route do |options|
optional :force_start, type: Boolean
optional :ref, type: String
optional :hidden, type: Boolean
optional :locked, type: Boolean
optional :color, type: String, documentation: { desc: "Color code with #. Default: #{Planner::Application.config.destination_color_default}." }
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/plannings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -756,7 +756,7 @@ def export_columns
:planning_date,
:route,
:ref_vehicle,
:order,
:index,
:stop_type,
:active,
:wait_time,
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/routes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ def export_columns
[
:route,
:ref_vehicle,
:order,
:index,
:stop_type,
:active,
:wait_time,
Expand Down
76 changes: 64 additions & 12 deletions app/jobs/importer_destinations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def columns_route
{
route: {title: I18n.t('destinations.import_file.route'), desc: I18n.t('destinations.import_file.route_desc'), format: I18n.t('destinations.import_file.format.string')},
ref_vehicle: {title: I18n.t('destinations.import_file.ref_vehicle'), desc: I18n.t('destinations.import_file.ref_vehicle_desc'), format: I18n.t('destinations.import_file.format.string')},
index: {title: I18n.t('destinations.import_file.index'), desc: I18n.t('destinations.import_file.index_desc'), format: I18n.t('destinations.import_file.format.integer')},
active: {title: I18n.t('destinations.import_file.active'), desc: I18n.t('destinations.import_file.active_desc'), format: I18n.t('destinations.import_file.format.yes_no')},
stop_type: {title: I18n.t('destinations.import_file.stop_type'), desc: I18n.t('destinations.import_file.stop_type_desc'), format: I18n.t('destinations.import_file.stop_format')}
}
Expand Down Expand Up @@ -121,6 +122,7 @@ def columns
def import_columns
columns_planning.merge(columns_route).merge(columns_destination).merge(columns_visit).merge(
without_visit: {title: I18n.t('destinations.import_file.without_visit'), desc: I18n.t('destinations.import_file.without_visit_desc'), format: I18n.t('destinations.import_file.format.yes_no')},
_json_group_key: {},
stop_custom_attributes: {},
stop_custom_attribute_visits: {},

Expand All @@ -138,11 +140,14 @@ def import_columns

# convert json with multi visits in several rows like in csv
def json_to_rows(json)
json.collect{ |dest|
json.each_with_index.collect{ |dest, destination_row_index|
dest[:tags] ||= []
dest[:tags] |= dest[:tag_ids].collect(&:to_i) if dest.key?(:tag_ids)
if dest.key?(:visits) && !dest[:visits].empty?
dest[:visits].collect{ |v|
# Keep a stable key to group rows coming from the same JSON destination
# when the destination has no ref and contains multiple visits.
v[:_json_group_key] = destination_row_index
v[:ref_visit] = v.delete(:ref)
v[:stop_custom_attribute_visits] = v[:stop_custom_attributes] || {}
v[:tag_visits] = v[:tag_ids]&.map(&:to_i) || []
Expand Down Expand Up @@ -171,11 +176,11 @@ def json_to_rows(json)
dest.except(:visits).merge(v)
}
elsif dest.key?(:visits) && dest[:visits].empty? && is_visit?(dest[:stop_type])
[dest.merge(without_visit: 'x')]
[dest.merge(without_visit: 'x', _json_group_key: destination_row_index)]
elsif is_visit?(dest[:stop_type])
[dest.merge(without_visit: 'y')] # Import without visit but without a destroy neither
[dest.merge(without_visit: 'y', _json_group_key: destination_row_index)] # Import without visit but without a destroy neither
else
[dest]
[dest.merge(_json_group_key: destination_row_index)]
end
}.flatten
end
Expand Down Expand Up @@ -257,6 +262,7 @@ def before_import(_name, data, options)

@destinations_visits_attributes_by_ref[nil] = CaseInsensitiveHash.new
@destinations_attributes_by_ref = CaseInsensitiveHash.new
@destinations_attributes_by_json_group = {}
@visits_attributes_with_destination = {}
@visits_attributes_without_ref = []
@visits_attributes_without_destination_with_ref_visit = CaseInsensitiveHash.new
Expand Down Expand Up @@ -974,9 +980,25 @@ def prepare_destination(row, line, destination_attributes, visit_attributes)
end
prepare_visit_with_destination_ref(row, line, destination, index, destination_attributes, visit_attributes) if index
else
@destinations_attributes_without_ref << [@destination_index, [line], destination_attributes]
prepare_visit_without_destination_ref(row, line, @destination_index, destination_attributes, visit_attributes)
@destination_index += 1
# In case of a JSON input, we know the associated destination by the _json_group_key even wihout a ref
json_group_key = row[:_json_group_key]
if json_group_key.present?
index, lines, grouped_attributes = @destinations_attributes_by_json_group[json_group_key]
if grouped_attributes
destination_attributes = grouped_attributes
else
index = @destination_index
reset_geocoding(destination_attributes)
@destinations_attributes_by_json_group[json_group_key] = [index, [line], destination_attributes]
@destinations_attributes_without_ref << [index, [line], destination_attributes]
@destination_index += 1
end
prepare_visit_without_destination_ref(row, line, index, destination_attributes, visit_attributes)
else
@destinations_attributes_without_ref << [@destination_index, [line], destination_attributes]
prepare_visit_without_destination_ref(row, line, @destination_index, destination_attributes, visit_attributes)
@destination_index += 1
end
end
end

Expand Down Expand Up @@ -1043,7 +1065,15 @@ def prepare_store_reload_in_planning(row, _line, _store_attributes, store_reload
@plannings_routes[row[:planning_ref]][row[:route]][:ref_vehicle] = row[:ref_vehicle]
@plannings_vehicles[row[:planning_ref]][row[:ref_vehicle]] = row[:route]
end
@plannings_routes[row[:planning_ref]][row[:route]][:visits] << [:store_reload, store_reload_attributes, { active: ValueToBoolean.value_to_boolean(row[:active], true), custom_attributes: row[:stop_custom_attributes] }]
@plannings_routes[row[:planning_ref]][row[:route]][:visits] << [
:store_reload,
store_reload_attributes,
{
active: ValueToBoolean.value_to_boolean(row[:active], true),
custom_attributes: row[:stop_custom_attributes],
index: normalize_route_order(row[:index])
}
]
@store_reload_ids << store_reload_attributes[:id] if store_reload_attributes[:id]
end
end
Expand Down Expand Up @@ -1071,7 +1101,15 @@ def prepare_destination_in_planning(row, line, destination_attributes, visit_att
@plannings_routes[row[:planning_ref]][row[:route]][:ref_vehicle] = row[:ref_vehicle]
@plannings_vehicles[row[:planning_ref]][row[:ref_vehicle]] = row[:route]
end
@plannings_routes[row[:planning_ref]][row[:route]][:visits] << [:visit, visit_attributes, { active: ValueToBoolean.value_to_boolean(row[:active], true), custom_attributes: row[:stop_custom_attribute_visits] }]
@plannings_routes[row[:planning_ref]][row[:route]][:visits] << [
:visit,
visit_attributes,
{
active: ValueToBoolean.value_to_boolean(row[:active], true),
custom_attributes: row[:stop_custom_attribute_visits],
index: normalize_route_order(row[:index])
}
]
@visit_ids << visit_attributes[:id] if visit_attributes[:id]
end
end
Expand Down Expand Up @@ -1103,19 +1141,25 @@ def prepare_plannings(name, _options)
end
routes_hash.each{ |k, v|
# Duplicated visit lines are only represented by a single visit
v[:visits].select!{ |_type, attribute, _active|
v[:visits].select!{ |_type, attribute, _stop_attributes|
attribute[:id] ||
@visit_index_to_id_hash[attribute[:visit_index]] ||
@store_reload_index_to_id_hash[attribute[:store_reload_index]]
}
visit_ids = v[:visits].map{ |type, attribute, _active|
if v[:visits].any? { |_type, _attribute, stop_attributes| !stop_attributes[:index].nil? }
v[:visits] = v[:visits].sort_by.with_index { |(_type, _attribute, stop_attributes), position|
[stop_attributes[:index] || Float::INFINITY, position]
}
end

visit_ids = v[:visits].map{ |type, attribute, _stop_attributes|
next unless type == :visit

attribute[:id] || @visit_index_to_id_hash[attribute[:visit_index]]
}
visits = Visit.includes_destinations_and_stores.where(id: visit_ids).index_by(&:id).values_at(*visit_ids)

store_reload_ids = v[:visits].map{ |type, attribute, _active|
store_reload_ids = v[:visits].map{ |type, attribute, _stop_attributes|
next unless type == :store_reload

attribute[:id] || @store_reload_index_to_id_hash[attribute[:store_reload_index]]
Expand Down Expand Up @@ -1209,4 +1253,12 @@ def custom_date_parse(date_string)

Date.strptime(date_string, I18n.t('destinations.import_file.format.date_short'))
end

def normalize_route_order(value)
return nil if value.nil? || value.to_s.strip.empty?

Integer(value)
rescue ArgumentError, TypeError
nil
end
end
33 changes: 33 additions & 0 deletions app/middleware/per_route_timeout.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
require 'timeout'

class PerRouteTimeout
DEFAULT_TIMEOUTS = {
%r{^/api/} => 15, # default API
%r{^/} => 60, # API 100
}.freeze

def initialize(app)
@app = app
end

def call(env)
path = env['PATH_INFO']
timeout = resolved_timeouts.find { |pattern, _| pattern.match?(path) }&.last || default_timeout

Timeout.timeout(timeout) { @app.call(env) }
rescue Timeout::Error
[504, { 'Content-Type' => 'text/plain' }, ['Gateway Timeout']]
end

private

def resolved_timeouts
configured_timeouts = Rails.configuration.x.per_route_timeouts || {}
# Priority to the configured timeouts
configured_timeouts.merge(DEFAULT_TIMEOUTS) { |_key, configured, _default| configured }
end

def default_timeout
Rails.configuration.x.per_route_default_timeout || 30
end
end
4 changes: 4 additions & 0 deletions app/models/application_record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ def import_attributes
self.attributes.slice(*self.class.column_names).except('lock_version', 'created_at', 'updated_at')
end

def symbolized_attributes
self.attributes.slice(*self.class.column_names).symbolize_keys
end

# Import records in batches and return all IDs
def self.import_in_batches(attributes_array, batch_size: 1000, **options)
return [] if attributes_array.empty?
Expand Down
11 changes: 5 additions & 6 deletions app/models/planning.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1096,10 +1096,8 @@ def averages(metric)
}

routes.each do |route|
if route.vehicle_usage && !route.drive_time.nil?
result[:routes_drive_time] += route.drive_time
result[:vehicles_used] += 1 if route.drive_time > 0

if route.vehicle_usage && (route.size_active.positive? || (route.vehicle_usage.default_store_start.present? && route.vehicle_usage.default_store_stop.present? && route.drive_time.present?))
result[:routes_drive_time] += route.drive_time if route.drive_time.present?
composed_cost = [route.cost_distance, route.cost_fixed, route.cost_time].compact.reduce(&:+)
result[:routes_cost] =
if result[:routes_cost].nil? || composed_cost.nil?
Expand All @@ -1116,16 +1114,17 @@ def averages(metric)
result[:routes_visits_duration] += route.visits_duration if route.visits_duration
result[:routes_wait_time] += route.wait_time if route.wait_time

routes_distance += route.distance
routes_distance += route.distance if route.distance.present?
end
result[:vehicles] += 1 if route.vehicle_usage
result[:vehicles_used] += 1 if route.vehicle_usage && route.size_active.positive?
end

if result[:routes_drive_time] != 0
result[:routes_speed_average] = ((routes_distance / result[:routes_drive_time]) * converter).round
result[:routes_wait_time] = result[:routes_wait_time] > 0 ? result[:routes_wait_time] : nil
result[:routes_visits_duration] = result[:routes_visits_duration] > 0 ? result[:routes_visits_duration] : nil
else
elsif result[:vehicles_used] == 0
result = nil
end

Expand Down
Loading
Loading