""" Rapport journalier des interventions encodées par les membres d'une Company. Génère un PDF et un Excel contenant : - Un tableau de synthèse par thématique (lignes) avec colonnes : Avarie | Défaut/Panne | Demande | StreetUp | PREGO | Terminées | Autres | Total - Un tableau par personne (nombre d'interventions créées) """ import io import os import zipfile from collections import defaultdict from datetime import datetime, timedelta from django.conf import settings from django.contrib.auth.decorators import login_required from django.http import HttpResponse, Http404 from django.utils import timezone from django.utils.translation import gettext as _ from fpdf import FPDF from openpyxl import Workbook from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from openpyxl.utils import get_column_letter from contracts.models import CompanyMember, CompanyTeam from common.models import Thematic from interventions.models import Intervention, STATUS_ORDERS # --------------------------------------------------------------------------- # Helpers : collecte des données # --------------------------------------------------------------------------- def _get_authorized_companies_for_user(user): """ Retourne la liste des Company distinctes dont l'utilisateur est membre via une équipe ayant can_generate_daily_report=True. Un utilisateur peut appartenir à plusieurs équipes ; toutes sont vérifiées. """ members = ( CompanyMember.objects .filter(user=user, teams__can_generate_daily_report=True) .prefetch_related('teams__company') ) seen = {} for m in members: for t in m.teams.all(): if t.can_generate_daily_report: c = t.company seen[c.id] = c return list(seen.values()) def _collect_report_data(companies, report_date): """ Collecte les données du rapport journalier pour une liste de Company. La fenêtre temporelle couvre de 20h00 (j-1) à 20h00 (j), heure de Bruxelles. Retourne un dict : { 'by_thematic': {thematic_name: {col_key: count, ...}, ...}, 'by_person': {person_name: count, ...}, 'totals': {col_key: count, ...}, # ligne totale 'total_all': int, } """ import pytz brussels = pytz.timezone('Europe/Brussels') # Fenêtre : 20h00 j-1 → 20h00 j (heure de Bruxelles) prev_day = report_date - timedelta(days=1) period_start = brussels.localize(datetime(prev_day.year, prev_day.month, prev_day.day, 20, 0, 0)) period_end = brussels.localize(datetime(report_date.year, report_date.month, report_date.day, 20, 0, 0)) company_ids = [c.id for c in companies] # Récupérer tous les utilisateurs membres des Companies concernées company_members = CompanyMember.objects.filter( teams__company_id__in=company_ids ).select_related('user').prefetch_related('teams__company') member_user_ids = list(company_members.values_list('user_id', flat=True)) # En cas de doublons (même user dans plusieurs équipes), on garde le premier nom trouvé member_name_map = {} for m in company_members: if m.user_id not in member_name_map: member_name_map[m.user_id] = m.name # Interventions créées dans la fenêtre par des membres des Companies # Uniquement les thématiques avec include_in_daily_report=True ; annulées exclues interventions = ( Intervention.objects .filter( created_by_id__in=member_user_ids, creation_time__gte=period_start, creation_time__lt=period_end, thematic__include_in_daily_report=True, ) .exclude(status__in=['in_preparation','canceled']) .select_related('thematic', 'source_category') ) # Colonnes COL_KEYS = [ 'damage', 'defect', 'demand', 'observation', 'prego', 'finished', 'other_status', 'total', ] by_thematic = defaultdict(lambda: {k: 0 for k in COL_KEYS}) by_person = defaultdict(int) totals = {k: 0 for k in COL_KEYS} # Pré-peupler avec toutes les thématiques marquées "inclure dans rapport journalier" for thematic in Thematic.objects.filter(include_in_daily_report=True).order_by('name_fr'): _ = by_thematic[thematic.name_fr] # initialise la ligne à zéros for itv in interventions: thematic_name = itv.thematic.name_fr if itv.thematic else "Sans thématique" row = by_thematic[thematic_name] # --- Origine --- origin = itv.origin_type or '' if origin == 'damage': row['damage'] += 1 totals['damage'] += 1 elif origin == 'defect': row['defect'] += 1 totals['defect'] += 1 elif origin == 'demand': row['demand'] += 1 totals['demand'] += 1 # --- Source category --- sc = itv.source_category if sc: sc_name = (sc.name_fr or '').strip().lower() if sc_name == 'observation': row['observation'] += 1 totals['observation'] += 1 elif sc_name == 'prego': row['prego'] += 1 totals['prego'] += 1 # --- Statut --- if itv.status_order >= STATUS_ORDERS['finished']: row['finished'] += 1 totals['finished'] += 1 else: row['other_status'] += 1 totals['other_status'] += 1 # --- Total --- row['total'] += 1 totals['total'] += 1 # --- Par personne --- person_name = member_name_map.get(itv.created_by_id, str(itv.created_by)) by_person[person_name] += 1 return { 'by_thematic': dict(by_thematic), 'by_person': dict(by_person), 'totals': totals, 'total_all': totals['total'], } # --------------------------------------------------------------------------- # Colonnes du tableau de synthèse (labels) # --------------------------------------------------------------------------- COLUMN_HEADERS = [ ('damage', 'Avarie'), ('defect', 'Défaut/Panne'), ('demand', 'Demande'), ('observation', 'StreetUp'), ('prego', 'Issu de Prego'), ('finished', 'Résolu'), ('other_status', 'En cours'), ('total', 'Total'), ] # Couleurs spéciales (R, G, B) pour l'en-tête et la ligne totale de certaines colonnes # Les colonnes absentes de ce dict héritent de la couleur par défaut. COLUMN_SPECIAL_COLORS_RGB = { 'prego': (255, 102, 0), # Orange 'finished': (112, 173, 71), # Vert 'other_status': ( 0, 176, 240), # Bleu ciel } # Version hex pour Excel (AARRGGBB sans alpha, format RRGGBB) COLUMN_SPECIAL_COLORS_HEX = { 'prego': 'FF6600', 'finished': '70AD47', 'other_status': '00B0F0', } # --------------------------------------------------------------------------- # PDF generation # --------------------------------------------------------------------------- class DailyReportPDF(FPDF): def __init__(self, company_name, report_date, *args, **kwargs): super().__init__(orientation='L', *args, **kwargs) self.company_name = company_name self.report_date = report_date # ---- header / footer ---- def header(self): font_dir = os.path.join(settings.BASE_DIR, "common", "static", "common") self.add_font("arial", style="", fname=os.path.join(font_dir, "arial.ttf")) self.add_font("arial", style="B", fname=os.path.join(font_dir, "arial_bold.ttf")) self.add_font("arial", style="I", fname=os.path.join(font_dir, "arial_italic.ttf")) logo_path = os.path.join(settings.BASE_DIR, "common", "static", settings.ORGANIZATION_LOGO_PNG) if os.path.exists(logo_path): self.image(logo_path, x=10, y=8, w=50) self.set_font("arial", "B", 14) self.set_y(10) self.cell(0, 8, _("Rapport journalier des interventions"), align="C", new_x="LMARGIN", new_y="NEXT") self.set_font("arial", "", 11) self.cell(0, 7, f"{self.company_name} — {self.report_date.strftime('%d/%m/%Y')}", align="C", new_x="LMARGIN", new_y="NEXT") self.ln(4) def footer(self): self.set_y(-15) self.set_font("arial", "I", 8) self.cell(0, 10, f"Page {self.page_no()}/{{nb}}", align="C") # ---- section title ---- def _section_title(self, text): self.set_font("arial", "B", 11) self.set_fill_color(*settings.ORGANIZATION_PRIMARY_COLOR_RGB) self.set_text_color(255, 255, 255) self.cell(0, 8, f" {text}", fill=True, new_x="LMARGIN", new_y="NEXT") self.set_text_color(0, 0, 0) self.ln(2) # ---- table : synthèse par thématique ---- def add_thematic_table(self, data): self._section_title(_("Synthèse par thématique")) col_widths = [60] + [25] * len(COLUMN_HEADERS) # thématique + cols headers = [_("Thématique")] + [label for _lbl, label in COLUMN_HEADERS] # Header row — hauteur fixe pour toutes les cellules, texte centré verticalement HEADER_ROW_H = 14 # mm LINE_H = 4.5 # hauteur par ligne de texte self.set_font("arial", "B", 8) self.set_draw_color(0, 0, 0) x0 = self.get_x() y0 = self.get_y() cumulative_x = x0 for i, h in enumerate(headers): # Couleur de fond : spéciale si colonne concernée, sinon bleu clair if i >= 1: col_key = COLUMN_HEADERS[i - 1][0] r, g, b = COLUMN_SPECIAL_COLORS_RGB.get(col_key, (220, 230, 241)) else: r, g, b = 220, 230, 241 self.set_fill_color(r, g, b) # Rectangle plein + bordure à hauteur fixe self.rect(cumulative_x, y0, col_widths[i], HEADER_ROW_H, style='FD') # Centrage vertical du texte lines = str(h).split('\n') text_block_h = len(lines) * LINE_H text_y = y0 + (HEADER_ROW_H - text_block_h) / 2 for k, line in enumerate(lines): self.set_xy(cumulative_x, text_y + k * LINE_H) self.cell(col_widths[i], LINE_H, line, align='C') cumulative_x += col_widths[i] self.set_xy(x0, y0 + HEADER_ROW_H) # Data rows self.set_font("arial", "", 8) fill = False for thematic_name in sorted(data['by_thematic'].keys()): row = data['by_thematic'][thematic_name] if fill: self.set_fill_color(245, 245, 245) else: self.set_fill_color(255, 255, 255) self.cell(col_widths[0], 6, str(thematic_name), border=1, fill=True, align="L") for j, (key, _lbl) in enumerate(COLUMN_HEADERS): val = row.get(key, 0) self.cell(col_widths[j + 1], 6, str(val), border=1, fill=True, align="C") self.ln() fill = not fill # Totals row self.set_font("arial", "B", 8) self.set_text_color(255, 255, 255) self.set_fill_color(0, 91, 153) self.cell(col_widths[0], 7, _("TOTAL"), border=1, fill=True, align="L") for j, (key, _lbl) in enumerate(COLUMN_HEADERS): r, g, b = COLUMN_SPECIAL_COLORS_RGB.get(key, (0, 91, 153)) self.set_fill_color(r, g, b) val = data['totals'].get(key, 0) self.cell(col_widths[j + 1], 7, str(val), border=1, fill=True, align="C") self.ln() self.set_text_color(0, 0, 0) self.ln(4) # ---- table : par personne ---- def add_person_table(self, data): self._section_title(_("Nombre d'interventions par personne")) col_widths = [100, 40] headers = [_("Personne"), _("Nombre")] self.set_font("arial", "B", 9) self.set_fill_color(220, 230, 241) for i, h in enumerate(headers): self.cell(col_widths[i], 7, str(h), border=1, fill=True, align="C") self.ln() self.set_font("arial", "", 9) fill = False total = 0 for person_name in sorted(data['by_person'].keys()): count = data['by_person'][person_name] total += count if fill: self.set_fill_color(245, 245, 245) else: self.set_fill_color(255, 255, 255) self.cell(col_widths[0], 6, str(person_name), border=1, fill=True, align="L") self.cell(col_widths[1], 6, str(count), border=1, fill=True, align="C") self.ln() fill = not fill # Total self.set_font("arial", "B", 9) self.set_fill_color(0, 91, 153) self.set_text_color(255, 255, 255) self.cell(col_widths[0], 7, _("TOTAL"), border=1, fill=True, align="L") self.cell(col_widths[1], 7, str(total), border=1, fill=True, align="C") self.ln() self.set_text_color(0, 0, 0) def generate_daily_report_pdf(company_name, report_date, data): """Génère le PDF et retourne les bytes.""" pdf = DailyReportPDF(company_name, report_date) pdf.alias_nb_pages() pdf.add_page() pdf.add_thematic_table(data) pdf.add_person_table(data) buffer = io.BytesIO() pdf.output(buffer) buffer.seek(0) return buffer.read() # --------------------------------------------------------------------------- # Excel generation # --------------------------------------------------------------------------- def generate_daily_report_excel(company_name, report_date, data): """Génère le fichier Excel et retourne les bytes.""" wb = Workbook() # --- Styles --- header_font = Font(bold=True, color="FFFFFF", size=10) header_fill = PatternFill(start_color="005B99", end_color="005B99", fill_type="solid") header_alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) total_font = Font(bold=True, color="FFFFFF", size=10) total_fill = PatternFill(start_color="005B99", end_color="005B99", fill_type="solid") thin_border = Border( left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'), ) alt_fill = PatternFill(start_color="F5F5F5", end_color="F5F5F5", fill_type="solid") # Couleurs spéciales pour certaines colonnes (en-têtes et totaux) col_special_fills = { key: PatternFill(start_color=hex_color, end_color=hex_color, fill_type="solid") for key, hex_color in COLUMN_SPECIAL_COLORS_HEX.items() } # =========== Feuille 1 : Synthèse par thématique =========== ws1 = wb.active ws1.title = _("Synthèse par thématique") # Title row ws1.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(COLUMN_HEADERS) + 1) title_cell = ws1.cell(row=1, column=1, value=f"{_('Rapport journalier')} — {company_name} — {report_date.strftime('%d/%m/%Y')}") title_cell.font = Font(bold=True, size=14) title_cell.alignment = Alignment(horizontal="center") # Headers row_num = 3 col_headers = [_("Thématique")] + [label for _lbl, label in COLUMN_HEADERS] for col_idx, header_text in enumerate(col_headers, 1): cell = ws1.cell(row=row_num, column=col_idx, value=str(header_text)) cell.font = header_font if col_idx >= 2: cell.fill = col_special_fills.get(COLUMN_HEADERS[col_idx - 2][0], header_fill) else: cell.fill = header_fill cell.alignment = header_alignment cell.border = thin_border # Data row_num = 4 for idx, thematic_name in enumerate(sorted(data['by_thematic'].keys())): row_data = data['by_thematic'][thematic_name] cell = ws1.cell(row=row_num, column=1, value=str(thematic_name)) cell.border = thin_border if idx % 2: cell.fill = alt_fill for col_idx, (key, _lbl) in enumerate(COLUMN_HEADERS, 2): cell = ws1.cell(row=row_num, column=col_idx, value=row_data.get(key, 0)) cell.alignment = Alignment(horizontal="center") cell.border = thin_border if idx % 2: cell.fill = alt_fill row_num += 1 # Total row (formules SUM pour recalcul automatique) data_first_row = 4 cell = ws1.cell(row=row_num, column=1, value=_("TOTAL")) cell.font = total_font cell.fill = total_fill cell.border = thin_border for col_idx, (key, _lbl) in enumerate(COLUMN_HEADERS, 2): col_letter = get_column_letter(col_idx) cell = ws1.cell(row=row_num, column=col_idx, value=f"=SUM({col_letter}{data_first_row}:{col_letter}{row_num - 1})") cell.font = total_font cell.fill = col_special_fills.get(key, total_fill) cell.alignment = Alignment(horizontal="center") cell.border = thin_border # Auto-adjust column widths for col in range(1, len(col_headers) + 1): ws1.column_dimensions[chr(64 + col) if col <= 26 else 'A'].width = 18 if col == 1 else 14 # =========== Feuille 2 : Par personne =========== ws2 = wb.create_sheet(title=_("Par personne")) ws2.merge_cells(start_row=1, start_column=1, end_row=1, end_column=2) title_cell = ws2.cell(row=1, column=1, value=f"{_('Rapport journalier')} — {company_name} — {report_date.strftime('%d/%m/%Y')}") title_cell.font = Font(bold=True, size=14) title_cell.alignment = Alignment(horizontal="center") row_num = 3 for col_idx, header_text in enumerate([_("Personne"), _("Nombre")], 1): cell = ws2.cell(row=row_num, column=col_idx, value=str(header_text)) cell.font = header_font cell.fill = header_fill cell.alignment = header_alignment cell.border = thin_border row_num = 4 for idx, person_name in enumerate(sorted(data['by_person'].keys())): count = data['by_person'][person_name] cell = ws2.cell(row=row_num, column=1, value=str(person_name)) cell.border = thin_border if idx % 2: cell.fill = alt_fill cell = ws2.cell(row=row_num, column=2, value=count) cell.alignment = Alignment(horizontal="center") cell.border = thin_border if idx % 2: cell.fill = alt_fill row_num += 1 # Total (formule SUM pour recalcul automatique) cell = ws2.cell(row=row_num, column=1, value=_("TOTAL")) cell.font = total_font cell.fill = total_fill cell.border = thin_border cell = ws2.cell(row=row_num, column=2, value=f"=SUM(B4:B{row_num - 1})") cell.font = total_font cell.fill = total_fill cell.alignment = Alignment(horizontal="center") cell.border = thin_border ws2.column_dimensions['A'].width = 35 ws2.column_dimensions['B'].width = 15 # Output buffer = io.BytesIO() wb.save(buffer) return buffer.getvalue() # --------------------------------------------------------------------------- # Vue Django # --------------------------------------------------------------------------- @login_required def daily_report_download(request): """ Génère un ZIP contenant le PDF et l'Excel du rapport journalier. Accessible uniquement aux membres d'une CompanyTeam ayant can_generate_daily_report=True. Query params optionnels : - date : date au format YYYY-MM-DD (défaut = aujourd'hui) - format : 'pdf', 'excel' ou 'zip' (défaut = 'zip') """ # Vérifier que l'utilisateur appartient à au moins une équipe autorisée # (toutes ses équipes sont vérifiées, pas seulement la première) companies = _get_authorized_companies_for_user(request.user) if not companies: raise Http404(_("Aucune de vos équipes n'est autorisée à générer ce rapport.")) # Date du rapport date_str = request.GET.get('date') if date_str: try: report_date = datetime.strptime(date_str, '%Y-%m-%d').date() except ValueError: report_date = timezone.now().date() else: report_date = timezone.now().date() # Collecter les données data = _collect_report_data(companies, report_date) company_name = " / ".join(sorted(c.name for c in companies)) date_label = report_date.strftime('%Y%m%d') output_format = request.GET.get('format', 'zip') if output_format == 'pdf': pdf_bytes = generate_daily_report_pdf(company_name, report_date, data) response = HttpResponse(pdf_bytes, content_type='application/pdf') response['Content-Disposition'] = f'inline; filename="rapport_journalier_{date_label}.pdf"' return response elif output_format == 'excel': excel_bytes = generate_daily_report_excel(company_name, report_date, data) response = HttpResponse( excel_bytes, content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ) response['Content-Disposition'] = f'attachment; filename="rapport_journalier_{date_label}.xlsx"' return response else: # ZIP contenant les deux pdf_bytes = generate_daily_report_pdf(company_name, report_date, data) excel_bytes = generate_daily_report_excel(company_name, report_date, data) zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: zf.writestr(f"rapport_journalier_{date_label}.pdf", pdf_bytes) zf.writestr(f"rapport_journalier_{date_label}.xlsx", excel_bytes) zip_buffer.seek(0) response = HttpResponse(zip_buffer.getvalue(), content_type='application/zip') response['Content-Disposition'] = f'attachment; filename="rapport_journalier_{date_label}.zip"' return response