M1: Grundgerüst, Datenmodell und Admin

Django-5.2-Projekt mit uv, django-environ und Docker-Compose-Setup
(web + Postgres, WeasyPrint-Systembibliotheken im Image). Vollständiges
Datenmodell (Firma, Therapeut, Auftraggeber, Teilnehmer, Leistungsart,
Leistung, InvoiceLog, InvoiceCounter) mit Admin, Login-Flow und
Platzhalter-Dashboard. Fortschritt in FORTSCHRITT.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yy3uodsai6Ytt3GJ7tBdz
This commit is contained in:
Claude
2026-07-10 23:53:56 +00:00
parent 7b60be2933
commit cfa2266dd9
31 changed files with 2044 additions and 0 deletions

254
abrechnung/models.py Normal file
View File

@@ -0,0 +1,254 @@
"""Datenmodell des Abrechnungssystems (siehe UMSETZUNGSPLAN.md §"Data model")."""
from __future__ import annotations
from decimal import Decimal
from django.conf import settings
from django.core.validators import MinValueValidator
from django.db import models
class Firma(models.Model):
"""Verkäufer / Rechnungssteller (Singleton). Basis für ZUGFeRD-Seller & Kopf."""
name = models.CharField("Firmenname", max_length=200)
strasse = models.CharField("Straße & Hausnummer", max_length=200)
plz = models.CharField("PLZ", max_length=10)
ort = models.CharField("Ort", max_length=100)
land = models.CharField("Land (ISO)", max_length=2, default="DE")
ust_idnr = models.CharField("USt-IdNr.", max_length=20, blank=True)
steuernummer = models.CharField("Steuernummer", max_length=30, blank=True)
iban = models.CharField("IBAN", max_length=34, blank=True)
bic = models.CharField("BIC", max_length=11, blank=True)
bank = models.CharField("Bank", max_length=100, blank=True)
email = models.EmailField("E-Mail", blank=True)
telefon = models.CharField("Telefon", max_length=40, blank=True)
logo = models.ImageField("Logo", upload_to="firma/", blank=True, null=True)
ust_befreiungsgrund = models.CharField(
"USt-Befreiungsgrund",
max_length=255,
default="Steuerfrei gemäß § 4 UStG",
help_text="Erscheint als Befreiungsgrund auf Rechnung und im ZUGFeRD-XML.",
)
class Meta:
verbose_name = "Firma"
verbose_name_plural = "Firma"
def __str__(self) -> str:
return self.name
class Therapeut(models.Model):
"""Leistungserbringer. Basis für Leistungsstatistiken."""
name = models.CharField("Name", max_length=150)
kuerzel = models.CharField("Kürzel", max_length=10, unique=True)
aktiv = models.BooleanField("aktiv", default=True)
class Meta:
verbose_name = "Therapeut"
verbose_name_plural = "Therapeuten"
ordering = ["name"]
def __str__(self) -> str:
return f"{self.name} ({self.kuerzel})"
class Auftraggeber(models.Model):
"""Rechnungsempfänger / Bezahler. → ZUGFeRD-Buyer."""
class Typ(models.TextChoices):
AMT = "AMT", "Amt / Behörde"
FIRMA = "FIRMA", "Firma"
PRIVAT = "PRIVAT", "Privatzahler"
name = models.CharField("Name", max_length=200)
typ = models.CharField("Typ", max_length=10, choices=Typ.choices, default=Typ.AMT)
strasse = models.CharField("Straße & Hausnummer", max_length=200)
plz = models.CharField("PLZ", max_length=10)
ort = models.CharField("Ort", max_length=100)
land = models.CharField("Land (ISO)", max_length=2, default="DE")
ust_idnr = models.CharField("USt-IdNr.", max_length=20, blank=True)
email = models.EmailField("E-Mail", blank=True)
leitweg_id = models.CharField(
"Leitweg-ID", max_length=50, blank=True,
help_text="Nur für Behörden mit XRechnung-Pflicht.",
)
class Meta:
verbose_name = "Auftraggeber"
verbose_name_plural = "Auftraggeber"
ordering = ["name"]
def __str__(self) -> str:
return self.name
class Teilnehmer(models.Model):
"""Leistungsempfänger (Patient/Klient). Fest einem Auftraggeber zugeordnet."""
name = models.CharField("Name", max_length=200)
aktenzeichen = models.CharField("Aktenzeichen", max_length=50, blank=True)
geburtsdatum = models.DateField("Geburtsdatum", null=True, blank=True)
auftraggeber = models.ForeignKey(
Auftraggeber, on_delete=models.PROTECT, related_name="teilnehmer",
verbose_name="Auftraggeber",
)
therapeut = models.ForeignKey(
Therapeut, on_delete=models.PROTECT, related_name="teilnehmer",
verbose_name="Zugeordneter Therapeut",
)
aktiv = models.BooleanField("aktiv", default=True)
class Meta:
verbose_name = "Teilnehmer"
verbose_name_plural = "Teilnehmer"
ordering = ["name"]
def __str__(self) -> str:
return self.name
class Leistungsart(models.Model):
"""Admin-konfigurierbare Leistungsart (Sitzung, Vorbereitung, Elterngespräch, …)."""
bezeichnung = models.CharField("Bezeichnung", max_length=100, unique=True)
standard_stundensatz = models.DecimalField(
"Standard-Stundensatz (€)", max_digits=8, decimal_places=2,
null=True, blank=True, validators=[MinValueValidator(Decimal("0"))],
)
aktiv = models.BooleanField("aktiv", default=True)
class Meta:
verbose_name = "Leistungsart"
verbose_name_plural = "Leistungsarten"
ordering = ["bezeichnung"]
def __str__(self) -> str:
return self.bezeichnung
class InvoiceLog(models.Model):
"""Rechnungsjournal: nur Metadaten, keine Dokumente. Lückenloser Nummernkreis."""
class Status(models.TextChoices):
GUELTIG = "GUELTIG", "Gültig"
STORNIERT = "STORNIERT", "Storniert"
class Typ(models.TextChoices):
RECHNUNG = "RECHNUNG", "Rechnung"
STORNO = "STORNO", "Storno"
rechnungsnummer = models.CharField("Rechnungsnummer", max_length=30, unique=True)
datum = models.DateField("Rechnungsdatum")
auftraggeber = models.ForeignKey(
Auftraggeber, on_delete=models.PROTECT, related_name="rechnungen",
verbose_name="Auftraggeber",
)
gesamtbetrag = models.DecimalField("Gesamtbetrag (€)", max_digits=12, decimal_places=2)
status = models.CharField(
"Status", max_length=10, choices=Status.choices, default=Status.GUELTIG,
)
typ = models.CharField("Typ", max_length=10, choices=Typ.choices, default=Typ.RECHNUNG)
storno_von = models.ForeignKey(
"self", on_delete=models.PROTECT, null=True, blank=True,
related_name="storno_belege", verbose_name="Storno von",
)
created_at = models.DateTimeField("erstellt am", auto_now_add=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True,
related_name="erstellte_rechnungen", verbose_name="erstellt von",
)
class Meta:
verbose_name = "Rechnungsjournal-Eintrag"
verbose_name_plural = "Rechnungsjournal"
ordering = ["-datum", "-id"]
def __str__(self) -> str:
return f"{self.rechnungsnummer} ({self.get_status_display()})"
class InvoiceCounter(models.Model):
"""Zähler-Zeile für die lückenlose Nummernvergabe (siehe services/numbering.py).
Genau eine Zeile pro Präfix; wird per select_for_update gesperrt, damit
parallele Abrechnungen keine Nummern doppelt oder mit Lücken vergeben.
"""
praefix = models.CharField("Präfix", max_length=20, unique=True)
letzter_wert = models.PositiveIntegerField("letzter vergebener Wert", default=0)
class Meta:
verbose_name = "Nummernkreis-Zähler"
verbose_name_plural = "Nummernkreis-Zähler"
def __str__(self) -> str:
return f"{self.praefix}: {self.letzter_wert}"
class Leistung(models.Model):
"""Erbrachte Leistung (Sitzung, Vorbereitung, Elterngespräch, Beratung …).
Umbenannt von TherapySession. Auftraggeber wird über den Teilnehmer abgeleitet.
"""
therapeut = models.ForeignKey(
Therapeut, on_delete=models.PROTECT, related_name="leistungen",
verbose_name="Therapeut",
)
teilnehmer = models.ForeignKey(
Teilnehmer, on_delete=models.PROTECT, related_name="leistungen",
verbose_name="Teilnehmer",
)
leistungsart = models.ForeignKey(
Leistungsart, on_delete=models.PROTECT, related_name="leistungen",
verbose_name="Leistungsart",
)
datum = models.DateField("Datum")
stundenanzahl = models.DecimalField(
"Stunden", max_digits=6, decimal_places=2,
validators=[MinValueValidator(Decimal("0"))],
)
stundensatz = models.DecimalField(
"Stundensatz (€)", max_digits=8, decimal_places=2,
validators=[MinValueValidator(Decimal("0"))],
)
beschreibung = models.CharField("Beschreibung", max_length=255, blank=True)
abgerechnet_mit = models.ForeignKey(
InvoiceLog, on_delete=models.PROTECT, null=True, blank=True,
related_name="leistungen", verbose_name="abgerechnet mit",
)
class Meta:
verbose_name = "Leistung"
verbose_name_plural = "Leistungen"
ordering = ["datum", "id"]
indexes = [
models.Index(fields=["therapeut", "datum"]),
models.Index(fields=["abgerechnet_mit"]),
]
def __str__(self) -> str:
return f"{self.datum} · {self.teilnehmer} · {self.leistungsart}"
@property
def betrag(self) -> Decimal:
"""Zeilensumme = Stunden × Stundensatz."""
return (self.stundenanzahl or Decimal("0")) * (self.stundensatz or Decimal("0"))
@property
def auftraggeber(self) -> Auftraggeber:
"""Abgeleitet über den Teilnehmer (kein direkter FK)."""
return self.teilnehmer.auftraggeber