diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index b96c83dc5..bb556675c 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -210,6 +210,7 @@ module Instructeurs @dossiers_funnel = @procedure.stats_dossiers_funnel @termines_states = @procedure.stats_termines_states @termines_by_week = @procedure.stats_termines_by_week + @usual_traitement_time_by_month = @procedure.stats_usual_traitement_time_by_month_in_days end private diff --git a/app/controllers/users/statistiques_controller.rb b/app/controllers/users/statistiques_controller.rb index 8b1611027..5ccafc2bf 100644 --- a/app/controllers/users/statistiques_controller.rb +++ b/app/controllers/users/statistiques_controller.rb @@ -5,6 +5,7 @@ module Users return procedure_not_found if @procedure.blank? || @procedure.brouillon? @usual_traitement_time = @procedure.stats_usual_traitement_time + @usual_traitement_time_by_month = @procedure.stats_usual_traitement_time_by_month_in_days @dossiers_funnel = @procedure.stats_dossiers_funnel @termines_states = @procedure.stats_termines_states @termines_by_week = @procedure.stats_termines_by_week diff --git a/app/models/concerns/procedure_stats_concern.rb b/app/models/concerns/procedure_stats_concern.rb index 6c8414e8c..cbe89c5a8 100644 --- a/app/models/concerns/procedure_stats_concern.rb +++ b/app/models/concerns/procedure_stats_concern.rb @@ -1,9 +1,21 @@ module ProcedureStatsConcern extend ActiveSupport::Concern + NB_DAYS_RECENT_DOSSIERS = 30 + # Percentage of dossiers considered to compute the 'usual traitement time'. + # For instance, a value of '90' means that the usual traitement time will return + # the duration under which 90% of the given dossiers are closed. + USUAL_TRAITEMENT_TIME_PERCENTILE = 90 + def stats_usual_traitement_time Rails.cache.fetch("#{cache_key_with_version}/stats_usual_traitement_time", expires_in: 12.hours) do - usual_traitement_time + usual_traitement_time_for_recent_dossiers(NB_DAYS_RECENT_DOSSIERS) + end + end + + def stats_usual_traitement_time_by_month_in_days + Rails.cache.fetch("#{cache_key_with_version}/stats_usual_traitement_time_by_month_in_days", expires_in: 12.hours) do + usual_traitement_time_by_month_in_days end end @@ -45,4 +57,51 @@ module ProcedureStatsConcern end end end + + def traitement_times(date_range) + Traitement.for_traitement_time_stats(self) + .where(processed_at: date_range) + .pluck('dossiers.en_construction_at', :processed_at) + .map { |en_construction_at, processed_at| { en_construction_at: en_construction_at, processed_at: processed_at } } + end + + def usual_traitement_time_by_month_in_days + traitement_times(first_processed_at..last_considered_processed_at) + .group_by { |t| t[:processed_at].beginning_of_month } + .transform_values { |month| month.map { |h| h[:processed_at] - h[:en_construction_at] } } + .transform_values { |traitement_times_for_month| traitement_times_for_month.percentile(USUAL_TRAITEMENT_TIME_PERCENTILE).ceil } + .transform_values { |seconds| seconds == 0 ? nil : seconds } + .transform_values { |seconds| convert_seconds_in_days(seconds) } + .transform_keys { |month| pretty_month(month) } + end + + def usual_traitement_time_for_recent_dossiers(nb_days) + now = Time.zone.now + traitement_time = + traitement_times((now - nb_days.days)..now) + .map { |times| times[:processed_at] - times[:en_construction_at] } + .percentile(USUAL_TRAITEMENT_TIME_PERCENTILE) + .ceil + + traitement_time = nil if traitement_time == 0 + traitement_time + end + + private + + def first_processed_at + Traitement.for_traitement_time_stats(self).pick(:processed_at) + end + + def last_considered_processed_at + (Time.zone.now - 1.month).end_of_month + end + + def convert_seconds_in_days(seconds) + (seconds / 60.0 / 60.0 / 24.0).ceil + end + + def pretty_month(month) + I18n.l(month, format: "%B %Y") + end end diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 0af87e5a9..07ca5e6ff 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -565,19 +565,6 @@ class Procedure < ApplicationRecord end end - def usual_traitement_time - times = Traitement.includes(:dossier) - .where(dossier: self.dossiers) - .where.not('dossiers.en_construction_at' => nil, :processed_at => nil) - .where(processed_at: 1.month.ago..Time.zone.now) - .pluck('dossiers.en_construction_at', :processed_at) - .map { |(en_construction_at, processed_at)| processed_at - en_construction_at } - - if times.present? - times.percentile(90).ceil - end - end - def populate_champ_stable_ids TypeDeChamp .joins(:revisions) diff --git a/app/models/traitement.rb b/app/models/traitement.rb index 8bb604945..83ca62867 100644 --- a/app/models/traitement.rb +++ b/app/models/traitement.rb @@ -19,6 +19,13 @@ class Traitement < ApplicationRecord .where("traitements.processed_at + (procedures.duree_conservation_dossiers_dans_ds * INTERVAL '1 month') - INTERVAL :expires_in < :now", { now: Time.zone.now, expires_in: Dossier::INTERVAL_BEFORE_EXPIRATION }) end + scope :for_traitement_time_stats, -> (procedure) do + includes(:dossier) + .where(dossier: procedure.dossiers) + .where.not('dossiers.en_construction_at' => nil, :processed_at => nil) + .order(:processed_at) + end + def self.count_dossiers_termines_by_month(groupe_instructeurs) last_traitements_per_dossier = Traitement .select('max(traitements.processed_at) as processed_at') diff --git a/app/views/shared/procedures/_stats.html.haml b/app/views/shared/procedures/_stats.html.haml index fb6925914..2da3e10bc 100644 --- a/app/views/shared/procedures/_stats.html.haml +++ b/app/views/shared/procedures/_stats.html.haml @@ -11,15 +11,23 @@ %span.big-number-card-number = distance_of_time_in_words(@usual_traitement_time) %span.big-number-card-detail - 90% des demandes du mois dernier ont été traitées en moins de #{distance_of_time_in_words(@usual_traitement_time)}. + #{ProcedureStatsConcern::USUAL_TRAITEMENT_TIME_PERCENTILE}% des demandes des #{ProcedureStatsConcern::NB_DAYS_RECENT_DOSSIERS} derniers jours ont été traitées en moins de #{distance_of_time_in_words(@usual_traitement_time)}. .stat-cards + .stat-card.stat-card-half.pull-left + %span.stat-card-title TEMPS DE TRAITEMENT + .chart-container + .chart + - colors = %w(#C3D9FF #0069CC #1C7EC9) # from _colors.scss + = column_chart @usual_traitement_time_by_month, ytitle: "Nb Jours", legend: "bottom", label: "Temps de traitement entre le passage en instruction et la réponse (accepté, refusé, ou classé sans suite) pour 90% des dossiers" + .stat-card.stat-card-half.pull-left %span.stat-card-title AVANCÉE DES DOSSIERS .chart-container .chart = area_chart @dossiers_funnel + .stat-cards .stat-card.stat-card-half.pull-left %span.stat-card-title TAUX D’ACCEPTATION .chart-container @@ -27,9 +35,8 @@ - colors = %w(#C3D9FF #0069CC #1C7EC9) # from _colors.scss = pie_chart @termines_states, colors: colors - .stat-cards - .stat-card.stat-card-half.pull-left - %span.stat-card-title RÉPARTITION PAR SEMAINE - .chart-container - .chart - = line_chart @termines_by_week, colors: ["#387EC3", "#AE2C2B", "#FAD859"] + .stat-card.stat-card-half.pull-left + %span.stat-card-title RÉPARTITION PAR SEMAINE + .chart-container + .chart + = line_chart @termines_by_week, colors: ["#387EC3", "#AE2C2B", "#FAD859"] diff --git a/app/views/users/dossiers/show/_estimated_delay.html.haml b/app/views/users/dossiers/show/_estimated_delay.html.haml index eabc51bb3..e33eab09c 100644 --- a/app/views/users/dossiers/show/_estimated_delay.html.haml +++ b/app/views/users/dossiers/show/_estimated_delay.html.haml @@ -4,8 +4,8 @@ - show_time_means = procedure.id != procedure_id_for_which_we_hide_the_estimated_delay && procedure.path != procedure_path_for_which_we_hide_the_estimated_delay - cache(procedure.id, expires_in: 1.day) do - - if procedure.usual_traitement_time && show_time_means + - if procedure.usual_traitement_time_for_recent_dossiers(ProcedureStatsConcern::NB_DAYS_RECENT_DOSSIERS) && show_time_means %p - Habituellement, les dossiers de cette démarche sont traités dans un délai de #{distance_of_time_in_words(procedure.usual_traitement_time)}. + Habituellement, les dossiers de cette démarche sont traités dans un délai de #{distance_of_time_in_words(procedure.usual_traitement_time_for_recent_dossiers(ProcedureStatsConcern::NB_DAYS_RECENT_DOSSIERS))}. %p - Cette estimation est calculée automatiquement à partir des délais d’instruction constatés précédemment. Le délai réel peut être différent, en fonction du type de démarche (par exemple pour un appel à projet avec date de décision fixe). + Cette estimation est calculée automatiquement à partir des délais d’instruction constatés sur #{ProcedureStatsConcern::USUAL_TRAITEMENT_TIME_PERCENTILE}% des demandes qui ont été traitées lors des #{ProcedureStatsConcern::NB_DAYS_RECENT_DOSSIERS} derniers jours. Le délai réel peut être différent, en fonction du type de démarche (par exemple pour un appel à projet avec date de décision fixe). diff --git a/lib/core_ext/array.rb b/lib/core_ext/array.rb index 00aa9f59c..39d7ad777 100644 --- a/lib/core_ext/array.rb +++ b/lib/core_ext/array.rb @@ -17,7 +17,7 @@ class Array values = self.sort if values.empty? - return [] + return 0 elsif values.size == 1 return values.first elsif p == 100 diff --git a/spec/models/concern/procedure_stats_concern_spec.rb b/spec/models/concern/procedure_stats_concern_spec.rb new file mode 100644 index 000000000..050989259 --- /dev/null +++ b/spec/models/concern/procedure_stats_concern_spec.rb @@ -0,0 +1,94 @@ +describe ProcedureStatsConcern do + describe '#usual_traitement_time_for_recent_dossiers' do + let(:procedure) { create(:procedure) } + + before do + Timecop.freeze(Time.utc(2019, 6, 1, 12, 0)) + + delays.each do |delay| + create_dossier(construction_date: 1.week.ago - delay, instruction_date: 1.week.ago - delay + 12.hours, processed_date: 1.week.ago) + end + end + + after { Timecop.return } + + context 'when there are several processed dossiers' do + let(:delays) { [1.day, 2.days, 2.days, 2.days, 2.days, 3.days, 3.days, 3.days, 3.days, 12.days] } + + it 'returns a time representative of the dossier instruction delay' do + expect(procedure.usual_traitement_time_for_recent_dossiers(30)).to be_between(3.days, 4.days) + end + end + + context 'when there are very old dossiers' do + let(:delays) { [2.days, 2.days] } + let!(:old_dossier) { create_dossier(construction_date: 3.months.ago, instruction_date: 2.months.ago, processed_date: 2.months.ago) } + + it 'ignores dossiers older than 1 month' do + expect(procedure.usual_traitement_time_for_recent_dossiers(30)).to be_within(1.hour).of(2.days) + end + end + + context 'when there is a dossier with bad data' do + let(:delays) { [2.days, 2.days] } + let!(:bad_dossier) { create_dossier(construction_date: nil, instruction_date: nil, processed_date: 10.days.ago) } + + it 'ignores bad dossiers' do + expect(procedure.usual_traitement_time_for_recent_dossiers(30)).to be_within(1.hour).of(2.days) + end + end + + context 'when there is only one processed dossier' do + let(:delays) { [1.day] } + it { expect(procedure.usual_traitement_time_for_recent_dossiers(30)).to be_within(1.hour).of(1.day) } + end + + context 'where there is no processed dossier' do + let(:delays) { [] } + it { expect(procedure.usual_traitement_time_for_recent_dossiers(30)).to eq nil } + end + end + + describe '.usual_traitement_time_by_month_in_days' do + let(:procedure) { create(:procedure) } + + def create_dossiers(delays_by_month) + delays_by_month.each_with_index do |delays, index| + delays.each do |delay| + processed_date = (index.months + 1.week).ago + create_dossier(construction_date: processed_date - delay, instruction_date: processed_date - delay + 12.hours, processed_date: processed_date) + end + end + end + + before do + Timecop.freeze(Time.utc(2019, 6, 25, 12, 0)) + + create_dossiers(delays_by_month) + end + + after { Timecop.return } + + context 'when there are several processed dossiers' do + let(:delays_by_month) { + [ + [90.days, 90.days], + [1.day, 2.days, 2.days, 2.days, 2.days, 3.days, 3.days, 3.days, 3.days, 12.days], + [30.days, 60.days, 60.days, 60.days] + ] +} + + it 'computes a time representative of the dossier instruction delay for each month except current month' do + expect(procedure.usual_traitement_time_by_month_in_days['avril 2019']).to eq 60 + expect(procedure.usual_traitement_time_by_month_in_days['mai 2019']).to eq 4 + expect(procedure.usual_traitement_time_by_month_in_days['juin 2019']).to eq nil + end + end + end + + private + + def create_dossier(construction_date:, instruction_date:, processed_date:) + dossier = create(:dossier, :accepte, procedure: procedure, en_construction_at: construction_date, en_instruction_at: instruction_date, processed_at: processed_date) + end +end diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 1f7327fdb..9850686d4 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -980,60 +980,6 @@ describe Procedure do end end - describe '#usual_traitement_time' do - let(:procedure) { create(:procedure) } - - def create_dossier(construction_date:, instruction_date:, processed_date:) - dossier = create(:dossier, :accepte, procedure: procedure, en_construction_at: construction_date, en_instruction_at: instruction_date, processed_at: processed_date) - end - - before do - Timecop.freeze(Time.utc(2019, 6, 1, 12, 0)) - - delays.each do |delay| - create_dossier(construction_date: 1.week.ago - delay, instruction_date: 1.week.ago - delay + 12.hours, processed_date: 1.week.ago) - end - end - - after { Timecop.return } - - context 'when there are several processed dossiers' do - let(:delays) { [1.day, 2.days, 2.days, 2.days, 2.days, 3.days, 3.days, 3.days, 3.days, 12.days] } - - it 'returns a time representative of the dossier instruction delay' do - expect(procedure.usual_traitement_time).to be_between(3.days, 4.days) - end - end - - context 'when there are very old dossiers' do - let(:delays) { [2.days, 2.days] } - let!(:old_dossier) { create_dossier(construction_date: 3.months.ago, instruction_date: 2.months.ago, processed_date: 2.months.ago) } - - it 'ignores dossiers older than 1 month' do - expect(procedure.usual_traitement_time).to be_within(1.hour).of(2.days) - end - end - - context 'when there is a dossier with bad data' do - let(:delays) { [2.days, 2.days] } - let!(:bad_dossier) { create_dossier(construction_date: nil, instruction_date: nil, processed_date: 10.days.ago) } - - it 'ignores bad dossiers' do - expect(procedure.usual_traitement_time).to be_within(1.hour).of(2.days) - end - end - - context 'when there is only one processed dossier' do - let(:delays) { [1.day] } - it { expect(procedure.usual_traitement_time).to be_within(1.hour).of(1.day) } - end - - context 'where there is no processed dossier' do - let(:delays) { [] } - it { expect(procedure.usual_traitement_time).to be_nil } - end - end - describe '.ensure_a_groupe_instructeur_exists' do let!(:procedure) { create(:procedure) } @@ -1082,12 +1028,12 @@ describe Procedure do it 'estimates average dossier weight' do expect(procedure.reload.average_dossier_weight).to eq 5 end + end - private + private - def create_dossier_with_pj_of_size(size, procedure) - dossier = create(:dossier, :accepte, procedure: procedure) - create(:champ_piece_justificative, size: size, dossier: dossier) - end + def create_dossier_with_pj_of_size(size, procedure) + dossier = create(:dossier, :accepte, procedure: procedure) + create(:champ_piece_justificative, size: size, dossier: dossier) end end diff --git a/spec/views/users/dossiers/show/_status_overview.html.haml_spec.rb b/spec/views/users/dossiers/show/_status_overview.html.haml_spec.rb index 71362ea7d..5f95bd3b7 100644 --- a/spec/views/users/dossiers/show/_status_overview.html.haml_spec.rb +++ b/spec/views/users/dossiers/show/_status_overview.html.haml_spec.rb @@ -1,5 +1,5 @@ describe 'users/dossiers/show/_status_overview.html.haml', type: :view do - before { allow(dossier.procedure).to receive(:usual_traitement_time).and_return(1.day) } + before { allow(dossier.procedure).to receive(:usual_traitement_time_for_recent_dossiers).and_return(1.day) } subject! { render 'users/dossiers/show/status_overview.html.haml', dossier: dossier }