Compare commits
6 Commits
increase-r
...
monthly-st
| Author | SHA1 | Date | |
|---|---|---|---|
| cc664b3555 | |||
| 59d64885d4 | |||
| 6d5e58273f | |||
|
d768156e0d
|
|||
|
e781c06593
|
|||
| d97565f1a1 |
@@ -25,6 +25,30 @@ def week_balance_minutes(actual_minutes: int, reference_minutes: int) -> int:
|
||||
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]:
|
||||
"""Retourne un dict {day_type: count} pour une liste d'entrées, sans les zéros."""
|
||||
counts: dict[str, int] = {}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
from flask import Blueprint, render_template, request
|
||||
from datetime import date
|
||||
from collections import defaultdict
|
||||
import sqlalchemy as sa
|
||||
from app import db
|
||||
from app.models import WorkEntry
|
||||
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
|
||||
|
||||
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("/")
|
||||
def index():
|
||||
@@ -23,6 +30,7 @@ def index():
|
||||
vehicles = get_vehicles()
|
||||
journeys = get_journeys()
|
||||
|
||||
# --- Stats annuelles (inchangées) ---
|
||||
total_km = {}
|
||||
total_co2 = 0.0
|
||||
for entry in entries:
|
||||
@@ -42,6 +50,32 @@ def index():
|
||||
|
||||
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(
|
||||
"reports.html",
|
||||
year=year,
|
||||
@@ -50,4 +84,5 @@ def index():
|
||||
frais_reels=frais_reels,
|
||||
vehicles=vehicles,
|
||||
day_type_counts=day_type_counts,
|
||||
monthly_data=monthly_data,
|
||||
)
|
||||
|
||||
@@ -78,4 +78,72 @@
|
||||
{% endif %}
|
||||
</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 %}
|
||||
|
||||
@@ -91,3 +91,11 @@ def test_create_entry_velo_no_motor_vehicle(client, app):
|
||||
)
|
||||
assert entry is not 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,8 +43,9 @@ def test_week_balance_negative():
|
||||
|
||||
|
||||
from app.business.time_calc import count_day_types
|
||||
from app.models import WorkEntry
|
||||
from datetime import date
|
||||
from app.models import WorkEntry, TimeSlot
|
||||
from app.business.time_calc import monthly_stats
|
||||
from datetime import date, time as dtime
|
||||
|
||||
|
||||
def test_count_day_types_basic():
|
||||
@@ -62,6 +63,63 @@ def test_count_day_types_empty():
|
||||
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():
|
||||
entries = [
|
||||
WorkEntry(date=date(2025, 2, 1), day_type="RTT"),
|
||||
|
||||
Reference in New Issue
Block a user