Compare commits
7 Commits
increase-r
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e2f158c09 | |||
| 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
|
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,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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user