diff --git a/app/api/v01/custom_attributes.rb b/app/api/v01/custom_attributes.rb
index 3a879e510..d7dfa39e3 100644
--- a/app/api/v01/custom_attributes.rb
+++ b/app/api/v01/custom_attributes.rb
@@ -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]
diff --git a/app/api/v01/destinations.rb b/app/api/v01/destinations.rb
index 0141ab220..59f825897 100644
--- a/app/api/v01/destinations.rb
+++ b/app/api/v01/destinations.rb
@@ -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)
@@ -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
diff --git a/app/assets/javascripts/plannings.js b/app/assets/javascripts/plannings.js
index 9b45cdcac..de0c58e9e 100644
--- a/app/assets/javascripts/plannings.js
+++ b/app/assets/javascripts/plannings.js
@@ -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();
diff --git a/app/assets/javascripts/routes_layers.js b/app/assets/javascripts/routes_layers.js
index a3c872e2a..fe759bfb2 100644
--- a/app/assets/javascripts/routes_layers.js
+++ b/app/assets/javascripts/routes_layers.js
@@ -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;
@@ -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)
@@ -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) {
@@ -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);
}
diff --git a/app/assets/stylesheets/scaffolds.css.scss b/app/assets/stylesheets/scaffolds.css.scss
index dc4a7cfab..bab5366f5 100644
--- a/app/assets/stylesheets/scaffolds.css.scss
+++ b/app/assets/stylesheets/scaffolds.css.scss
@@ -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;
@@ -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;
}
diff --git a/app/controllers/api_web/v01/stores_controller.rb b/app/controllers/api_web/v01/stores_controller.rb
index cd4f9714b..f5f817ecc 100644
--- a/app/controllers/api_web/v01/stores_controller.rb
+++ b/app/controllers/api_web/v01/stores_controller.rb
@@ -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
diff --git a/app/controllers/plannings_controller.rb b/app/controllers/plannings_controller.rb
index 6cfe04cfa..9c50009b3 100644
--- a/app/controllers/plannings_controller.rb
+++ b/app/controllers/plannings_controller.rb
@@ -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
diff --git a/app/controllers/routes_controller.rb b/app/controllers/routes_controller.rb
index a251b8a70..3d77ea228 100644
--- a/app/controllers/routes_controller.rb
+++ b/app/controllers/routes_controller.rb
@@ -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
@@ -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
diff --git a/app/controllers/stores_controller.rb b/app/controllers/stores_controller.rb
index f449b1972..13d2a825b 100644
--- a/app/controllers/stores_controller.rb
+++ b/app/controllers/stores_controller.rb
@@ -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
diff --git a/app/helpers/custom_attributes_helper.rb b/app/helpers/custom_attributes_helper.rb
index 0271be06d..8c098e1ad 100644
--- a/app/helpers/custom_attributes_helper.rb
+++ b/app/helpers/custom_attributes_helper.rb
@@ -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
@@ -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: "
#{custom_attribute.name} : " }
when 4
- { html: " #{custom_attribute.name} : #{object.custom_attributes.key?(custom_attribute.name) ? current_value : nil}" }
+ { html: " #{custom_attribute.name} : #{has_value ? current_value : nil}" }
else
{ html: " #{custom_attribute.name} : #{current_value}" }
end
diff --git a/app/models/concerns/typed_attribute.rb b/app/models/concerns/typed_attribute.rb
index 2536d8842..4b6e2faa7 100644
--- a/app/models/concerns/typed_attribute.rb
+++ b/app/models/concerns/typed_attribute.rb
@@ -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)
@@ -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
diff --git a/app/models/custom_attribute.rb b/app/models/custom_attribute.rb
index 0ef077a95..95a3d90db 100644
--- a/app/models/custom_attribute.rb
+++ b/app/models/custom_attribute.rb
@@ -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
@@ -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)
diff --git a/app/models/customer.rb b/app/models/customer.rb
index ded301f03..65f29950e 100644
--- a/app/models/customer.rb
+++ b/app/models/customer.rb
@@ -439,7 +439,6 @@ def delete_destinations(ids)
end
def delete_all_destinations
- stops_relations.delete_all
destinations.delete_all
self.reload
reindex_routes
@@ -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
diff --git a/app/templates/stops/_show.mustache b/app/templates/stops/_show.mustache
index c74db2157..a996c9739 100644
--- a/app/templates/stops/_show.mustache
+++ b/app/templates/stops/_show.mustache
@@ -261,11 +261,42 @@
{{#i18n}}plannings.edit.popup.store{{/i18n}}
+ {{#status}}
+ - {{#i18n}}plannings.edit.popup.status{{/i18n}} {{status}}
+ - {{#i18n}}plannings.edit.popup.eta{{/i18n}} {{eta_formated}}
+ - {{#i18n}}plannings.edit.popup.status_updated_at{{/i18n}} {{status_updated_at}}
+ {{/status}}
{{#time}}- {{#i18n}}plannings.edit.popup.time{{/i18n}} {{time}}{{#time_day}} (+{{time_day}}){{/time_day}}
{{/time}}
{{#loads}}
- {{#i18n}}plannings.edit.popup.loadings{{/i18n}} {{quantity_formatted}}
{{/loads}}
+{{#store_start}}
+
+{{#i18n}}plannings.edit.popup.store_start{{/i18n}}{{#name}} - {{name}}{{/name}}
+
+ {{#status}}
+ - {{#i18n}}plannings.edit.popup.status{{/i18n}} {{status}}
+ - {{#i18n}}plannings.edit.popup.eta{{/i18n}} {{eta_formated}}
+ {{/status}}
+ {{#custom_attributes}}
+ {{{html}}}
+ {{/custom_attributes}}
+
+{{/store_start}}
+{{#store_stop}}
+
+{{#i18n}}plannings.edit.popup.store_stop{{/i18n}}{{#name}} - {{name}}{{/name}}
+
+ {{#status}}
+ - {{#i18n}}plannings.edit.popup.status{{/i18n}} {{status}}
+ - {{#i18n}}plannings.edit.popup.eta{{/i18n}} {{eta_formated}}
+ {{/status}}
+ {{#custom_attributes}}
+ {{{html}}}
+ {{/custom_attributes}}
+
+{{/store_stop}}
{{#error}}
{{#no_path}}
diff --git a/app/views/custom_attributes/_form.html.haml b/app/views/custom_attributes/_form.html.haml
index 12c18a052..a03e2fb69 100644
--- a/app/views/custom_attributes/_form.html.haml
+++ b/app/views/custom_attributes/_form.html.haml
@@ -8,7 +8,7 @@
- [label, key.to_s]
= bootstrap_form_for @custom_attribute, layout: :horizontal do |f|
= render 'shared/error_messages', model: @custom_attribute
- = f.text_field :name, help: t('.name_help')
+ = f.text_field :name, help: t('.name_help'), pattern: '[^:]*', title: t('.name_pattern_title')
= f.select :object_type, CustomAttribute.object_types.keys.map{ |key| [t("custom_attributes.types.#{key}"), key] }, { help: t('.object_type_help') }, class: 'form-control'
= f.hidden_field :object_class, id: 'custom_attribute_object_class'
= f.hidden_field :related_field, id: 'custom_attribute_related_field'
diff --git a/app/views/routes/_edit.json.jbuilder b/app/views/routes/_edit.json.jbuilder
index 4badf41db..edbd4dc36 100644
--- a/app/views/routes/_edit.json.jbuilder
+++ b/app/views/routes/_edit.json.jbuilder
@@ -134,6 +134,10 @@ json.store_start do
json.extract! route.start_route_data, :emission, :cost_distance, :cost_fixed, :cost_time, :revenue, :start, :end, :drive_time, :wait_time, :visits_duration, :pickups, :deliveries, :departure, :status, :eta, :hidden, :color
json.quantities route_data_quantities(route.start_route_data, route.vehicle_usage_id && route.vehicle_usage.vehicle)
end
+ json.custom_attributes (
+ planning.customer.custom_attributes.for_route.for_related_field('start_route_data')
+ .map{ |c_a| custom_attribute_template(c_a, route, related_field: 'start_route_data') }
+ )
end if route.vehicle_usage && route.vehicle_usage.default_store_start
(json.start_with_service Time.at(display_start_time(route)).utc.strftime('%H:%M')) if display_start_time(route)
(json.start_with_service_day number_of_days(display_start_time(route))) if display_start_time(route)
@@ -227,6 +231,12 @@ if @with_stops
(json.error true) if !stop.store_reload.store.position?
(json.departure time_over_day(stop.time.to_i + stop.store_reload.default_duration.to_i))
json.departure_day number_of_days(stop.time.to_i + stop.store_reload.default_duration.to_i)
+ if (store_status = stop.route_data&.status || stop.status) && planning.customer.enable_stop_status
+ json.status t("plannings.edit.stop_store_status.#{store_status.downcase}", default: store_status)
+ json.status_code store_status.downcase
+ json.eta_formated l(stop.route_data.eta, format: :hour_minute) if stop.route_data&.eta
+ json.status_updated_at l(stop.status_updated_at, format: :hour_minute) if stop.status_updated_at
+ end
end
json.route_data do
json.route_id stop.route.id
@@ -247,6 +257,37 @@ if @with_stops
end
end
json.duration l(Time.at(stop.duration).utc, format: :hour_minute_second) if stop.duration > 0
+ # Include route depot statuses and custom attributes for stop-popup display
+ if route.vehicle_usage_id && planning.customer.enable_stop_status
+ if route.vehicle_usage.default_store_start && (route.start_route_data&.status || planning.customer.custom_attributes.for_route.for_related_field('start_route_data').any?)
+ json.store_start do
+ json.name route.vehicle_usage.default_store_start.name
+ if route.start_route_data&.status
+ json.status t("plannings.edit.stop_store_status.#{route.start_route_data.status.downcase}", default: route.start_route_data.status)
+ json.status_code route.start_route_data.status.downcase
+ json.eta_formated l(route.start_route_data.eta, format: :hour_minute) if route.start_route_data.eta
+ end
+ json.custom_attributes (
+ planning.customer.custom_attributes.for_route.for_related_field('start_route_data')
+ .map{ |c_a| custom_attribute_template(c_a, route, related_field: 'start_route_data') }
+ )
+ end
+ end
+ if route.vehicle_usage.default_store_stop && (route.stop_route_data&.status || planning.customer.custom_attributes.for_route.for_related_field('stop_route_data').any?)
+ json.store_stop do
+ json.name route.vehicle_usage.default_store_stop.name
+ if route.stop_route_data&.status
+ json.status t("plannings.edit.stop_store_status.#{route.stop_route_data.status.downcase}", default: route.stop_route_data.status)
+ json.status_code route.stop_route_data.status.downcase
+ json.eta_formated l(route.stop_route_data.eta, format: :hour_minute) if route.stop_route_data.eta
+ end
+ json.custom_attributes (
+ planning.customer.custom_attributes.for_route.for_related_field('stop_route_data')
+ .map{ |c_a| custom_attribute_template(c_a, route, related_field: 'stop_route_data') }
+ )
+ end
+ end
+ end
end
end
@@ -274,6 +315,10 @@ json.store_stop do
json.vehicle_id route.vehicle_usage.vehicle_id
json.extract! route.stop_route_data, :status, :eta
end
+ json.custom_attributes (
+ planning.customer.custom_attributes.for_route.for_related_field('stop_route_data')
+ .map{ |c_a| custom_attribute_template(c_a, route, related_field: 'stop_route_data') }
+ )
end if route.vehicle_usage_id && route.vehicle_usage.default_store_stop
(json.end_without_service Time.at(display_end_time(route)).utc.strftime('%H:%M')) if display_end_time(route)
(json.end_without_service_day number_of_days(display_end_time(route))) if display_end_time(route)
diff --git a/app/views/routes/_show.csv.ruby b/app/views/routes/_show.csv.ruby
index 6b20146dc..b547dcd3b 100644
--- a/app/views/routes/_show.csv.ruby
+++ b/app/views/routes/_show.csv.ruby
@@ -1,5 +1,6 @@
-visit_custom_attributes = route.planning.customer.custom_attributes.select(&:visit?)
-stop_visit_custom_attributes = route.planning.customer.custom_attributes.select(&:stop_visit?)
+visit_custom_attributes = route.planning.customer.custom_attributes.for_visit
+# One column per name: stop_visit, stop_store and route attributes with the same name share a column
+stop_custom_attributes = route.planning.customer.custom_attributes.for_export_stops_unique_by_name
if route.vehicle_usage_id && (!@params.key?(:stops) || @params[:stops].split('|').include?('store'))
row = {
@@ -73,8 +74,8 @@ if route.vehicle_usage_id && (!@params.key?(:stops) || @params[:stops].split('|'
])
row.merge!(
- Hash[stop_visit_custom_attributes.map{ |ca|
- ["custom_attributes_stop_visit[#{ca.name}]".to_sym, nil]
+ Hash[stop_custom_attributes.map{ |ca|
+ ["custom_attributes_stop[#{ca.name}]".to_sym, route.custom_attributes_typed_hash(related_field: :start_route_data)[ca.name]]
}
])
@@ -172,8 +173,8 @@ route.stops.each { |stop|
}
])
row.merge!(
- Hash[stop_visit_custom_attributes.map{ |ca|
- ["custom_attributes_stop_visit[#{ca.name}]".to_sym, stop.custom_attributes_typed_hash[ca.name]]
+ Hash[stop_custom_attributes.map{ |ca|
+ ["custom_attributes_stop[#{ca.name}]".to_sym, stop.custom_attributes_typed_hash[ca.name]]
}
])
@@ -253,8 +254,8 @@ if route.vehicle_usage_id && (!@params.key?(:stops) || @params[:stops].split('|'
])
row.merge!(
- Hash[stop_visit_custom_attributes.map{ |ca|
- ["custom_attributes_stop_visit[#{ca.name}]".to_sym, nil]
+ Hash[stop_custom_attributes.map{ |ca|
+ ["custom_attributes_stop[#{ca.name}]".to_sym, route.custom_attributes_typed_hash(related_field: :stop_route_data)[ca.name]]
}
])
diff --git a/app/views/stops/_show.json.jbuilder b/app/views/stops/_show.json.jbuilder
index d3c07a5ef..64a713466 100644
--- a/app/views/stops/_show.json.jbuilder
+++ b/app/views/stops/_show.json.jbuilder
@@ -95,6 +95,12 @@ when StopStore
json.store_reload_id stop.store_reload.id
json.color stop.default_color
(json.error true) if !stop.store_reload.store.position?
+ if (store_status = stop.route_data&.status || stop.status) && stop.route.planning.customer.enable_stop_status
+ json.status t("plannings.edit.stop_store_status.#{store_status.downcase}", default: store_status)
+ json.status_code store_status.downcase
+ json.eta_formated l(stop.route_data.eta, format: :hour_minute) if stop.route_data&.eta
+ json.status_updated_at l(stop.status_updated_at, format: :hour_minute) if stop.status_updated_at
+ end
end
json.route_data do
json.id stop.route_data.id
diff --git a/app/views/stores/_show.json.jbuilder b/app/views/stores/_show.json.jbuilder
index 235775300..cdd331752 100644
--- a/app/views/stores/_show.json.jbuilder
+++ b/app/views/stores/_show.json.jbuilder
@@ -6,3 +6,30 @@ if @show_isoline
json.isochrone store.customer.router.isochrone
json.isodistance store.customer.router.isodistance
end
+if @store_start_route
+ json.store_start do
+ route = @store_start_route
+ route_data = route.start_route_data
+ json.name route.vehicle_usage.vehicle.name
+ json.route_id route.id
+ if route_data&.status
+ json.status t("plannings.edit.stop_store_status.#{route_data.status.downcase}", default: route_data.status)
+ json.status_code route_data.status.downcase
+ json.eta_formated l(route_data.eta, format: :hour_minute) if route_data.eta
+ end
+ json.custom_attributes store.customer.custom_attributes.for_route.for_related_field('start_route_data').map { |c_a| custom_attribute_template(c_a, route, related_field: 'start_route_data') }
+ end
+elsif @store_stop_route
+ json.store_stop do
+ route = @store_stop_route
+ route_data = route.stop_route_data
+ json.name route.vehicle_usage.vehicle.name
+ json.route_id route.id
+ if route_data&.status
+ json.status t("plannings.edit.stop_store_status.#{route_data.status.downcase}", default: route_data.status)
+ json.status_code route_data.status.downcase
+ json.eta_formated l(route_data.eta, format: :hour_minute) if route_data.eta
+ end
+ json.custom_attributes store.customer.custom_attributes.for_route.for_related_field('stop_route_data').map { |c_a| custom_attribute_template(c_a, route, related_field: 'stop_route_data') }
+ end
+end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 7678b2252..7f1f5b6d6 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -633,6 +633,10 @@ en:
attributes:
inconsistent_customer: does not belong to this customer
models:
+ custom_attribute:
+ attributes:
+ name:
+ cannot_contain_colon: cannot contain the character ":"
location:
missing_address_or_latlng: Postalcode and city cannot be empty if lat/lng are empty
lat_outside_range: must be inside range -90..90
@@ -955,7 +959,8 @@ en:
title: New additional property
form:
name: Name
- name_help: Property name
+ name_help: Property name (cannot contain ":")
+ name_pattern_title: The character ":" is not allowed
object_class: Entity
object_class_help_html: >-
Entity to which property is linked.
@@ -2087,6 +2092,9 @@ en:
status: 'Status:'
status_updated_at: 'Time Status:'
eta: 'ETA:'
+ store_reload: Reload
+ store_start: 'Start store'
+ store_stop: 'Stop store'
day: D+
error:
no_geolocalization: No geolocalization
@@ -2310,7 +2318,7 @@ en:
status_updated_at: time status changed
eta: eta
custom_attributes_visit: visit attributes
- custom_attributes_stop_visit: stop attributes
+ custom_attributes_stop: stop attributes
stop_size: number of stops
stop_active_size: number of active stops
time: total time
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 10f92cd58..7dd8fe11d 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -639,6 +639,10 @@ fr:
attributes:
inconsistent_customer: n'appartient pas à cet espace client
models:
+ custom_attribute:
+ attributes:
+ name:
+ cannot_contain_colon: ne peut pas contenir le caractère « : »
location:
missing_address_or_latlng: >-
Le code postal et la ville ne peuvent pas être vides si lat/lng sont
@@ -979,7 +983,8 @@ fr:
title: Nouvelle propriété additionnelle
form:
name: Nom
- name_help: Nom de la propriété
+ name_help: Nom de la propriété (le caractère « : » n'est pas autorisé)
+ name_pattern_title: Le caractère « : » n'est pas autorisé
object_class: Entité
object_class_help_html: >-
Entité à laquelle lier la propriété.
@@ -2186,6 +2191,9 @@ fr:
status: 'Statut :'
status_updated_at: Horaire statut
eta: 'ETA :'
+ store_reload: Rechargement
+ store_start: 'Site départ'
+ store_stop: 'Site arrivée'
day: J+
error:
no_geolocalization: Pas de géolocalisation
@@ -2415,7 +2423,7 @@ fr:
status_updated_at: horaire statut
eta: eta
custom_attributes_visit: attributs visite
- custom_attributes_stop_visit: attributs arrêt
+ custom_attributes_stop: attributs arrêt
stop_size: nombre d'arrêts
stop_active_size: nombre d'arrêts actifs
time: durée total
diff --git a/test/api/v01/custom_attributes_test.rb b/test/api/v01/custom_attributes_test.rb
index 3421b2185..55792a145 100644
--- a/test/api/v01/custom_attributes_test.rb
+++ b/test/api/v01/custom_attributes_test.rb
@@ -66,4 +66,14 @@ def api_admin(part = nil, param = {})
assert_equal "1.1", JSON.parse(last_response.body)['default_value']
end
end
+
+ test 'should reject name containing colon on create' do
+ post api(), {name: 'invalid:name', default_value: 1.1, object_type: 'float', object_class: 'vehicle'}
+ assert last_response.client_error?, last_response.body
+ end
+
+ test 'should reject name containing colon on update' do
+ put api(@custom_attribute.id), nil, input: {name: 'invalid:name', object_type: 'string', object_class: 'vehicle'}.to_json, CONTENT_TYPE: 'application/json'
+ assert last_response.client_error?, last_response.body
+ end
end
diff --git a/test/controllers/routes_controller_test.rb b/test/controllers/routes_controller_test.rb
index af03152de..11f5b2a99 100644
--- a/test/controllers/routes_controller_test.rb
+++ b/test/controllers/routes_controller_test.rb
@@ -171,7 +171,8 @@ class RoutesControllerTest < ActionController::TestCase
customer: vehicle.customer,
name: 'driver_note',
object_class: 'route',
- related_field: 'start_route_data'
+ related_field: 'start_route_data',
+ object_type: 'string'
)
patch :driver_update, params: {
@@ -181,7 +182,7 @@ class RoutesControllerTest < ActionController::TestCase
start_route_data_attributes: {
status: 'in_progress'
},
- custom_attributes: { 'driver_note' => 'On route' }
+ custom_attributes: { 'start_route_data:driver_note' => 'On route' }
},
format: :json
}
@@ -190,7 +191,62 @@ class RoutesControllerTest < ActionController::TestCase
assert_equal({ 'success' => true }, JSON.parse(response.body))
route.reload
assert_equal 'in_progress', route.start_route_data.status
- assert_equal 'On route', route.custom_attributes['driver_note']
+ assert_equal 'On route', route.custom_attributes_typed_hash(related_field: :start_route_data)['driver_note']
+ end
+
+ test 'should distinguish start_route_data and stop_route_data custom_attributes via driver_update' do
+ vehicle = vehicles(:vehicle_one)
+ route = routes(:route_one_one)
+
+ CustomAttribute.create!(
+ customer: vehicle.customer,
+ name: 'odometer',
+ object_class: 'route',
+ related_field: 'start_route_data',
+ object_type: 'float'
+ )
+ CustomAttribute.create!(
+ customer: vehicle.customer,
+ name: 'odometer',
+ object_class: 'route',
+ related_field: 'stop_route_data',
+ object_type: 'float'
+ )
+
+ # Update start custom attribute
+ patch :driver_update, params: {
+ id: route,
+ driver_token: vehicle.driver_token,
+ route: {
+ start_route_data_attributes: { status: 'in_progress' },
+ custom_attributes: {
+ 'start_route_data:odometer' => '100'
+ }
+ },
+ format: :json
+ }
+ assert_response :success
+ route.reload
+ assert_equal 100, route.custom_attributes_typed_hash(related_field: :start_route_data)['odometer']
+ assert_not route.custom_attributes_has_key?('odometer', related_field: :stop_route_data),
+ 'stop_route_data odometer should not be set yet'
+
+ # Update stop custom attribute without overwriting start
+ patch :driver_update, params: {
+ id: route,
+ driver_token: vehicle.driver_token,
+ route: {
+ stop_route_data_attributes: { status: 'atstore' },
+ custom_attributes: {
+ 'stop_route_data:odometer' => '250'
+ }
+ },
+ format: :json
+ }
+ assert_response :success
+ route.reload
+ assert_equal 100, route.custom_attributes_typed_hash(related_field: :start_route_data)['odometer']
+ assert_equal 250, route.custom_attributes_typed_hash(related_field: :stop_route_data)['odometer']
end
test 'should not update route hidden via driver_update' do
diff --git a/test/models/custom_attribute_test.rb b/test/models/custom_attribute_test.rb
index 3aef9e66c..f2ee5b528 100644
--- a/test/models/custom_attribute_test.rb
+++ b/test/models/custom_attribute_test.rb
@@ -25,6 +25,28 @@ class CustomAttributeTest < ActiveSupport::TestCase
assert custom_attribute.errors[:customer].any?, "customer should have validation errors"
end
+ test 'should reject name containing colon' do
+ custom_attribute = CustomAttribute.new(
+ name: 'invalid:name',
+ object_type: 'string',
+ object_class: 'vehicle',
+ customer: @customer
+ )
+ refute custom_attribute.valid?
+ assert_includes custom_attribute.errors[:name],
+ I18n.t('activerecord.errors.models.custom_attribute.attributes.name.cannot_contain_colon')
+ end
+
+ test 'should accept name without colon' do
+ custom_attribute = CustomAttribute.new(
+ name: 'valid_name',
+ object_type: 'string',
+ object_class: 'vehicle',
+ customer: @customer
+ )
+ assert custom_attribute.valid?, "Custom attribute should be valid without colon: #{custom_attribute.errors.full_messages}"
+ end
+
test 'should strip whitespace from name' do
custom_attribute = CustomAttribute.create!(
name: ' test_name ',
diff --git a/test/views/api_web/v01/plannings_test.rb b/test/views/api_web/v01/plannings_test.rb
index a329810e1..2dd2e531d 100644
--- a/test/views/api_web/v01/plannings_test.rb
+++ b/test/views/api_web/v01/plannings_test.rb
@@ -31,4 +31,26 @@ def around
assert json['stop_id']
assert !json['manage_organize']
end
+
+ test 'stop json includes store_start and store_stop with custom_attributes when route has depots' do
+ Bullet.enable = false
+ customer = customers(:customer_one)
+ customer.update!(job_optimizer_id: nil, enable_stop_status: true)
+ route = @planning.routes.joins(:vehicle_usage).first
+ skip 'Route has no start/stop depots' if !route&.vehicle_usage&.default_store_start || !route&.vehicle_usage&.default_store_stop
+
+ get "/api-web/0.1/routes/#{route.id}/stops/by_index/1.json?api_key=testkey1"
+ assert last_response.ok?, last_response.body
+ json = JSON.parse(last_response.body)
+
+ # When route has start/stop depots, store_start and store_stop may be present with custom_attributes
+ if json['store_start']
+ assert json['store_start'].key?('custom_attributes'), 'store_start should include custom_attributes'
+ assert_kind_of Array, json['store_start']['custom_attributes']
+ end
+ if json['store_stop']
+ assert json['store_stop'].key?('custom_attributes'), 'store_stop should include custom_attributes'
+ assert_kind_of Array, json['store_stop']['custom_attributes']
+ end
+ end
end