Compare commits
2 Commits
master
...
increase-r
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c9bf2f882 | |||
| 3b396086b8 |
21
README.md
21
README.md
@@ -74,27 +74,6 @@ Toute la configuration métier se trouve dans `config.toml` :
|
|||||||
.venv/bin/python -m pytest
|
.venv/bin/python -m pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Import bulk depuis CSV
|
|
||||||
|
|
||||||
Un script est disponible pour importer des entrées en masse depuis un fichier CSV :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Format du CSV : date,day_type,journey_profile_id,motor_vehicle_id,start_time,end_time,comment
|
|
||||||
# Exemple :
|
|
||||||
# 2025-06-02,WORK,moteur_seul,familiale,09:00;14:00,17:45;12:00,Travail normal
|
|
||||||
# 2025-06-03,TT,,,09:00,17:45,Télétravail
|
|
||||||
|
|
||||||
.venv/bin/python scripts/import_csv.py mon_fichier.csv
|
|
||||||
|
|
||||||
# Avec une config personnalisée
|
|
||||||
.venv/bin/python scripts/import_csv.py mon_fichier.csv --config /chemin/vers/config.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
**Comportement :**
|
|
||||||
- En cas de conflit sur une date, les données existantes sont conservées et un avertissement est affiché
|
|
||||||
- Les plages horaires multiples peuvent être séparées par des points-virgules (`;`)
|
|
||||||
- Types de jour valides : WORK, TT, GARDE, ASTREINTE, FORMATION, RTT, CONGE, MALADE, FERIE
|
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
[MIT](LICENSE.md) — Copyright (c) 2026 Antoine Van-Elstraete
|
[MIT](LICENSE.md) — Copyright (c) 2026 Antoine Van-Elstraete
|
||||||
|
|||||||
@@ -25,30 +25,6 @@ def week_balance_minutes(actual_minutes: int, reference_minutes: int) -> int:
|
|||||||
return actual_minutes - reference_minutes
|
return actual_minutes - reference_minutes
|
||||||
|
|
||||||
|
|
||||||
import statistics as _stats
|
|
||||||
|
|
||||||
|
|
||||||
def monthly_stats(entries: list) -> dict:
|
|
||||||
"""
|
|
||||||
Calcule médiane journalière et médiane hebdomadaire (semaines ISO)
|
|
||||||
pour un groupe d'entrées. Les absences (total_minutes=0) sont incluses.
|
|
||||||
"""
|
|
||||||
if not entries:
|
|
||||||
return {"median_daily_min": 0, "median_weekly_min": 0}
|
|
||||||
|
|
||||||
daily = [e.total_minutes() for e in entries]
|
|
||||||
median_daily = int(_stats.median(daily))
|
|
||||||
|
|
||||||
weekly: dict[tuple, int] = {}
|
|
||||||
for e in entries:
|
|
||||||
key = e.date.isocalendar()[:2] # (year, isoweek)
|
|
||||||
weekly[key] = weekly.get(key, 0) + e.total_minutes()
|
|
||||||
|
|
||||||
median_weekly = int(_stats.median(weekly.values()))
|
|
||||||
|
|
||||||
return {"median_daily_min": median_daily, "median_weekly_min": median_weekly}
|
|
||||||
|
|
||||||
|
|
||||||
def count_day_types(entries: list) -> dict[str, int]:
|
def count_day_types(entries: list) -> dict[str, int]:
|
||||||
"""Retourne un dict {day_type: count} pour une liste d'entrées, sans les zéros."""
|
"""Retourne un dict {day_type: count} pour une liste d'entrées, sans les zéros."""
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
from flask import Blueprint, render_template, request
|
from flask import Blueprint, render_template, request
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from collections import defaultdict
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from app import db
|
from app import db
|
||||||
from app.models import WorkEntry
|
from app.models import WorkEntry
|
||||||
from app.business.travel_calc import compute_km_for_entry, compute_co2_grams, compute_frais_reels
|
from app.business.travel_calc import compute_km_for_entry, compute_co2_grams, compute_frais_reels
|
||||||
from app.business.time_calc import count_day_types, monthly_stats, minutes_to_str
|
from app.business.time_calc import count_day_types
|
||||||
from app.config_loader import get_vehicles, get_journeys, get_bareme
|
from app.config_loader import get_vehicles, get_journeys, get_bareme
|
||||||
|
|
||||||
bp = Blueprint("reports", __name__, url_prefix="/reports")
|
bp = Blueprint("reports", __name__, url_prefix="/reports")
|
||||||
|
|
||||||
MONTHS_FR = {
|
|
||||||
1: "Janvier", 2: "Février", 3: "Mars", 4: "Avril",
|
|
||||||
5: "Mai", 6: "Juin", 7: "Juillet", 8: "Août",
|
|
||||||
9: "Septembre", 10: "Octobre", 11: "Novembre", 12: "Décembre",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/")
|
@bp.route("/")
|
||||||
def index():
|
def index():
|
||||||
@@ -30,7 +23,6 @@ def index():
|
|||||||
vehicles = get_vehicles()
|
vehicles = get_vehicles()
|
||||||
journeys = get_journeys()
|
journeys = get_journeys()
|
||||||
|
|
||||||
# --- Stats annuelles (inchangées) ---
|
|
||||||
total_km = {}
|
total_km = {}
|
||||||
total_co2 = 0.0
|
total_co2 = 0.0
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
@@ -50,32 +42,6 @@ def index():
|
|||||||
|
|
||||||
day_type_counts = count_day_types(entries)
|
day_type_counts = count_day_types(entries)
|
||||||
|
|
||||||
# --- Stats mensuelles ---
|
|
||||||
entries_by_month: dict[int, list] = defaultdict(list)
|
|
||||||
for entry in entries:
|
|
||||||
entries_by_month[entry.date.month].append(entry)
|
|
||||||
|
|
||||||
monthly_data = {}
|
|
||||||
for month in range(1, 13):
|
|
||||||
month_entries = entries_by_month.get(month, [])
|
|
||||||
|
|
||||||
month_km: dict[str, int] = {}
|
|
||||||
for entry in month_entries:
|
|
||||||
km = compute_km_for_entry(entry.journey_profile_id, journeys, entry.motor_vehicle_id)
|
|
||||||
for v, d in km.items():
|
|
||||||
month_km[v] = month_km.get(v, 0) + d
|
|
||||||
|
|
||||||
stats = monthly_stats(month_entries)
|
|
||||||
|
|
||||||
monthly_data[month] = {
|
|
||||||
"month_name": MONTHS_FR[month],
|
|
||||||
"entry_count": len(month_entries),
|
|
||||||
"km_by_vehicle": month_km,
|
|
||||||
"km_total": sum(month_km.values()),
|
|
||||||
"median_daily_str": minutes_to_str(stats["median_daily_min"]) if month_entries else "–",
|
|
||||||
"median_weekly_str": minutes_to_str(stats["median_weekly_min"]) if month_entries else "–",
|
|
||||||
}
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"reports.html",
|
"reports.html",
|
||||||
year=year,
|
year=year,
|
||||||
@@ -84,5 +50,4 @@ def index():
|
|||||||
frais_reels=frais_reels,
|
frais_reels=frais_reels,
|
||||||
vehicles=vehicles,
|
vehicles=vehicles,
|
||||||
day_type_counts=day_type_counts,
|
day_type_counts=day_type_counts,
|
||||||
monthly_data=monthly_data,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -78,72 +78,4 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Détail mensuel -->
|
|
||||||
<p class="font-display text-lg font-semibold mt-8 mb-3" style="color:var(--ink);">Détail mensuel</p>
|
|
||||||
|
|
||||||
{% for month_num in range(1, 13) %}
|
|
||||||
{% set m = monthly_data[month_num] %}
|
|
||||||
<div class="card card-ink mb-4">
|
|
||||||
<div class="flex items-baseline justify-between mb-2">
|
|
||||||
<p class="card-label">{{ m.month_name }}</p>
|
|
||||||
<span class="text-xs" style="color:#9A9288;">{{ m.entry_count }} j</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if m.entry_count == 0 %}
|
|
||||||
<p class="text-sm" style="color:#9A9288;">Aucune entrée</p>
|
|
||||||
{% else %}
|
|
||||||
{# --- Barre transport --- #}
|
|
||||||
{% if m.km_total > 0 %}
|
|
||||||
<p class="font-data font-semibold mb-1" style="font-size:1.2rem; color:var(--ink);">
|
|
||||||
{{ m.km_total }}<span class="font-normal text-xs ml-1" style="color:#9A9288;">km</span>
|
|
||||||
</p>
|
|
||||||
<div class="flex rounded overflow-hidden mb-1" style="height:10px; background:#e5e2dc;">
|
|
||||||
{% for vehicle_id, km in m.km_by_vehicle.items() %}
|
|
||||||
{% set v_info = vehicles.get(vehicle_id, {}) %}
|
|
||||||
{% if v_info.get('type') == 'velo' %}
|
|
||||||
{% set color = '#4ade80' %}
|
|
||||||
{% elif v_info.get('fuel') == 'electric' %}
|
|
||||||
{% set color = '#818cf8' %}
|
|
||||||
{% else %}
|
|
||||||
{% set color = '#d4a574' %}
|
|
||||||
{% endif %}
|
|
||||||
{% set pct = (km / m.km_total * 100) | round(1) %}
|
|
||||||
<div style="width:{{ pct }}%; background-color:{{ color }};"></div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-x-3 mb-3">
|
|
||||||
{% for vehicle_id, km in m.km_by_vehicle.items() %}
|
|
||||||
{% set v_info = vehicles.get(vehicle_id, {}) %}
|
|
||||||
{% if v_info.get('type') == 'velo' %}
|
|
||||||
{% set color = '#4ade80' %}
|
|
||||||
{% elif v_info.get('fuel') == 'electric' %}
|
|
||||||
{% set color = '#818cf8' %}
|
|
||||||
{% else %}
|
|
||||||
{% set color = '#d4a574' %}
|
|
||||||
{% endif %}
|
|
||||||
<span class="text-xs" style="color:#8A8278;">
|
|
||||||
<span style="display:inline-block; width:8px; height:8px; border-radius:2px; background:{{ color }}; margin-right:3px;"></span>
|
|
||||||
{{ vehicles.get(vehicle_id, {}).get('name', vehicle_id) }} ({{ km }} km)
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-xs mb-3" style="color:#9A9288;">Aucun déplacement</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# --- Stats temporelles --- #}
|
|
||||||
<div class="flex gap-6">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs mb-0.5" style="color:#9A9288;">Médiane / jour</p>
|
|
||||||
<p class="font-data font-semibold" style="color:var(--ink);">{{ m.median_daily_str }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs mb-0.5" style="color:#9A9288;">Médiane / semaine</p>
|
|
||||||
<p class="font-data font-semibold" style="color:var(--ink);">{{ m.median_weekly_str }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
79
config.toml
79
config.toml
@@ -115,82 +115,3 @@ forfait = 1515
|
|||||||
km_max = 0
|
km_max = 0
|
||||||
taux = 0.470
|
taux = 0.470
|
||||||
forfait = 0
|
forfait = 0
|
||||||
|
|
||||||
# --- Barème kilométrique voitures 2026 (revenus 2025) ---
|
|
||||||
# Source : https://www.service-public.gouv.fr/particuliers/actualites/A14686
|
|
||||||
# Majoration +20% pour véhicules électriques gérée dans travel_calc.py
|
|
||||||
|
|
||||||
[[bareme_kilometrique.2026.cv_3.tranches]]
|
|
||||||
km_max = 5000
|
|
||||||
taux = 0.529
|
|
||||||
forfait = 0
|
|
||||||
|
|
||||||
[[bareme_kilometrique.2026.cv_3.tranches]]
|
|
||||||
km_max = 20000
|
|
||||||
taux = 0.316
|
|
||||||
forfait = 1065
|
|
||||||
|
|
||||||
[[bareme_kilometrique.2026.cv_3.tranches]]
|
|
||||||
km_max = 0
|
|
||||||
taux = 0.370
|
|
||||||
forfait = 0
|
|
||||||
|
|
||||||
[[bareme_kilometrique.2026.cv_4.tranches]]
|
|
||||||
km_max = 5000
|
|
||||||
taux = 0.606
|
|
||||||
forfait = 0
|
|
||||||
|
|
||||||
[[bareme_kilometrique.2026.cv_4.tranches]]
|
|
||||||
km_max = 20000
|
|
||||||
taux = 0.340
|
|
||||||
forfait = 1330
|
|
||||||
|
|
||||||
[[bareme_kilometrique.2026.cv_4.tranches]]
|
|
||||||
km_max = 0
|
|
||||||
taux = 0.407
|
|
||||||
forfait = 0
|
|
||||||
|
|
||||||
[[bareme_kilometrique.2026.cv_5.tranches]]
|
|
||||||
km_max = 5000
|
|
||||||
taux = 0.636
|
|
||||||
forfait = 0
|
|
||||||
|
|
||||||
[[bareme_kilometrique.2026.cv_5.tranches]]
|
|
||||||
km_max = 20000
|
|
||||||
taux = 0.357
|
|
||||||
forfait = 1395
|
|
||||||
|
|
||||||
[[bareme_kilometrique.2026.cv_5.tranches]]
|
|
||||||
km_max = 0
|
|
||||||
taux = 0.427
|
|
||||||
forfait = 0
|
|
||||||
|
|
||||||
[[bareme_kilometrique.2026.cv_6.tranches]]
|
|
||||||
km_max = 5000
|
|
||||||
taux = 0.665
|
|
||||||
forfait = 0
|
|
||||||
|
|
||||||
[[bareme_kilometrique.2026.cv_6.tranches]]
|
|
||||||
km_max = 20000
|
|
||||||
taux = 0.374
|
|
||||||
forfait = 1457
|
|
||||||
|
|
||||||
[[bareme_kilometrique.2026.cv_6.tranches]]
|
|
||||||
km_max = 0
|
|
||||||
taux = 0.447
|
|
||||||
forfait = 0
|
|
||||||
|
|
||||||
[[bareme_kilometrique.2026.cv_7plus.tranches]]
|
|
||||||
km_max = 5000
|
|
||||||
taux = 0.697
|
|
||||||
forfait = 0
|
|
||||||
|
|
||||||
[[bareme_kilometrique.2026.cv_7plus.tranches]]
|
|
||||||
km_max = 20000
|
|
||||||
taux = 0.394
|
|
||||||
forfait = 1515
|
|
||||||
|
|
||||||
[[bareme_kilometrique.2026.cv_7plus.tranches]]
|
|
||||||
km_max = 0
|
|
||||||
taux = 0.470
|
|
||||||
forfait = 0
|
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Script d'import CSV vers la base de données SQLite.
|
|
||||||
|
|
||||||
Format CSV attendu :
|
|
||||||
date,day_type,journey_profile_id,motor_vehicle_id,start_time,end_time,comment
|
|
||||||
|
|
||||||
Exemple :
|
|
||||||
2025-06-02,WORK,moteur_seul,familiale,09:00;14:00,17:45;12:00,Travail normal
|
|
||||||
2025-06-03,TT,,,09:00,17:45,Télétravail
|
|
||||||
|
|
||||||
Pour plusieurs plages horaires, séparer les heures par des points-virgules (;).
|
|
||||||
|
|
||||||
Types de jour valides : WORK, TT, GARDE, ASTREINTE, FORMATION, RTT, CONGE, MALADE, FERIE
|
|
||||||
|
|
||||||
En cas de conflit sur une date, les données existantes sont conservées et un avertissement est affiché.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import csv
|
|
||||||
import sys
|
|
||||||
from datetime import date, time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# Ajouter le dossier parent au path pour importer les modules
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from app import create_app, db
|
|
||||||
from app.models import WorkEntry, TimeSlot
|
|
||||||
from app.config_loader import day_types_without_journey, journey_has_motor
|
|
||||||
|
|
||||||
|
|
||||||
DAY_TYPES = {
|
|
||||||
"WORK", "TT", "GARDE", "ASTREINTE", "FORMATION", "RTT", "CONGE", "MALADE", "FERIE"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main(csv_path: str, config_path: str = None):
|
|
||||||
"""Importe les données depuis un fichier CSV vers la base de données."""
|
|
||||||
|
|
||||||
# Créer l'application Flask avec la config
|
|
||||||
app = create_app(config_path=config_path)
|
|
||||||
|
|
||||||
with app.app_context():
|
|
||||||
conflicts = []
|
|
||||||
imported_count = 0
|
|
||||||
|
|
||||||
# Lire le fichier CSV
|
|
||||||
with open(csv_path, "r", encoding="utf-8") as f:
|
|
||||||
csv_reader = csv.DictReader(f)
|
|
||||||
|
|
||||||
for row_num, row in enumerate(csv_reader, start=2):
|
|
||||||
try:
|
|
||||||
# Parser la date
|
|
||||||
entry_date = date.fromisoformat(row.get("date", "").strip())
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
conflicts.append(f"Ligne {row_num}: date invalide ou manquante")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Valider le type de jour
|
|
||||||
day_type = row.get("day_type", "WORK").strip().upper()
|
|
||||||
if day_type not in DAY_TYPES:
|
|
||||||
conflicts.append(f"Ligne {row_num}: type de jour invalide '{day_type}'")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Récupérer les autres champs
|
|
||||||
journey_profile_id = row.get("journey_profile_id", "").strip() or None
|
|
||||||
motor_vehicle_id = row.get("motor_vehicle_id", "").strip() or None
|
|
||||||
comment = row.get("comment", "").strip() or None
|
|
||||||
|
|
||||||
# Si le type de jour n'a pas de trajet, forcer journey_profile_id à None
|
|
||||||
if day_type in day_types_without_journey():
|
|
||||||
journey_profile_id = None
|
|
||||||
|
|
||||||
# Si le profil de trajet n'a pas de moteur, forcer motor_vehicle_id à None
|
|
||||||
if journey_profile_id and not journey_has_motor(journey_profile_id):
|
|
||||||
motor_vehicle_id = None
|
|
||||||
|
|
||||||
# Vérifier si une entrée existe déjà pour cette date
|
|
||||||
existing = db.session.scalar(
|
|
||||||
sa.select(WorkEntry).where(WorkEntry.date == entry_date)
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
# Vérifier si les données sont différentes
|
|
||||||
is_different = (
|
|
||||||
existing.day_type != day_type or
|
|
||||||
existing.journey_profile_id != journey_profile_id or
|
|
||||||
existing.motor_vehicle_id != motor_vehicle_id or
|
|
||||||
existing.comment != comment
|
|
||||||
)
|
|
||||||
if is_different:
|
|
||||||
conflicts.append(
|
|
||||||
f"Ligne {row_num}: conflit sur la date {entry_date}. "
|
|
||||||
f"Données existantes conservées."
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Créer la nouvelle entrée
|
|
||||||
entry = WorkEntry(
|
|
||||||
date=entry_date,
|
|
||||||
day_type=day_type,
|
|
||||||
journey_profile_id=journey_profile_id,
|
|
||||||
motor_vehicle_id=motor_vehicle_id,
|
|
||||||
comment=comment,
|
|
||||||
)
|
|
||||||
db.session.add(entry)
|
|
||||||
|
|
||||||
# Ajouter les plages horaires
|
|
||||||
start_times = row.get("start_time", "").split(";")
|
|
||||||
end_times = row.get("end_time", "").split(";")
|
|
||||||
|
|
||||||
for s, e in zip(start_times, end_times):
|
|
||||||
s = s.strip()
|
|
||||||
e = e.strip()
|
|
||||||
if s and e:
|
|
||||||
try:
|
|
||||||
db.session.add(TimeSlot(
|
|
||||||
entry=entry,
|
|
||||||
start_time=time.fromisoformat(s),
|
|
||||||
end_time=time.fromisoformat(e),
|
|
||||||
))
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
conflicts.append(f"Ligne {row_num}: format d'heure invalide '{s}' ou '{e}'")
|
|
||||||
db.session.rollback()
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
imported_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
db.session.rollback()
|
|
||||||
break
|
|
||||||
|
|
||||||
# Valider et commiter
|
|
||||||
try:
|
|
||||||
db.session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
print(f"Erreur lors du commit: {e}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Afficher les résultats
|
|
||||||
print(f"Import terminé: {imported_count} entrée(s) importée(s)")
|
|
||||||
|
|
||||||
if conflicts:
|
|
||||||
print(f"\n⚠️ {len(conflicts)} avertissement(s):")
|
|
||||||
for conflict in conflicts:
|
|
||||||
print(f" - {conflict}")
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Importer un fichier CSV dans la base de données")
|
|
||||||
parser.add_argument("csv_file", help="Chemin vers le fichier CSV à importer")
|
|
||||||
parser.add_argument("--config", default=None, help="Chemin vers le fichier config.toml (optionnel)")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
sys.exit(main(args.csv_file, args.config))
|
|
||||||
@@ -91,11 +91,3 @@ def test_create_entry_velo_no_motor_vehicle(client, app):
|
|||||||
)
|
)
|
||||||
assert entry is not None
|
assert entry is not None
|
||||||
assert entry.motor_vehicle_id is None
|
assert entry.motor_vehicle_id is None
|
||||||
|
|
||||||
|
|
||||||
def test_reports_monthly_section(client):
|
|
||||||
response = client.get("/reports/")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Détail mensuel" in response.text
|
|
||||||
assert "Janvier" in response.text
|
|
||||||
assert "Décembre" in response.text
|
|
||||||
|
|||||||
@@ -43,9 +43,8 @@ def test_week_balance_negative():
|
|||||||
|
|
||||||
|
|
||||||
from app.business.time_calc import count_day_types
|
from app.business.time_calc import count_day_types
|
||||||
from app.models import WorkEntry, TimeSlot
|
from app.models import WorkEntry
|
||||||
from app.business.time_calc import monthly_stats
|
from datetime import date
|
||||||
from datetime import date, time as dtime
|
|
||||||
|
|
||||||
|
|
||||||
def test_count_day_types_basic():
|
def test_count_day_types_basic():
|
||||||
@@ -63,63 +62,6 @@ def test_count_day_types_empty():
|
|||||||
assert count_day_types([]) == {}
|
assert count_day_types([]) == {}
|
||||||
|
|
||||||
|
|
||||||
def _entry(d: date, *slots: tuple[str, str]) -> WorkEntry:
|
|
||||||
"""Helper : crée un WorkEntry avec des TimeSlots."""
|
|
||||||
entry = WorkEntry(date=d, day_type="WORK")
|
|
||||||
entry.time_slots = [
|
|
||||||
TimeSlot(start_time=dtime(*[int(x) for x in s.split(":")]),
|
|
||||||
end_time=dtime(*[int(x) for x in e.split(":")]))
|
|
||||||
for s, e in slots
|
|
||||||
]
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
def _absence(d: date) -> WorkEntry:
|
|
||||||
"""Helper : crée un WorkEntry sans time_slots (0 min)."""
|
|
||||||
entry = WorkEntry(date=d, day_type="CONGE")
|
|
||||||
entry.time_slots = []
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
def test_monthly_stats_empty():
|
|
||||||
result = monthly_stats([])
|
|
||||||
assert result == {"median_daily_min": 0, "median_weekly_min": 0}
|
|
||||||
|
|
||||||
|
|
||||||
def test_monthly_stats_median_daily_odd():
|
|
||||||
# 420, 465, 510 → médiane = 465
|
|
||||||
entries = [
|
|
||||||
_entry(date(2025, 1, 6), ("9:00", "16:00")), # 420 min
|
|
||||||
_entry(date(2025, 1, 7), ("9:00", "16:45")), # 465 min
|
|
||||||
_entry(date(2025, 1, 8), ("9:00", "17:30")), # 510 min
|
|
||||||
]
|
|
||||||
result = monthly_stats(entries)
|
|
||||||
assert result["median_daily_min"] == 465
|
|
||||||
|
|
||||||
|
|
||||||
def test_monthly_stats_includes_absences():
|
|
||||||
# 0, 465 → médiane de 2 valeurs = (0+465)/2 = 232 (int)
|
|
||||||
entries = [
|
|
||||||
_absence(date(2025, 1, 6)),
|
|
||||||
_entry(date(2025, 1, 7), ("9:00", "16:45")),
|
|
||||||
]
|
|
||||||
result = monthly_stats(entries)
|
|
||||||
assert result["median_daily_min"] == 232
|
|
||||||
|
|
||||||
|
|
||||||
def test_monthly_stats_median_weekly():
|
|
||||||
# Semaine 1 : 465+465 = 930 min
|
|
||||||
# Semaine 2 : 420 min
|
|
||||||
# médiane([930, 420]) = (420+930)/2 = 675
|
|
||||||
entries = [
|
|
||||||
_entry(date(2025, 1, 6), ("9:00", "16:45")), # sem 2
|
|
||||||
_entry(date(2025, 1, 7), ("9:00", "16:45")), # sem 2
|
|
||||||
_entry(date(2025, 1, 13), ("9:00", "16:00")), # sem 3
|
|
||||||
]
|
|
||||||
result = monthly_stats(entries)
|
|
||||||
assert result["median_weekly_min"] == 675
|
|
||||||
|
|
||||||
|
|
||||||
def test_count_day_types_single_type():
|
def test_count_day_types_single_type():
|
||||||
entries = [
|
entries = [
|
||||||
WorkEntry(date=date(2025, 2, 1), day_type="RTT"),
|
WorkEntry(date=date(2025, 2, 1), day_type="RTT"),
|
||||||
|
|||||||
Reference in New Issue
Block a user