Merge pull request #6261 from betagouv/6149-temps-traitement-usager

6149 expose sous forme de graphe l'évolution du temps de traitement d'une procédure pour les usagers et les instructeurs
This commit is contained in:
krichtof 2021-06-17 17:03:37 +02:00 committed by GitHub
commit 6650dcd844
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 187 additions and 85 deletions

View file

@ -210,6 +210,7 @@ module Instructeurs
@dossiers_funnel = @procedure.stats_dossiers_funnel @dossiers_funnel = @procedure.stats_dossiers_funnel
@termines_states = @procedure.stats_termines_states @termines_states = @procedure.stats_termines_states
@termines_by_week = @procedure.stats_termines_by_week @termines_by_week = @procedure.stats_termines_by_week
@usual_traitement_time_by_month = @procedure.stats_usual_traitement_time_by_month_in_days
end end
private private

View file

@ -5,6 +5,7 @@ module Users
return procedure_not_found if @procedure.blank? || @procedure.brouillon? return procedure_not_found if @procedure.blank? || @procedure.brouillon?
@usual_traitement_time = @procedure.stats_usual_traitement_time @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 @dossiers_funnel = @procedure.stats_dossiers_funnel
@termines_states = @procedure.stats_termines_states @termines_states = @procedure.stats_termines_states
@termines_by_week = @procedure.stats_termines_by_week @termines_by_week = @procedure.stats_termines_by_week

View file

@ -1,9 +1,21 @@
module ProcedureStatsConcern module ProcedureStatsConcern
extend ActiveSupport::Concern 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 def stats_usual_traitement_time
Rails.cache.fetch("#{cache_key_with_version}/stats_usual_traitement_time", expires_in: 12.hours) do 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
end end
@ -45,4 +57,51 @@ module ProcedureStatsConcern
end end
end 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 end

View file

@ -565,19 +565,6 @@ class Procedure < ApplicationRecord
end end
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 def populate_champ_stable_ids
TypeDeChamp TypeDeChamp
.joins(:revisions) .joins(:revisions)

View file

@ -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 }) .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 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) def self.count_dossiers_termines_by_month(groupe_instructeurs)
last_traitements_per_dossier = Traitement last_traitements_per_dossier = Traitement
.select('max(traitements.processed_at) as processed_at') .select('max(traitements.processed_at) as processed_at')

View file

@ -11,15 +11,23 @@
%span.big-number-card-number %span.big-number-card-number
= distance_of_time_in_words(@usual_traitement_time) = distance_of_time_in_words(@usual_traitement_time)
%span.big-number-card-detail %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-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 .stat-card.stat-card-half.pull-left
%span.stat-card-title AVANCÉE DES DOSSIERS %span.stat-card-title AVANCÉE DES DOSSIERS
.chart-container .chart-container
.chart .chart
= area_chart @dossiers_funnel = area_chart @dossiers_funnel
.stat-cards
.stat-card.stat-card-half.pull-left .stat-card.stat-card-half.pull-left
%span.stat-card-title TAUX DACCEPTATION %span.stat-card-title TAUX DACCEPTATION
.chart-container .chart-container
@ -27,9 +35,8 @@
- colors = %w(#C3D9FF #0069CC #1C7EC9) # from _colors.scss - colors = %w(#C3D9FF #0069CC #1C7EC9) # from _colors.scss
= pie_chart @termines_states, colors: colors = pie_chart @termines_states, colors: colors
.stat-cards .stat-card.stat-card-half.pull-left
.stat-card.stat-card-half.pull-left %span.stat-card-title RÉPARTITION PAR SEMAINE
%span.stat-card-title RÉPARTITION PAR SEMAINE .chart-container
.chart-container .chart
.chart = line_chart @termines_by_week, colors: ["#387EC3", "#AE2C2B", "#FAD859"]
= line_chart @termines_by_week, colors: ["#387EC3", "#AE2C2B", "#FAD859"]

View file

@ -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 - 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 - 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 %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 %p
Cette estimation est calculée automatiquement à partir des délais dinstruction 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 dinstruction 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).

View file

@ -17,7 +17,7 @@ class Array
values = self.sort values = self.sort
if values.empty? if values.empty?
return [] return 0
elsif values.size == 1 elsif values.size == 1
return values.first return values.first
elsif p == 100 elsif p == 100

View file

@ -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

View file

@ -980,60 +980,6 @@ describe Procedure do
end end
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 describe '.ensure_a_groupe_instructeur_exists' do
let!(:procedure) { create(:procedure) } let!(:procedure) { create(:procedure) }
@ -1082,12 +1028,12 @@ describe Procedure do
it 'estimates average dossier weight' do it 'estimates average dossier weight' do
expect(procedure.reload.average_dossier_weight).to eq 5 expect(procedure.reload.average_dossier_weight).to eq 5
end end
end
private private
def create_dossier_with_pj_of_size(size, procedure) def create_dossier_with_pj_of_size(size, procedure)
dossier = create(:dossier, :accepte, procedure: procedure) dossier = create(:dossier, :accepte, procedure: procedure)
create(:champ_piece_justificative, size: size, dossier: dossier) create(:champ_piece_justificative, size: size, dossier: dossier)
end
end end
end end

View file

@ -1,5 +1,5 @@
describe 'users/dossiers/show/_status_overview.html.haml', type: :view do 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 } subject! { render 'users/dossiers/show/status_overview.html.haml', dossier: dossier }