Préparation
Objectif &
sources
Ce document analyse les finances de la Ville de Lyon à partir des
bases OFGL :
- Commune :
ofgl-base-communes (lecture
“budgétaire brute”)
- Consolidée :
ofgl-base-communes-consolidee (lecture “économique nette”,
neutralisation des flux internes BP–BA)
L’analyse est structurée en trois périmètres : 1.
Budget principal (BP) 2. Budgets annexes
(BA) 3. Budget consolidé (CONSO)
Pour chaque périmètre, on déroule : - Indicateurs de
gestion (CAF/épargne, rigidité, investissement, dette…) -
Investissement : dépenses & recettes (tables avec
total + détail + contrôle de somme) - Fonctionnement :
dépenses & recettes (tables avec total + détail + contrôle de
somme)
Chargement des bases
(RDS préparés en amont)
# WD = dossier du Rmd (si exécution sous RStudio)
if (requireNamespace("rstudioapi", quietly = TRUE) &&
rstudioapi::isAvailable() &&
nzchar(rstudioapi::getActiveDocumentContext()$path)) {
setwd(dirname(rstudioapi::getActiveDocumentContext()$path))
}
out_dir <- "output"
df_lyon <- readRDS(file.path(out_dir, "df_lyon.rds"))
df_lyon_conso <- readRDS(file.path(out_dir, "df_lyon_conso.rds"))
glimpse(df_lyon_conso)
## Rows: 392
## Columns: 28
## $ Exercice <int> 2017, 2017, 2020, 2020, 2020, …
## $ `Tranche revenu par habitant` <fct> 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, …
## $ `Code Insee 2024 Commune` <fct> 69123, 69123, 69123, 69123, 69…
## $ `Nom 2024 Commune` <fct> Lyon, Lyon, Lyon, Lyon, Lyon, …
## $ Catégorie <fct> Commune, Commune, Commune, Com…
## $ `Code Siren Collectivité` <fct> 216901231, 216901231, 21690123…
## $ `Code Insee Collectivité` <fct> 69123, 69123, 69123, 69123, 69…
## $ `Libellé Budget` <fct> LYON, LYON, LYON, LYON, LYON, …
## $ Agrégat <fct> Achats et charges externes, Au…
## $ `Montant BP` <dbl> 101212133.0, 3913073.0, 853856…
## $ `Montant BA` <dbl> 14191325.03, 0.00, 0.00, 28014…
## $ `Montant flux BP-BA` <dbl> 0.00, 0.00, 0.00, 16182484.52,…
## $ Montant <dbl> 115403458.0, 3913073.0, 853856…
## $ `Montant en millions` <dbl> 115.4034580, 3.9130730, 85.385…
## $ `Population totale` <dbl> 514707, 514707, 522679, 522679…
## $ `Montant en € par habitant` <dbl> 224.211946, 7.602525, 163.3616…
## $ ordre_analyse1_section1 <lgl> NA, NA, NA, NA, NA, NA, NA, NA…
## $ ordre_analyse1_section2 <chr> NA, NA, NA, "4", NA, NA, "5", …
## $ ordre_analyse1_section3 <chr> NA, NA, NA, NA, NA, NA, NA, NA…
## $ ordre_analyse2_section1 <chr> NA, NA, "3", NA, NA, NA, NA, N…
## $ ordre_analyse2_section2 <chr> "3", NA, NA, "1", NA, "4", NA,…
## $ ordre_analyse2_section3 <chr> NA, NA, NA, "2", NA, NA, NA, N…
## $ ordre_analyse3_section1 <lgl> NA, NA, NA, NA, NA, NA, NA, NA…
## $ ordre_analyse3_section2 <chr> NA, NA, NA, NA, "2", NA, "1", …
## $ ordre_analyse3_section3 <chr> NA, NA, NA, NA, NA, NA, "1", N…
## $ ordre_analyse4_section1 <chr> NA, NA, NA, NA, NA, NA, NA, "2…
## $ annee_join <int> 2017, 2017, 2020, 2020, 2020, …
## $ `Population totale du dernier exercice` <dbl> 528550, 528550, 528550, 528550…
Préparation commune
aux 3 périmètres (BP / BA / CONSO)
# Fabrique une "vue" homogène avec une seule colonne montant, selon périmètre
make_view <- function(df_conso, per = c("BP","BA","CONSO")) {
per <- match.arg(per)
df_conso %>%
mutate(
Exercice = as.integer(Exercice),
montant = dplyr::case_when(
per == "BP" ~ as.numeric(`Montant BP`),
per == "BA" ~ as.numeric(`Montant BA`),
per == "CONSO" ~ as.numeric(Montant),
TRUE ~ NA_real_
)
) %>%
select(Exercice, Agrégat, montant)
}
# Séries annuelles
series <- function(df, ag) {
df %>%
filter(Agrégat == ag) %>%
group_by(Exercice) %>%
summarise(val = sum(montant, na.rm = TRUE), .groups="drop")
}
series_multi <- function(df, ag_vec) {
df %>%
filter(Agrégat %in% ag_vec) %>%
group_by(Exercice, Agrégat) %>%
summarise(val = sum(montant, na.rm = TRUE), .groups="drop")
}
# Table "total + détail" avec contrôle de somme
table_total_detail <- function(df, total_ag, detail_ag, unit = 1e6) {
tot <- series(df, total_ag) %>% rename(total = val)
det <- series_multi(df, detail_ag) %>%
tidyr::pivot_wider(names_from = Agrégat, values_from = val, values_fill = 0)
tab <- tot %>%
left_join(det, by="Exercice")
detail_cols <- setdiff(names(tab), c("Exercice","total"))
tab <- tab %>%
rowwise() %>%
mutate(
detail_sum = sum(c_across(all_of(detail_cols)), na.rm = TRUE),
reste = total - detail_sum,
ecart = total - (detail_sum + reste)
) %>%
ungroup()
long <- tab %>%
select(Exercice, total, all_of(detail_cols), reste) %>%
pivot_longer(cols = -Exercice, names_to = "poste", values_to = "val") %>%
mutate(val = val/unit)
wide <- long %>%
pivot_wider(names_from = Exercice, values_from = val) %>%
mutate(poste = factor(poste, levels = c("total", detail_cols, "reste"))) %>%
arrange(poste) %>%
mutate(poste = as.character(poste)) %>%
mutate(poste = recode(poste, total = paste0("TOTAL — ", total_ag), reste = "Reste / non ventilé"))
list(
table = wide,
check = tab %>%
select(Exercice, total, detail_sum, reste, ecart) %>%
mutate(across(c(total, detail_sum, reste), ~./unit))
)
}
# Graphes "qualité de gestion" (pack minimal, sans facettes)
plot_indicateurs_gestion <- function(df, titre_prefix = "") {
# CAF / Epargne
epargne_ag <- c("Epargne de gestion", "Epargne brute", "Epargne nette")
epargne <- series_multi(df, epargne_ag) %>% mutate(m = val/1e6)
p1 <- ggplot(epargne, aes(x=Exercice, y=m, color=Agrégat)) +
geom_hline(yintercept=0, linetype=2) +
geom_line(linewidth=1) + geom_point() +
labs(title=paste0(titre_prefix, "CAF / Épargne (M€)"), x="Exercice", y="M€") +
theme_minimal()
# Taux d’épargne brute = EB / RF
rf <- series(df, "Recettes de fonctionnement") %>% rename(rf = val)
eb <- series(df, "Epargne brute") %>% rename(eb = val)
taux <- rf %>% left_join(eb, by="Exercice") %>%
mutate(taux_eb = ifelse(rf==0, NA_real_, eb/rf))
p2 <- ggplot(taux, aes(x=Exercice, y=taux_eb)) +
geom_hline(yintercept=0, linetype=2) +
geom_line(linewidth=1) + geom_point() +
scale_y_continuous(labels=percent) +
labs(title=paste0(titre_prefix, "Taux d’épargne brute (EB/RF)"), x="Exercice", y="%") +
theme_minimal()
# Rigidité = personnel / dépenses de fonctionnement
dfct <- series(df, "Dépenses de fonctionnement") %>% rename(dfct = val)
pers <- series(df, "Frais de personnel") %>% rename(pers = val)
rigid <- dfct %>% left_join(pers, by="Exercice") %>%
mutate(part = ifelse(dfct==0, NA_real_, pers/dfct))
p3 <- ggplot(rigid, aes(x=Exercice, y=part)) +
geom_line(linewidth=1) + geom_point() +
scale_y_continuous(labels=percent) +
labs(title=paste0(titre_prefix, "Rigidité (Personnel/DF)"), x="Exercice", y="%") +
theme_minimal()
# Investissement : niveau
inv <- series(df, "Dépenses d'investissement") %>% mutate(m = val/1e6)
p4 <- ggplot(inv, aes(x=Exercice, y=m)) +
geom_line(linewidth=1) + geom_point() +
labs(title=paste0(titre_prefix, "Dépenses d’investissement (M€)"), x="Exercice", y="M€") +
theme_minimal()
# Dette & désendettement
dette <- series(df, "Encours de dette") %>% rename(dette = val)
eb2 <- series(df, "Epargne brute") %>% rename(eb = val)
des <- dette %>% left_join(eb2, by="Exercice") %>%
mutate(dette_m = dette/1e6,
capacite = ifelse(eb<=0, NA_real_, dette/eb))
p5 <- ggplot(des, aes(x=Exercice, y=dette_m)) +
geom_line(linewidth=1) + geom_point() +
labs(title=paste0(titre_prefix, "Encours de dette (M€)"), x="Exercice", y="M€") +
theme_minimal()
p6 <- ggplot(des, aes(x=Exercice, y=capacite)) +
geom_hline(yintercept=0, linetype=2) +
geom_line(linewidth=1) + geom_point() +
labs(title=paste0(titre_prefix, "Capacité de désendettement (dette/EB)"), x="Exercice", y="années") +
theme_minimal()
# Solde global
solde <- series(df, "Capacité ou besoin de financement") %>% mutate(m = val/1e6)
p7 <- ggplot(solde, aes(x=Exercice, y=m)) +
geom_hline(yintercept=0, linetype=2) +
geom_line(linewidth=1) + geom_point() +
labs(title=paste0(titre_prefix, "Capacité / besoin de financement (M€)"), x="Exercice", y="M€") +
theme_minimal()
list(p1=p1, p2=p2, p3=p3, p4=p4, p5=p5, p6=p6, p7=p7)
}
# ---- Décomposition OFGL (une seule fois) ----
# Fonctionnement
fct_dep_total <- "Dépenses de fonctionnement"
fct_dep_detail <- c(
"Frais de personnel",
"Achats et charges externes",
"Dépenses d'intervention",
"Charges financières",
"Autres dépenses de fonctionnement"
# "Autres dotations de fonctionnement" : volontairement hors DF (SIG)
)
fct_rec_total <- "Recettes de fonctionnement"
fct_rec_detail <- c(
"Concours de l'Etat",
"Impôts et taxes",
"Subventions reçues et participations",
"Ventes de biens et services",
"Autres recettes de fonctionnement"
)
# Investissement
inv_dep_total <- "Dépenses d'investissement"
inv_dep_detail <- c(
"Dépenses d'investissement hors remb",
"Remboursements d'emprunts hors GAD"
)
inv_rec_total <- "Recettes d'investissement"
inv_rec_detail <- c(
"Recettes d'investissement hors emprunts",
"Emprunts hors GAD"
)
Budgets annexes
df_ba <- make_view(df_lyon_conso, "BA")
Dépenses
d’investissement
res <- table_total_detail(df_ba, inv_dep_total, inv_dep_detail)
kable(res$table, digits = 1)
| TOTAL — Dépenses d’investissement |
0.7 |
1.6 |
2.2 |
2.6 |
1.2 |
1.0 |
1.5 |
2.1 |
| Dépenses d’investissement hors remb |
0.5 |
1.4 |
2.0 |
2.3 |
1.0 |
0.9 |
1.3 |
2.0 |
| Remboursements d’emprunts hors GAD |
0.3 |
0.2 |
0.2 |
0.2 |
0.2 |
0.2 |
0.1 |
0.1 |
| Reste / non ventilé |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
kable(res$check, digits = 3)
| 2017 |
0.749 |
0.749 |
0 |
0 |
| 2018 |
1.613 |
1.613 |
0 |
0 |
| 2019 |
2.207 |
2.207 |
0 |
0 |
| 2020 |
2.575 |
2.575 |
0 |
0 |
| 2021 |
1.226 |
1.226 |
0 |
0 |
| 2022 |
1.032 |
1.032 |
0 |
0 |
| 2023 |
1.460 |
1.460 |
0 |
0 |
| 2024 |
2.087 |
2.087 |
0 |
0 |
Recettes
d’investissement
res <- table_total_detail(df_ba, inv_rec_total, inv_rec_detail)
kable(res$table, digits = 1)
| TOTAL — Recettes d’investissement |
0.4 |
0.8 |
1.3 |
1.8 |
0.6 |
0.6 |
0.8 |
1.2 |
| Emprunts hors GAD |
0.2 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
| Recettes d’investissement hors emprunts |
0.2 |
0.8 |
1.3 |
1.8 |
0.6 |
0.6 |
0.8 |
1.2 |
| Reste / non ventilé |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
kable(res$check, digits = 3)
| 2017 |
0.361 |
0.361 |
0 |
0 |
| 2018 |
0.806 |
0.806 |
0 |
0 |
| 2019 |
1.333 |
1.333 |
0 |
0 |
| 2020 |
1.831 |
1.831 |
0 |
0 |
| 2021 |
0.647 |
0.647 |
0 |
0 |
| 2022 |
0.580 |
0.580 |
0 |
0 |
| 2023 |
0.803 |
0.803 |
0 |
0 |
| 2024 |
1.240 |
1.240 |
0 |
0 |
Dépenses de
fonctionnement
res <- table_total_detail(df_ba, fct_dep_total, fct_dep_detail)
kable(res$table, digits = 1)
| TOTAL — Dépenses de fonctionnement |
30.6 |
30.6 |
32.1 |
28.0 |
28.9 |
32.6 |
33.2 |
35.7 |
| Achats et charges externes |
14.2 |
14.1 |
15.2 |
11.0 |
11.9 |
14.8 |
15.2 |
16.9 |
| Autres dépenses de fonctionnement |
1.0 |
0.7 |
0.9 |
2.2 |
1.9 |
0.9 |
0.9 |
1.0 |
| Charges financières |
0.1 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
| Dépenses d’intervention |
0.1 |
0.1 |
0.1 |
0.1 |
0.1 |
0.1 |
0.1 |
0.1 |
| Frais de personnel |
15.3 |
15.6 |
15.9 |
14.8 |
15.0 |
16.7 |
17.1 |
17.8 |
| Reste / non ventilé |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
kable(res$check, digits = 3)
| 2017 |
30.620 |
30.620 |
0 |
0 |
| 2018 |
30.570 |
30.570 |
0 |
0 |
| 2019 |
32.147 |
32.147 |
0 |
0 |
| 2020 |
28.014 |
28.014 |
0 |
0 |
| 2021 |
28.877 |
28.877 |
0 |
0 |
| 2022 |
32.570 |
32.570 |
0 |
0 |
| 2023 |
33.186 |
33.186 |
0 |
0 |
| 2024 |
35.739 |
35.739 |
0 |
0 |
Recettes de
fonctionnement
res <- table_total_detail(df_ba, fct_rec_total, fct_rec_detail)
kable(res$table, digits = 1)
| TOTAL — Recettes de fonctionnement |
31.2 |
31.9 |
32.7 |
28.9 |
30.9 |
32.0 |
35.5 |
35.4 |
| Autres recettes de fonctionnement |
15.8 |
15.9 |
16.0 |
16.8 |
17.2 |
15.8 |
16.9 |
17.9 |
| Concours de l’Etat |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
| Impôts et taxes |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
| Subventions reçues et participations |
7.6 |
7.6 |
7.7 |
7.7 |
8.3 |
8.3 |
10.1 |
9.1 |
| Ventes de biens et services |
7.9 |
8.5 |
9.0 |
4.4 |
5.5 |
7.9 |
8.5 |
8.4 |
| Reste / non ventilé |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
0.0 |
kable(res$check, digits = 3)
| 2017 |
31.248 |
31.248 |
0 |
0 |
| 2018 |
31.939 |
31.939 |
0 |
0 |
| 2019 |
32.724 |
32.724 |
0 |
0 |
| 2020 |
28.856 |
28.856 |
0 |
0 |
| 2021 |
30.948 |
30.948 |
0 |
0 |
| 2022 |
31.981 |
31.981 |
0 |
0 |
| 2023 |
35.486 |
35.486 |
0 |
0 |
| 2024 |
35.419 |
35.419 |
0 |
0 |