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
255 lines
8.8 KiB
Python
255 lines
8.8 KiB
Python
"""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
|