Files
pointeuse-optimisator/app/export.py
Jacquin Antoine c208a17eec Décompte les heures d'astreinte par tranches de 15 min dans des compteurs pondérés séparés
La saisie astreinte devient une liste d'événements (début, fin, description,
nombre illimité, fin antérieure au début = intervention à cheval sur minuit).
Ces heures ne comptent jamais dans la semaine : elles sont décomptées à part
par pas de 15 minutes, toute tranche entamée comptant en totalité, et rangées
dans des compteurs selon le jour et l'heure (Nuit ×1,5, Journée semaine ×1,
Samedi ×1,5, Dimanche/férié ×2 ; jours fériés français calculés
automatiquement, règles ajustables dans config.json via astreinte_facteurs).
Le détail par événement et les totaux pondérés s'affichent sous le bandeau de
la semaine, et les exports .ods gagnent les colonnes Astreinte brute et
pondérée. Le retour au bureau (fin du dernier événement + 11h de repos) est
désormais dérivé des événements.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-09-08 22:26:47 +02:00

198 lines
7.5 KiB
Python

"""Export des pointages au format ODS (tableur), par semaine ou par année."""
import io
from datetime import datetime, timedelta
from odf.opendocument import OpenDocumentSpreadsheet
from odf.style import Style, TableColumnProperties, TextProperties
from odf.table import Table, TableColumn, TableRow, TableCell
from odf.text import P
from calcul import compute_week, minutes_to_hhmm
from models import (
load_week_pointages, load_conges, load_astreintes, list_all_weeks,
load_plages, heures_jour_min,
)
JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]
HEADERS = [
"Date", "Jour", "Matin entrée", "Matin sortie", "Aprem entrée", "Aprem sortie",
"Travaillé", "Dû", "Δ jour", "Congé", "Astreinte", "Astreinte pondérée", "Retour",
]
def _week_dates(year: int, week: int) -> list[str]:
first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date()
return [(first + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(5)]
def _new_doc():
doc = OpenDocumentSpreadsheet()
bold = Style(name="Bold", family="table-cell")
bold.addElement(TextProperties(fontweight="bold"))
doc.styles.addElement(bold)
wide_col = Style(name="WideCol", family="table-column")
wide_col.addElement(TableColumnProperties(columnwidth="2.4cm"))
doc.styles.addElement(wide_col)
return doc, bold, wide_col
def _cell(text, style=None) -> TableCell:
c = TableCell(stylename=style) if style else TableCell()
c.addElement(P(text="" if text is None else str(text)))
return c
def _header_row(bold: Style) -> TableRow:
row = TableRow()
for h in HEADERS:
row.addElement(_cell(h, bold))
return row
def _congé_label(cg: set) -> str:
if "jour" in cg:
return "Journée"
parts = []
if "matin" in cg:
parts.append("Matin")
if "aprem" in cg:
parts.append("Après-midi")
return " + ".join(parts)
def _astreinte_label(jour: dict) -> str:
if jour.get("astreinte_min"):
return jour["astreinte_h"]
return "Astreinte" if jour.get("astreinte") else ""
def _jour_row(jour: dict, nom: str) -> TableRow:
row = TableRow()
for v in (
jour["date"], nom,
jour.get("matin_entree"), jour.get("matin_sortie"),
jour.get("aprem_entree"), jour.get("aprem_sortie"),
jour["travaille"], jour["du"],
("+" if jour["delta_min"] > 0 else "") + jour["delta"],
_congé_label(jour["conge"]),
_astreinte_label(jour),
minutes_to_hhmm(jour["astreinte_pondere_min"]) if jour["astreinte_pondere_min"] > 0 else "",
jour.get("retour"),
):
row.addElement(_cell(v))
return row
def _totals_row(bold: Style, travaille_min: int, du_min: int,
as_min: int = 0, as_pondere_min: int = 0) -> TableRow:
row = TableRow()
for v in ("Total", "", "", "", "", "",
minutes_to_hhmm(travaille_min), minutes_to_hhmm(du_min),
("+" if travaille_min - du_min > 0 else "") + minutes_to_hhmm(travaille_min - du_min),
"", minutes_to_hhmm(as_min), minutes_to_hhmm(as_pondere_min), ""):
row.addElement(_cell(v, bold))
return row
def _size_columns(table: Table, wide_col: Style, n: int = len(HEADERS)):
for _ in range(n):
table.addElement(TableColumn(stylename=wide_col))
def export_semaine_ods(year: int, week: int, user_id: str) -> bytes:
"""Build an .ods workbook with the daily detail for a single week."""
doc, bold, wide_col = _new_doc()
h_jour = heures_jour_min()
plages = load_plages(user_id)
conges = load_conges(user_id)
astreintes = load_astreintes(user_id)
dates = _week_dates(year, week)
raw = load_week_pointages(year, week, user_id)
p_by_date = {p["date"]: p for p in raw}
pointages = [
p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None,
"aprem_entree": None, "aprem_sortie": None})
for d in dates
]
result = compute_week(pointages, conges, h_jour, plages, astreintes)
table = Table(name=f"Semaine {week:02d}-{year}")
_size_columns(table, wide_col)
table.addElement(_header_row(bold))
for jour, nom in zip(result["jours"], JOURS_FR):
table.addElement(_jour_row(jour, nom))
table.addElement(_totals_row(bold, sum(j["travaille_min"] for j in result["jours"]),
sum(j["du_min"] for j in result["jours"]),
sum(j["astreinte_min"] for j in result["jours"]),
sum(j["astreinte_pondere_min"] for j in result["jours"])))
doc.spreadsheet.addElement(table)
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
def export_annee_ods(year: int, user_id: str) -> bytes:
"""Build an .ods workbook with one sheet per week of the year, plus a yearly total."""
doc, bold, wide_col = _new_doc()
h_jour = heures_jour_min()
plages = load_plages(user_id)
conges = load_conges(user_id)
astreintes = load_astreintes(user_id)
weeks = sorted(wk for yr, wk in list_all_weeks(user_id) if yr == year)
par_semaine = [] # (week, result, travaille_min, du_min)
for week in weeks:
dates = _week_dates(year, week)
raw = load_week_pointages(year, week, user_id)
p_by_date = {p["date"]: p for p in raw}
pointages = [
p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None,
"aprem_entree": None, "aprem_sortie": None})
for d in dates
]
result = compute_week(pointages, conges, h_jour, plages, astreintes)
travaille_min = sum(j["travaille_min"] for j in result["jours"])
du_min = sum(j["du_min"] for j in result["jours"])
par_semaine.append((week, result, travaille_min, du_min))
year_travaille = sum(t for _, _, t, _ in par_semaine)
year_du = sum(d for _, _, _, d in par_semaine)
recap = Table(name="Récapitulatif")
_size_columns(recap, wide_col, n=4)
header = TableRow()
for h in ("Semaine", "Travaillé", "Dû", "Δ"):
header.addElement(_cell(h, bold))
recap.addElement(header)
for week, _, travaille_min, du_min in par_semaine:
row = TableRow()
for v in (f"S{week:02d}", minutes_to_hhmm(travaille_min), minutes_to_hhmm(du_min),
("+" if travaille_min - du_min > 0 else "") + minutes_to_hhmm(travaille_min - du_min)):
row.addElement(_cell(v))
recap.addElement(row)
total_row = TableRow()
for v in ("Total", minutes_to_hhmm(year_travaille), minutes_to_hhmm(year_du),
("+" if year_travaille - year_du > 0 else "") + minutes_to_hhmm(year_travaille - year_du)):
total_row.addElement(_cell(v, bold))
recap.addElement(total_row)
# Le récapitulatif en première feuille pour une vue d'ensemble immédiate à l'ouverture.
doc.spreadsheet.addElement(recap)
for week, result, travaille_min, du_min in par_semaine:
table = Table(name=f"S{week:02d}")
_size_columns(table, wide_col)
table.addElement(_header_row(bold))
for jour, nom in zip(result["jours"], JOURS_FR):
table.addElement(_jour_row(jour, nom))
table.addElement(_totals_row(bold, travaille_min, du_min,
sum(j["astreinte_min"] for j in result["jours"]),
sum(j["astreinte_pondere_min"] for j in result["jours"])))
doc.spreadsheet.addElement(table)
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()