From 6c8ddbb4f075e74cf83388a27466146a15d50f27 Mon Sep 17 00:00:00 2001 From: Jan Remmer Siebels Date: Tue, 30 Dec 2025 20:20:33 +0100 Subject: [PATCH] =?UTF-8?q?Getrennte=20Fristen=20f=C3=BCr=20Studiennachwei?= =?UTF-8?q?s=20und=20Zahlung=20implementieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue Felder: studiennachweis_faelligkeitsdatum (semesterbasiert) und zahlung_faelligkeitsdatum (vierteljährlich im Voraus) - Studiennachweis-Fristen: Q1/Q2 → 15. März, Q3/Q4 → 15. September - Zahlungsfälligkeiten: Q1 → 15. Dez (Vorjahr), Q2 → 15. Mär, Q3 → 15. Jun, Q4 → 15. Sep - Auto-Freigabe: Q1 freigeben → Q2 Studiennachweis auto-freigegeben, Q3 → Q4 - Unterstützungserstellung: Verhindert Duplikate durch präzise Suche nach zahlung_faelligkeitsdatum - Quartalserstellung: Modal-Formular funktioniert korrekt - UI: Beide Fristen in Tabelle angezeigt, separate Überfälligkeits-Indikatoren - Migration: Neue Felder hinzugefügt und bestehende Datensätze befüllt --- .../migrations/0042_add_separate_deadlines.py | 84 ++++++ app/stiftung/models.py | 150 ++++++++-- app/stiftung/views.py | 166 ++++++----- .../stiftung/destinataer_detail.html | 272 ++++++++++++------ .../stiftung/quarterly_confirmation_edit.html | 53 +++- compose.dev.yml | 4 +- 6 files changed, 542 insertions(+), 187 deletions(-) create mode 100644 app/stiftung/migrations/0042_add_separate_deadlines.py diff --git a/app/stiftung/migrations/0042_add_separate_deadlines.py b/app/stiftung/migrations/0042_add_separate_deadlines.py new file mode 100644 index 0000000..44efaac --- /dev/null +++ b/app/stiftung/migrations/0042_add_separate_deadlines.py @@ -0,0 +1,84 @@ +# Generated migration for separate study proof and payment deadlines + +from django.db import migrations, models +from datetime import date + + +def calculate_study_proof_deadline(jahr, quartal): + """Calculate semester-based study proof deadline""" + # Q1, Q2 → March 15 (same year) + # Q3, Q4 → September 15 (same year) + if quartal in [1, 2]: + return date(jahr, 3, 15) + else: # Q3, Q4 + return date(jahr, 9, 15) + + +def calculate_payment_due_date(jahr, quartal): + """Calculate quarterly payment due date (paid in advance)""" + # Q1 → December 15 (previous year) + # Q2 → March 15 (same year) + # Q3 → June 15 (same year) + # Q4 → September 15 (same year) + if quartal == 1: + return date(jahr - 1, 12, 15) + elif quartal == 2: + return date(jahr, 3, 15) + elif quartal == 3: + return date(jahr, 6, 15) + else: # Q4 + return date(jahr, 9, 15) + + +def populate_deadlines(apps, schema_editor): + """Populate new deadline fields for existing records""" + VierteljahresNachweis = apps.get_model('stiftung', 'VierteljahresNachweis') + + for nachweis in VierteljahresNachweis.objects.all(): + # Calculate and set study proof deadline + if not nachweis.studiennachweis_faelligkeitsdatum: + nachweis.studiennachweis_faelligkeitsdatum = calculate_study_proof_deadline( + nachweis.jahr, nachweis.quartal + ) + + # Calculate and set payment due date + if not nachweis.zahlung_faelligkeitsdatum: + nachweis.zahlung_faelligkeitsdatum = calculate_payment_due_date( + nachweis.jahr, nachweis.quartal + ) + + nachweis.save(update_fields=['studiennachweis_faelligkeitsdatum', 'zahlung_faelligkeitsdatum']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('stiftung', '0041_alter_geschichteseite_inhalt'), + ] + + operations = [ + # Add new fields + migrations.AddField( + model_name='vierteljahresnachweis', + name='studiennachweis_faelligkeitsdatum', + field=models.DateField( + blank=True, + help_text='Semesterbasierte Frist: Q1/Q2 → 15. März, Q3/Q4 → 15. September', + null=True, + verbose_name='Studiennachweis Fälligkeitsdatum' + ), + ), + migrations.AddField( + model_name='vierteljahresnachweis', + name='zahlung_faelligkeitsdatum', + field=models.DateField( + blank=True, + help_text='Vierteljährliche Zahlungsfälligkeit im Voraus: Q1→15. Dez (Vorjahr), Q2→15. Mär, Q3→15. Jun, Q4→15. Sep', + null=True, + verbose_name='Zahlungsfälligkeit' + ), + ), + # Populate existing records + migrations.RunPython(populate_deadlines, migrations.RunPython.noop), + ] + diff --git a/app/stiftung/models.py b/app/stiftung/models.py index e7d708c..e35d25b 100644 --- a/app/stiftung/models.py +++ b/app/stiftung/models.py @@ -2665,7 +2665,23 @@ class VierteljahresNachweis(models.Model): faelligkeitsdatum = models.DateField( null=True, blank=True, - verbose_name="Fälligkeitsdatum" + verbose_name="Fälligkeitsdatum", + help_text="Veraltet - wird durch studiennachweis_faelligkeitsdatum und zahlung_faelligkeitsdatum ersetzt" + ) + + # Separate deadlines for study proof (semester-based) and payment (quarterly) + studiennachweis_faelligkeitsdatum = models.DateField( + null=True, + blank=True, + verbose_name="Studiennachweis Fälligkeitsdatum", + help_text="Semesterbasierte Frist: Q1/Q2 → 15. März, Q3/Q4 → 15. September" + ) + + zahlung_faelligkeitsdatum = models.DateField( + null=True, + blank=True, + verbose_name="Zahlungsfälligkeit", + help_text="Vierteljährliche Zahlungsfälligkeit im Voraus: Q1→15. Dez (Vorjahr), Q2→15. Mär, Q3→15. Jun, Q4→15. Sep" ) class Meta: @@ -2746,18 +2762,67 @@ class VierteljahresNachweis(models.Model): return int((completed_requirements / total_requirements) * 100) if total_requirements > 0 else 0 + def get_study_proof_deadline(self): + """Calculate semester-based study proof deadline""" + from datetime import date + # Q1, Q2 → March 15 (same year) + # Q3, Q4 → September 15 (same year) + if self.quartal in [1, 2]: + return date(self.jahr, 3, 15) + else: # Q3, Q4 + return date(self.jahr, 9, 15) + + def get_payment_due_date(self): + """Calculate quarterly payment due date (paid in advance)""" + from datetime import date + # Q1 → December 15 (previous year) + # Q2 → March 15 (same year) + # Q3 → June 15 (same year) + # Q4 → September 15 (same year) + if self.quartal == 1: + return date(self.jahr - 1, 12, 15) + elif self.quartal == 2: + return date(self.jahr, 3, 15) + elif self.quartal == 3: + return date(self.jahr, 6, 15) + else: # Q4 + return date(self.jahr, 9, 15) + + def is_study_proof_overdue(self): + """Check if study proof deadline has passed""" + if not self.studiennachweis_faelligkeitsdatum: + return False + from django.utils import timezone + return timezone.now().date() > self.studiennachweis_faelligkeitsdatum and not self.studiennachweis_eingereicht + + def is_payment_overdue(self): + """Check if payment due date has passed""" + if not self.zahlung_faelligkeitsdatum: + return False + from django.utils import timezone + # Payment is overdue if due date passed and no payment exists or payment is not completed + payment = self.get_related_support_payment() + if payment and payment.status in ['bezahlt', 'in_bearbeitung']: + return False + return timezone.now().date() > self.zahlung_faelligkeitsdatum + + def is_overdue(self): + """Check if either deadline has passed""" + return self.is_study_proof_overdue() or self.is_payment_overdue() + def save(self, *args, **kwargs): """Override save to auto-update status and timestamps""" - # Auto-set deadline if not provided (semester-based deadlines) + # Set study proof deadline (semester-based) if not provided + if not self.studiennachweis_faelligkeitsdatum: + self.studiennachweis_faelligkeitsdatum = self.get_study_proof_deadline() + + # Set payment due date (quarterly, advance) if not provided + if not self.zahlung_faelligkeitsdatum: + self.zahlung_faelligkeitsdatum = self.get_payment_due_date() + + # Backward compatibility: set faelligkeitsdatum from study proof deadline if not set if not self.faelligkeitsdatum: - from datetime import date - quarter_deadlines = { - 1: date(self.jahr, 3, 15), # Q1 deadline: March 15 (Spring semester) - 2: date(self.jahr, 3, 15), # Q2 deadline: March 15 (Spring semester, same as Q1) - 3: date(self.jahr, 9, 15), # Q3 deadline: September 15 (Fall semester) - 4: date(self.jahr, 9, 15), # Q4 deadline: September 15 (Fall semester, same as Q3) - } - self.faelligkeitsdatum = quarter_deadlines.get(self.quartal) + self.faelligkeitsdatum = self.studiennachweis_faelligkeitsdatum # Auto-update status based on completion if self.is_complete(): @@ -2773,18 +2838,71 @@ class VierteljahresNachweis(models.Model): def get_related_support_payment(self): """Get the related support payment for this quarterly confirmation""" - from datetime import datetime + from datetime import date, timedelta + from django.db.models import Q - quarter_start = datetime(self.jahr, (self.quartal - 1) * 3 + 1, 1).date() - quarter_end = datetime(self.jahr, self.quartal * 3, 1).date() + # Use payment due date from quarterly confirmation for accurate search + # This is more accurate than using quarter date range, especially for Q1 (Dec 15 prev year) + payment_due_date = self.zahlung_faelligkeitsdatum + if not payment_due_date: + # Fallback: calculate if not set + if self.quartal == 1: + payment_due_date = date(self.jahr - 1, 12, 15) + elif self.quartal == 2: + payment_due_date = date(self.jahr, 3, 15) + elif self.quartal == 3: + payment_due_date = date(self.jahr, 6, 15) + else: # Q4 + payment_due_date = date(self.jahr, 9, 15) + + # Search for existing payment - match by payment due date and description + # Use a date range around the due date (±30 days) to catch any variations + date_start = payment_due_date - timedelta(days=30) + date_end = payment_due_date + timedelta(days=30) return DestinataerUnterstuetzung.objects.filter( destinataer=self.destinataer, - faellig_am__gte=quarter_start, - faellig_am__lt=quarter_end, - beschreibung__contains=f"Q{self.quartal}/{self.jahr}" + faellig_am__gte=date_start, + faellig_am__lte=date_end + ).filter( + Q(beschreibung__contains=f"Q{self.quartal}/{self.jahr}") | + Q(beschreibung__contains=f"Vierteljährliche Unterstützung Q{self.quartal}/{self.jahr}") ).first() + def auto_approve_next_quarter(self): + """Auto-approve the next quarter when Q1 or Q3 is approved (semester-based logic)""" + if self.quartal in [1, 3] and self.status == "geprueft": + next_quarter = self.quartal + 1 + try: + next_nachweis = VierteljahresNachweis.objects.get( + destinataer=self.destinataer, + jahr=self.jahr, + quartal=next_quarter + ) + + if next_nachweis.status in ["offen", "teilweise"]: + # Copy study proof confirmations from current quarter (semester-based) + next_nachweis.studiennachweis_eingereicht = self.studiennachweis_eingereicht + next_nachweis.studiennachweis_datei = self.studiennachweis_datei + next_nachweis.studiennachweis_bemerkung = self.studiennachweis_bemerkung + + # Set study proof deadline for next quarter (same semester) + next_nachweis.studiennachweis_faelligkeitsdatum = next_nachweis.get_study_proof_deadline() + + # Set auto-approved status + next_nachweis.status = "auto_geprueft" + next_nachweis.geprueft_am = timezone.now() + next_nachweis.geprueft_von = self.geprueft_von + next_nachweis.save(update_fields=[ + 'studiennachweis_eingereicht', 'studiennachweis_datei', 'studiennachweis_bemerkung', + 'studiennachweis_faelligkeitsdatum', 'status', 'geprueft_am', 'geprueft_von' + ]) + + return next_nachweis + except VierteljahresNachweis.DoesNotExist: + pass + return None + @classmethod def get_or_create_for_period(cls, destinataer, jahr, quartal): """Get or create a quarterly confirmation for a specific period""" diff --git a/app/stiftung/views.py b/app/stiftung/views.py index f11e923..aa557e6 100644 --- a/app/stiftung/views.py +++ b/app/stiftung/views.py @@ -7422,21 +7422,33 @@ def create_quarterly_support_payment(nachweis): if not destinataer.iban: return None - # Calculate quarter date range for more robust search - quarter_start = datetime(nachweis.jahr, (nachweis.quartal - 1) * 3 + 1, 1).date() - if nachweis.quartal == 4: # Q4 special case - quarter_end = datetime(nachweis.jahr + 1, 1, 1).date() - else: - quarter_end = datetime(nachweis.jahr, nachweis.quartal * 3 + 1, 1).date() + # Search for existing payment using payment due date from quarterly confirmation + # This is more accurate than using quarter date range, especially for Q1 (Dec 15 prev year) + payment_due_date = nachweis.zahlung_faelligkeitsdatum + if not payment_due_date: + # Fallback: calculate if not set + if nachweis.quartal == 1: + payment_due_date = date(nachweis.jahr - 1, 12, 15) + elif nachweis.quartal == 2: + payment_due_date = date(nachweis.jahr, 3, 15) + elif nachweis.quartal == 3: + payment_due_date = date(nachweis.jahr, 6, 15) + else: # Q4 + payment_due_date = date(nachweis.jahr, 9, 15) + + # Search for existing payment - match by payment due date and description + # Use a date range around the due date (±30 days) to catch any variations + from datetime import timedelta + date_start = payment_due_date - timedelta(days=30) + date_end = payment_due_date + timedelta(days=30) - # Search for existing payment - use broader criteria to catch all possibilities existing_payment = DestinataerUnterstuetzung.objects.filter( destinataer=destinataer, - faellig_am__gte=quarter_start, - faellig_am__lt=quarter_end + faellig_am__gte=date_start, + faellig_am__lte=date_end ).filter( Q(beschreibung__contains=f"Q{nachweis.quartal}/{nachweis.jahr}") | - Q(beschreibung__contains=f"Vierteljährliche Unterstützung") + Q(beschreibung__contains=f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr}") ).first() if existing_payment: @@ -7457,17 +7469,19 @@ def create_quarterly_support_payment(nachweis): if not default_konto: return None - # Calculate payment due date (advance payments 3 months before quarter) - # Q1: December 15 (previous year), Q2: March 15, Q3: June 15, Q4: September 15 - - if nachweis.quartal == 1: # Q1 payment due December 15 of previous year - payment_due_date = date(nachweis.jahr - 1, 12, 15) - elif nachweis.quartal == 2: # Q2 payment due March 15 - payment_due_date = date(nachweis.jahr, 3, 15) - elif nachweis.quartal == 3: # Q3 payment due June 15 - payment_due_date = date(nachweis.jahr, 6, 15) - else: # Q4 payment due September 15 - payment_due_date = date(nachweis.jahr, 9, 15) + # Use payment due date from quarterly confirmation (already calculated by model) + # This ensures consistency with zahlung_faelligkeitsdatum + payment_due_date = nachweis.zahlung_faelligkeitsdatum + if not payment_due_date: + # Fallback: calculate if not set (should not happen, but safety check) + if nachweis.quartal == 1: # Q1 payment due December 15 of previous year + payment_due_date = date(nachweis.jahr - 1, 12, 15) + elif nachweis.quartal == 2: # Q2 payment due March 15 + payment_due_date = date(nachweis.jahr, 3, 15) + elif nachweis.quartal == 3: # Q3 payment due June 15 + payment_due_date = date(nachweis.jahr, 6, 15) + else: # Q4 payment due September 15 + payment_due_date = date(nachweis.jahr, 9, 15) # Create the support payment payment = DestinataerUnterstuetzung.objects.create( @@ -7490,9 +7504,14 @@ def create_quarterly_support_payment(nachweis): @login_required def quarterly_confirmation_create(request, destinataer_id): """Create a new quarterly confirmation for a destinataer""" + import logging + logger = logging.getLogger(__name__) + logger.info(f"quarterly_confirmation_create called: method={request.method}, destinataer_id={destinataer_id}") + destinataer = get_object_or_404(Destinataer, pk=destinataer_id) if request.method == "POST": + logger.info(f"POST data: {request.POST}") jahr = request.POST.get('jahr') quartal = request.POST.get('quartal') @@ -7515,26 +7534,41 @@ def quarterly_confirmation_create(request, destinataer_id): ) else: # Create new quarterly confirmation - nachweis = VierteljahresNachweis.objects.create( - destinataer=destinataer, - jahr=jahr, - quartal=quartal, - studiennachweis_erforderlich=True, # Always required now - ) - - # Set deadline (15th of second month of quarter) - deadline_months = {1: 5, 2: 8, 3: 11, 4: 2} # Q1->May, Q2->Aug, Q3->Nov, Q4->Feb(next year) - deadline_month = deadline_months[quartal] - deadline_year = jahr if quartal != 4 else jahr + 1 - - from datetime import date - nachweis.faelligkeitsdatum = date(deadline_year, deadline_month, 15) - nachweis.save() - - messages.success( - request, - f"Quartal {jahr} Q{quartal} wurde erfolgreich für {destinataer.get_full_name()} erstellt." - ) + try: + nachweis = VierteljahresNachweis.objects.create( + destinataer=destinataer, + jahr=jahr, + quartal=quartal, + studiennachweis_erforderlich=True, # Always required now + ) + # Deadlines are automatically set by the model's save() method + # studiennachweis_faelligkeitsdatum: semester-based (Q1/Q2→Mar 15, Q3/Q4→Sep 15) + # zahlung_faelligkeitsdatum: quarterly advance (Q1→Dec 15 prev year, Q2→Mar 15, Q3→Jun 15, Q4→Sep 15) + + # Refresh from database to ensure deadlines are set + nachweis.refresh_from_db() + + studiennachweis_str = nachweis.studiennachweis_faelligkeitsdatum.strftime('%d.%m.%Y') if nachweis.studiennachweis_faelligkeitsdatum else "Nicht gesetzt" + zahlung_str = nachweis.zahlung_faelligkeitsdatum.strftime('%d.%m.%Y') if nachweis.zahlung_faelligkeitsdatum else "Nicht gesetzt" + + messages.success( + request, + f"Quartal {jahr} Q{quartal} wurde erfolgreich für {destinataer.get_full_name()} erstellt. " + f"Studiennachweis fällig: {studiennachweis_str}, " + f"Zahlung fällig: {zahlung_str}." + ) + except Exception as e: + from django.db import IntegrityError + if isinstance(e, IntegrityError): + messages.error( + request, + f"Quartal {jahr} Q{quartal} existiert bereits für {destinataer.get_full_name()}." + ) + else: + messages.error( + request, + f"Fehler beim Erstellen des Quartals: {str(e)}" + ) except (ValueError, TypeError): messages.error(request, "Ungültige Jahr- oder Quartalswerte.") @@ -7655,41 +7689,35 @@ def quarterly_confirmation_approve(request, pk): ) # Handle support payment - create if missing, update if exists - if not related_payment: - # Create new support payment - related_payment = create_quarterly_support_payment(nachweis) - if related_payment: - related_payment.status = 'in_bearbeitung' - related_payment.aktualisiert_am = timezone.now() - related_payment.save() - + # Check if payment already exists before calling create_quarterly_support_payment() + payment_existed_before = related_payment is not None + + # Use create_quarterly_support_payment() which handles both cases (find existing or create new) + related_payment = create_quarterly_support_payment(nachweis) + if related_payment: + # Update status to 'in_bearbeitung' for both new and existing payments + old_status = related_payment.status + related_payment.status = 'in_bearbeitung' + related_payment.aktualisiert_am = timezone.now() + related_payment.save() + + if payment_existed_before: + messages.success( + request, + f"Vierteljahresnachweis freigegeben und bestehende Unterstützung für {nachweis.destinataer.get_full_name()} " + f"({nachweis.jahr} Q{nachweis.quartal}) wurde von '{old_status}' auf 'in Bearbeitung' aktualisiert." + ) + else: messages.success( request, f"Vierteljahresnachweis freigegeben und neue Unterstützung über {related_payment.betrag}€ für {nachweis.destinataer.get_full_name()} " f"({nachweis.jahr} Q{nachweis.quartal}) wurde erstellt." ) - else: - messages.warning( - request, - f"Vierteljahresnachweis freigegeben, aber Unterstützung konnte nicht erstellt werden. " - f"Bitte prüfen Sie die Einstellungen für {nachweis.destinataer.get_full_name()}." - ) - elif related_payment.status == 'geplant': - # Update existing payment - related_payment.status = 'in_bearbeitung' - related_payment.aktualisiert_am = timezone.now() - related_payment.save() - - messages.success( - request, - f"Vierteljahresnachweis und zugehörige Unterstützung für {nachweis.destinataer.get_full_name()} " - f"({nachweis.jahr} Q{nachweis.quartal}) wurden freigegeben." - ) else: - messages.success( + messages.warning( request, - f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} " - f"({nachweis.jahr} Q{nachweis.quartal}) wurde freigegeben." + f"Vierteljahresnachweis freigegeben, aber Unterstützung konnte nicht erstellt werden. " + f"Bitte prüfen Sie die Einstellungen für {nachweis.destinataer.get_full_name()}." ) else: messages.error( diff --git a/app/templates/stiftung/destinataer_detail.html b/app/templates/stiftung/destinataer_detail.html index 0d24d98..a22ab6b 100644 --- a/app/templates/stiftung/destinataer_detail.html +++ b/app/templates/stiftung/destinataer_detail.html @@ -466,7 +466,7 @@ Vierteljährliche Nachweise
- Frist: jeweils 15. des zweiten Quartalsmonats + Studiennachweis: 15. März / 15. September | Zahlung: vierteljährlich im Voraus