6 Commits

5 changed files with 196 additions and 3 deletions

View File

@@ -25,6 +25,30 @@ 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] = {}

View File

@@ -1,14 +1,21 @@
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 from app.business.time_calc import count_day_types, monthly_stats, minutes_to_str
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():
@@ -23,6 +30,7 @@ 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:
@@ -42,6 +50,32 @@ 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,
@@ -50,4 +84,5 @@ 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,
) )

View File

@@ -78,4 +78,72 @@
{% 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 %}

View File

@@ -91,3 +91,11 @@ 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

View File

@@ -43,8 +43,9 @@ 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 from app.models import WorkEntry, TimeSlot
from datetime import date from app.business.time_calc import monthly_stats
from datetime import date, time as dtime
def test_count_day_types_basic(): def test_count_day_types_basic():
@@ -62,6 +63,63 @@ 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"),