1 Préparation

1.1 Objectif & sources

Ce document analyse les finances de la Ville de villeurbanne à 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 un périmètre : 1. Budget principal (BP)

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)

1.2 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_villeurbanne       <- readRDS(file.path(out_dir, "df_villeurbanne.rds"))
df_villeurbanne_conso <- readRDS(file.path(out_dir, "df_villeurbanne_conso.rds"))
glimpse(df_villeurbanne_conso)
## Rows: 392
## Columns: 28
## $ Exercice                                <int> 2017, 2017, 2020, 2020, 2020, …
## $ `Tranche revenu par habitant`           <fct> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, …
## $ `Code Insee 2024 Commune`               <fct> 69266, 69266, 69266, 69266, 69…
## $ `Nom 2024 Commune`                      <fct> Villeurbanne, Villeurbanne, Vi…
## $ Catégorie                               <fct> Commune, Commune, Commune, Com…
## $ `Code Siren Collectivité`               <fct> 216902668, 216902668, 21690266…
## $ `Code Insee Collectivité`               <fct> 69266, 69266, 69266, 69266, 69…
## $ `Libellé Budget`                        <fct> VILLEURBANNE, VILLEURBANNE, VI…
## $ Agrégat                                 <fct> Achats et charges externes, Au…
## $ `Montant BP`                            <dbl> 21624748.37, 581503.52, 240467…
## $ `Montant BA`                            <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, …
## $ `Montant flux BP-BA`                    <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, …
## $ Montant                                 <dbl> 21624748.37, 581503.52, 240467…
## $ `Montant en millions`                   <dbl> 21.62474837, 0.58150352, 24.04…
## $ `Population totale`                     <dbl> 150075, 150075, 148754, 148754…
## $ `Montant en € par habitant`             <dbl> 144.0929427, 3.8747528, 161.65…
## $ 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> 157953, 157953, 157953, 157953…

1.3 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"
)

2 Budget principal

df_bp <- make_view(df_villeurbanne_conso, "BP")

2.1 Indicateurs de gestion

plots <- plot_indicateurs_gestion(df_bp, "BP — ")
plots$p1; plots$p2; plots$p3; plots$p4; plots$p5; plots$p6; plots$p7

2.2 Dépenses d’investissement

res <- table_total_detail(df_bp, inv_dep_total, inv_dep_detail)
kable(res$table, digits = 1)
poste 2017 2018 2019 2020 2021 2022 2023 2024
TOTAL — Dépenses d’investissement 38.9 47.1 58.9 38.9 53.6 61.1 84.5 88.2
Dépenses d’investissement hors remb 36.0 43.9 55.3 34.9 50.4 56.7 78.6 80.3
Remboursements d’emprunts hors GAD 2.8 3.2 3.5 4.0 3.2 4.4 5.9 7.9
Reste / non ventilé 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
kable(res$check, digits = 3)
Exercice total detail_sum reste ecart
2017 38.854 38.854 0 0
2018 47.115 47.115 0 0
2019 58.871 58.871 0 0
2020 38.867 38.867 0 0
2021 53.635 53.635 0 0
2022 61.076 61.076 0 0
2023 84.513 84.513 0 0
2024 88.230 88.230 0 0

2.3 Recettes d’investissement

res <- table_total_detail(df_bp, inv_rec_total, inv_rec_detail)
kable(res$table, digits = 1)
poste 2017 2018 2019 2020 2021 2022 2023 2024
TOTAL — Recettes d’investissement 19.2 22.9 30.5 21.8 26.1 24.2 34.8 62.8
Emprunts hors GAD 11.1 11.8 9.9 0.0 13.0 10.0 25.5 31.1
Recettes d’investissement hors emprunts 8.1 11.1 20.6 21.8 13.1 14.2 9.3 31.7
Reste / non ventilé 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
kable(res$check, digits = 3)
Exercice total detail_sum reste ecart
2017 19.161 19.161 0 0
2018 22.927 22.927 0 0
2019 30.507 30.507 0 0
2020 21.787 21.787 0 0
2021 26.121 26.121 0 0
2022 24.212 24.212 0 0
2023 34.798 34.798 0 0
2024 62.850 62.850 0 0

2.4 Dépenses de fonctionnement

Note : “Autres dotations de fonctionnement” est laissé hors ventilation “dépenses de fonctionnement” par défaut (lecture SIG).

res <- table_total_detail(df_bp, fct_dep_total, fct_dep_detail)
kable(res$table, digits = 1)
poste 2017 2018 2019 2020 2021 2022 2023 2024
TOTAL — Dépenses de fonctionnement 119.9 120.8 121.4 122.3 131.6 146.0 149.7 159.9
Achats et charges externes 21.6 21.0 22.4 19.8 24.8 32.6 27.5 34.2
Autres dépenses de fonctionnement 1.7 3.8 1.7 1.9 2.1 2.4 2.6 3.0
Charges financières 0.1 0.2 0.2 0.2 0.2 0.3 1.0 2.4
Dépenses d’intervention 22.5 22.4 22.3 22.9 22.6 22.2 26.1 23.4
Frais de personnel 73.9 73.4 74.8 77.4 81.9 88.4 92.6 96.9
Reste / non ventilé 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
kable(res$check, digits = 3)
Exercice total detail_sum reste ecart
2017 119.857 119.857 0 0
2018 120.765 120.765 0 0
2019 121.357 121.357 0 0
2020 122.279 122.279 0 0
2021 131.568 131.568 0 0
2022 145.996 145.996 0 0
2023 149.743 149.743 0 0
2024 159.947 159.947 0 0

2.5 Recettes de fonctionnement

res <- table_total_detail(df_bp, fct_rec_total, fct_rec_detail)
kable(res$table, digits = 1)
poste 2017 2018 2019 2020 2021 2022 2023 2024
TOTAL — Recettes de fonctionnement 139.1 146.9 151.8 151.4 163.1 173.6 181.3 195.6
Autres recettes de fonctionnement 1.0 1.0 0.7 0.7 1.4 1.8 1.2 1.5
Concours de l’Etat 23.2 23.1 23.9 24.0 20.7 21.9 21.8 22.4
Impôts et taxes 96.5 101.9 106.9 108.7 122.3 126.9 136.7 144.3
Subventions reçues et participations 8.8 8.7 8.3 8.8 7.3 10.8 8.0 11.1
Ventes de biens et services 9.7 12.2 12.0 9.1 11.4 12.1 13.6 16.3
Reste / non ventilé 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
kable(res$check, digits = 3)
Exercice total detail_sum reste ecart
2017 139.104 139.104 0 0
2018 146.937 146.937 0 0
2019 151.816 151.816 0 0
2020 151.350 151.350 0 0
2021 163.077 163.077 0 0
2022 173.551 173.551 0 0
2023 181.304 181.304 0 0
2024 195.632 195.632 0 0