Skip to content
Merged

Fixes #608

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
2 changes: 1 addition & 1 deletion app/api/v01/custom_attributes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def custom_attribute_params
params do
requires :id, type: Integer

requires :name, type: String
requires :name, type: String, regexp: /\A[^:]*\z/
requires :object_type, type: String
requires :object_class, type: String
optional :default_value, types: [Array[String], String, Integer, Float, Boolean]
Expand Down
4 changes: 4 additions & 0 deletions app/api/v01/destinations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,8 @@ def with_quantities(visit)
end
end

error! V01::Status.code_response(:code_304), 304 if parsed_ids.empty? && parsed_refs.empty?

destinations_query = current_customer.destinations
if parsed_ids.any? && parsed_refs.any?
destinations_query = destinations_query.where('destinations.id IN (?) OR destinations.ref IN (?)', parsed_ids, parsed_refs)
Expand All @@ -322,6 +324,8 @@ def with_quantities(visit)

matching_count = destinations_query.count

error! V01::Status.code_response(:code_304), 304 if matching_count.zero?

if current_customer.destinations_count == matching_count
current_customer.delete_all_destinations
else
Expand Down
7 changes: 5 additions & 2 deletions app/assets/javascripts/plannings.js
Original file line number Diff line number Diff line change
Expand Up @@ -1849,9 +1849,12 @@ export const plannings_edit = function(params) {
var routeId = $(this).closest("[data-route-id]").attr("data-route-id");
routesLayer.focus({routeId: routeId, stopIndex: stopIndex});
} else {
var storeId = $(this).closest("[data-store-id]").attr("data-store-id");
var li = $(this).closest("[data-store-id]");
var storeId = li.attr("data-store-id");
if (storeId) {
routesLayer.focus({storeId: storeId});
var routeId = li.attr("data-origin-route-id");
var depotType = li.attr("data-type");
routesLayer.focus({ storeId: storeId, routeId: routeId, depotType: depotType });
}
}
$(this).blur();
Expand Down
21 changes: 16 additions & 5 deletions app/assets/javascripts/routes_layers.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const popupModule = (function() {
return false;
};

const _buildContentForPopup = function(marker, map) {
const _buildContentForPopup = function(marker, map, focusOptions) {

var route = marker.properties.route_id && _context.options.routes.filter(function(route) {
return route.route_id == marker.properties.route_id;
Expand All @@ -70,8 +70,16 @@ const popupModule = (function() {
if (_ajaxCanBeProceeded()) {
var url = _context.options.appBaseUrl;

if (marker.properties.store_id)
if (marker.properties.store_id) {
url += 'stores/' + marker.properties.store_id + '.json';
var params = [];
if (_context.planningId) params.push('planning_id=' + _context.planningId);
if (focusOptions && focusOptions.routeId && focusOptions.depotType) {
params.push('route_id=' + encodeURIComponent(focusOptions.routeId));
params.push('depot_type=' + encodeURIComponent(focusOptions.depotType));
}
if (params.length) url += (url.indexOf('?') >= 0 ? '&' : '?') + params.join('&');
}
else if (marker.properties.visit_id)
url += 'visits/' + marker.properties.visit_id + '.json';
else if (marker.properties.route_id)
Expand Down Expand Up @@ -136,14 +144,14 @@ const popupModule = (function() {
});
};

const createPopupForLayer = function(layer, map) {
const createPopupForLayer = function(layer, map, focusOptions) {
if (_previousMarker)
_previousMarker.closePopup();

if (_previousPopup instanceof L.Popup)
_previousPopup.closePopup();

_buildContentForPopup(layer, map);
_buildContentForPopup(layer, map, focusOptions);
};

const initializeModule = function(options, that) {
Expand Down Expand Up @@ -639,7 +647,10 @@ export const RoutesLayer = L.FeatureGroup.extend({
this.map.setView(this.markerStores[options.storeId].getLatLng(), this.map.getZoom(), {
reset: true
});
popupModule.createPopupForLayer(this.markerStores[options.storeId], this.map);
popupModule.createPopupForLayer(this.markerStores[options.storeId], this.map, {
routeId: options.routeId,
depotType: options.depotType
});
} else if (options.routeId) {
this._setViewForRoute(options.routeId);
}
Expand Down
5 changes: 4 additions & 1 deletion app/assets/stylesheets/scaffolds.css.scss
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,7 @@ input.form-control.number-of-days {
}

.stop-status-planned, .stop-status-intransit {
background: #357EC7;
background: $primary-color;
}
.stop-status-started, .stop-status-exception {
background: #FBB117;
Expand All @@ -712,6 +712,9 @@ input.form-control.number-of-days {
.stop-status-rejected, .stop-status-undelivered {
background: red;
}
.stop-status-atstore {
background: var(--info);
}
.stop-status-none {
background: none;
}
Expand Down
14 changes: 14 additions & 0 deletions app/controllers/api_web/v01/stores_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ def index
def show
respond_to do |format|
@show_isoline = false
if request.format.json? && params[:planning_id].present? && params[:route_id].present? && params[:depot_type].in?(%w[start stop])
# Sidebar marker click: show only the depot data for the current route
@planning = current_user.customer.plannings.find_by(id: params[:planning_id])
if @planning
routes = @planning.routes.includes(vehicle_usage: [:store_start, :store_stop, {vehicle_usage_set: [:store_start, :store_stop]}])
.select(&:vehicle_usage_id)
route = routes.find { |r| r.id.to_s == params[:route_id] }
if params[:depot_type] == 'start' && route && route.vehicle_usage.default_store_start&.id == @store.id
@store_start_route = route
elsif params[:depot_type] == 'stop' && route && route.vehicle_usage.default_store_stop&.id == @store.id
@store_stop_route = route
end
end
end
format.json
end
end
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/plannings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -815,8 +815,8 @@ def export_columns
) +
(@customer || @planning.customer).custom_attributes.for_visit.map{ |ca|
"custom_attributes_visit[#{ca.name}]".to_sym
} + (@customer || @planning.customer).custom_attributes.for_stop_visit.map{ |ca|
"custom_attributes_stop_visit[#{ca.name}]".to_sym
} + (@customer || @planning.customer).custom_attributes.for_export_stops_unique_by_name.map{ |ca|
"custom_attributes_stop[#{ca.name}]".to_sym
}
end

Expand Down
50 changes: 47 additions & 3 deletions app/controllers/routes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -203,12 +203,56 @@ def route_params
end

def route_driver_params
params.require(:route).permit(
permitted = params.require(:route).permit(
:status,
start_route_data_attributes: [:status],
stop_route_data_attributes: [:status],
custom_attributes: RecursiveParamsHelper.permit_recursive(params['route']['custom_attributes'])
)

merge_route_custom_attributes!(permitted) if permitted[:custom_attributes].present?

permitted
end

# Merges incoming custom_attributes using composite keys (related_field:name).
# Converts nested or flat incoming params to flat hash with composite keys.
def merge_route_custom_attributes!(permitted)
merged = (@route.custom_attributes || {}).dup
nested_keys = %w[start_route_data stop_route_data]
incoming = permitted[:custom_attributes]

# Convert nested structure (from form) to composite keys
if incoming.keys.any? { |k| nested_keys.include?(k.to_s) }
incoming.each do |key, value|
key_s = key.to_s
if nested_keys.include?(key_s) && value.is_a?(Hash)
value.each { |name, val| merged["#{key_s}:#{name}"] = val }
elsif !nested_keys.include?(key_s)
merged[key_s] = value
end
end
else
# Flat: infer related_field from route_data_attributes context when keys are not already composite
related_field = if permitted[:start_route_data_attributes].present? && permitted[:stop_route_data_attributes].blank?
'start_route_data'
elsif permitted[:stop_route_data_attributes].present? && permitted[:start_route_data_attributes].blank?
'stop_route_data'
end

incoming.each do |k, v|
key_s = k.to_s
# Keys from form may already be composite (e.g. "start_route_data:Kilométrage"), do not double-prefix
storage_key = if related_field && !key_s.include?(':')
"#{related_field}:#{key_s}"
else
key_s
end
merged[storage_key] = v
end
end

permitted[:custom_attributes] = merged
end

def export_params
Expand Down Expand Up @@ -280,8 +324,8 @@ def export_columns
}) +
(@customer || @planning.customer).custom_attributes.for_visit.map{ |ca|
"custom_attributes_visit[#{ca.name}]".to_sym
} + (@customer || @planning.customer).custom_attributes.for_stop_visit.map{ |ca|
"custom_attributes_stop_visit[#{ca.name}]".to_sym
} + (@customer || @planning.customer).custom_attributes.for_export_stops_unique_by_name.map{ |ca|
"custom_attributes_stop[#{ca.name}]".to_sym
}
end
end
14 changes: 14 additions & 0 deletions app/controllers/stores_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ def index

def show
@show_isoline = true
if request.format.json? && params[:planning_id].present? && params[:route_id].present? && params[:depot_type].in?(%w[start stop])
# Sidebar marker click: show only the depot data for the current route
@planning = current_user.customer.plannings.find_by(id: params[:planning_id])
if @planning
routes = @planning.routes.includes(vehicle_usage: [:store_start, :store_stop, {vehicle_usage_set: [:store_start, :store_stop]}])
.select(&:vehicle_usage_id)
route = routes.find { |r| r.id.to_s == params[:route_id] }
if params[:depot_type] == 'start' && route && route.vehicle_usage.default_store_start&.id == @store.id
@store_start_route = route
elsif params[:depot_type] == 'stop' && route && route.vehicle_usage.default_store_stop&.id == @store.id
@store_stop_route = route
end
end
end
end

def new
Expand Down
26 changes: 21 additions & 5 deletions app/helpers/custom_attributes_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ def custom_attribute_default_value_form_field(object_type, typed_default_value)
end

def custom_attribute_form_field(form, object, custom_attribute, prefix, related_field: nil)
field_name = "#{prefix}[custom_attributes][#{custom_attribute.name}]"
# Use composite key (related_field:name) for Route to distinguish start vs stop
storage_key = CustomAttribute.storage_key_for(custom_attribute.name, related_field: related_field)
field_name = "#{prefix}[custom_attributes][#{storage_key}]"
typed_hash = if related_field.present?
object.custom_attributes_typed_hash(related_field: related_field)
else
object.custom_attributes_typed_hash
end
current_value = object.custom_attributes.key?(custom_attribute.name) ? typed_hash[custom_attribute.name] : custom_attribute.typed_default_value
has_value = object.custom_attributes.key?(storage_key)
current_value = has_value ? typed_hash[custom_attribute.name] : custom_attribute.typed_default_value
placeholder = custom_attribute.typed_default_value || t('web.form.empty_entry')
case custom_attribute.object_type_before_type_cast
when 0
Expand All @@ -42,13 +45,26 @@ def custom_attribute_form_field(form, object, custom_attribute, prefix, related_
end
end

def custom_attribute_template(custom_attribute, object)
current_value = object.custom_attributes.key?(custom_attribute.name) ? object.custom_attributes_typed_hash[custom_attribute.name] : custom_attribute.typed_default_value
# Builds display hash for custom attribute in popups.
# For Route with related_field (e.g. start_route_data, stop_route_data), uses composite storage key.
def custom_attribute_template(custom_attribute, object, related_field: nil)
storage_key = if related_field.present? && object.is_a?(Route)
CustomAttribute.storage_key_for(custom_attribute.name, related_field: related_field)
else
custom_attribute.name
end
typed_hash = if related_field.present? && object.respond_to?(:custom_attributes_typed_hash)
object.custom_attributes_typed_hash(related_field: related_field)
else
object.custom_attributes_typed_hash
end
has_value = object.custom_attributes.key?(storage_key)
current_value = has_value ? typed_hash[custom_attribute.name] : custom_attribute.typed_default_value
case custom_attribute.object_type_before_type_cast
when 0
{ html: "<li><i class='fa fa-file-lines fa-fw'></i> #{custom_attribute.name} : <i class='fa #{current_value ? 'fa-circle-check' : 'fa-circle-xmark'} fa-fw'></i></li>" }
when 4
{ html: "<li><i class='fa fa-file-lines fa-fw'></i> #{custom_attribute.name} : #{object.custom_attributes.key?(custom_attribute.name) ? current_value : nil}</li>" }
{ html: "<li><i class='fa fa-file-lines fa-fw'></i> #{custom_attribute.name} : #{has_value ? current_value : nil}</li>" }
else
{ html: "<li><i class='fa fa-file-lines fa-fw'></i> #{custom_attribute.name} : #{current_value}</li>" }
end
Expand Down
11 changes: 9 additions & 2 deletions app/models/concerns/typed_attribute.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ module TypedAttribute

class_methods do
def typed_attr(current_attribute)
define_method("#{current_attribute}_has_key?") do |name, related_field: nil|
storage_key = CustomAttribute.storage_key_for(name, related_field: related_field)
send(current_attribute).key?(storage_key)
end

define_method("#{current_attribute}_typed_hash") do |related_field: nil|
customer =
if self.respond_to?(:customer)
Expand All @@ -24,9 +29,11 @@ def typed_attr(current_attribute)
reference_attributes = reference_attributes.where(related_field: nil)
end

current_attributes = send(current_attribute)
current_attributes = send(current_attribute) || {}
rhash = Hash[reference_attributes.map{ |r_a|
[r_a.name, typed_value(r_a.object_type, current_attributes[r_a.name] || r_a.default_value)]
storage_key = CustomAttribute.storage_key_for(r_a.name, related_field: related_field)
raw_value = current_attributes[storage_key]
[r_a.name, typed_value(r_a.object_type, raw_value || r_a.default_value)]
}]
rhash
end
Expand Down
10 changes: 10 additions & 0 deletions app/models/custom_attribute.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,17 @@ class CustomAttribute < ApplicationRecord
scope :for_visit, -> { where(object_class: :visit) }
scope :for_stop_visit, -> { where(object_class: :stop_visit) }
scope :for_stop_store, -> { where(object_class: :stop_store) }
scope :for_export_stops_unique_by_name, -> {
export_stops = where(object_class: [:stop_visit, :stop_store, :route])
export_stops.where(id: export_stops.unscope(:order).group(:name).select("MIN(id) as id"))
}
scope :for_route, -> { where(object_class: :route) }
scope :for_related_field, ->(field) { where(related_field: field) }
scope :without_related_field, -> { where(related_field: nil) }

auto_strip_attributes :name
validates :name, presence: true
validates :name, format: { without: /:/, message: :cannot_contain_colon }

def typed_default_value
case object_type
Expand Down Expand Up @@ -107,6 +112,11 @@ def self.parse_object_class_value(value)
end
end

# Composite key for custom_attributes storage: "related_field:name" when related_field present, else "name"
def self.storage_key_for(name, related_field: nil)
related_field.present? ? "#{related_field}:#{name}" : name.to_s
end

def valid_related_fields
return [] unless object_class
self.class.related_fields_for(object_class).map(&:to_s)
Expand Down
20 changes: 9 additions & 11 deletions app/models/customer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,6 @@ def delete_destinations(ids)
end

def delete_all_destinations
stops_relations.delete_all
destinations.delete_all
self.reload
reindex_routes
Expand Down Expand Up @@ -543,16 +542,15 @@ def destinations_inside_time_distance(position, distance, time, vehicle_usage =
private

def reindex_routes
Route.includes_stops.scoping do
plannings.reload.each { |p|
p.routes.each do |route|
# reindex remaining stops (like rests)
route.force_reindex
route.outdated = true if !route.geojson_points.try(&:empty?) || !route.geojson_tracks.try(&:empty?)
end
p.save!
}
end
plannings_to_reindex = plannings.includes(routes: [:route_geojson, :stops])
plannings_to_reindex.each { |p|
p.routes.each do |route|
# reindex remaining stops (like rests)
route.force_reindex
route.outdated = true if !route.geojson_points.try(&:empty?) || !route.geojson_tracks.try(&:empty?)
end
p.save!
}
end

def devices_update_vehicles
Expand Down
Loading
Loading