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:
commit
6650dcd844
11 changed files with 187 additions and 85 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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 D’ACCEPTATION
|
%span.stat-card-title TAUX D’ACCEPTATION
|
||||||
.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"]
|
|
||||||
|
|
|
@ -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 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).
|
||||||
|
|
|
@ -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
|
||||||
|
|
94
spec/models/concern/procedure_stats_concern_spec.rb
Normal file
94
spec/models/concern/procedure_stats_concern_spec.rb
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue