From b9b4376dcc534bda327d8fdcdc38e9b047caa6bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Mon, 16 Feb 2026 12:19:53 +0100 Subject: [PATCH 1/3] Destinations#destroy_multiple do not delete_all if nothing found --- app/api/v01/destinations.rb | 4 ++++ 1 file changed, 4 insertions(+) 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 From b52439c613c84ff12ab63099298583f94455ab70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Mon, 16 Feb 2026 14:09:27 +0100 Subject: [PATCH 2/3] Customer - Destinations delete avoid N+1 requests --- app/models/customer.rb | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) 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 From f63407ef2bd5b1144c954c9e0c94d12d4ef44702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Mon, 16 Feb 2026 15:41:30 +0100 Subject: [PATCH 3/3] Export store custom_attributes (start, reload, stop) & display store status in popup --- app/api/v01/custom_attributes.rb | 2 +- app/assets/javascripts/plannings.js | 7 ++- app/assets/javascripts/routes_layers.js | 21 +++++-- app/assets/stylesheets/scaffolds.css.scss | 5 +- .../api_web/v01/stores_controller.rb | 14 +++++ app/controllers/plannings_controller.rb | 4 +- app/controllers/routes_controller.rb | 50 ++++++++++++++- app/controllers/stores_controller.rb | 14 +++++ app/helpers/custom_attributes_helper.rb | 26 ++++++-- app/models/concerns/typed_attribute.rb | 11 +++- app/models/custom_attribute.rb | 10 +++ app/templates/stops/_show.mustache | 31 ++++++++++ app/views/custom_attributes/_form.html.haml | 2 +- app/views/routes/_edit.json.jbuilder | 45 ++++++++++++++ app/views/routes/_show.csv.ruby | 17 ++--- app/views/stops/_show.json.jbuilder | 6 ++ app/views/stores/_show.json.jbuilder | 27 ++++++++ config/locales/en.yml | 12 +++- config/locales/fr.yml | 12 +++- test/api/v01/custom_attributes_test.rb | 10 +++ test/controllers/routes_controller_test.rb | 62 ++++++++++++++++++- test/models/custom_attribute_test.rb | 22 +++++++ test/views/api_web/v01/plannings_test.rb | 22 +++++++ 23 files changed, 395 insertions(+), 37 deletions(-) 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/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/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}} + + + + {{/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}}
    • + + {{/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}}
    • + + {{/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