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:
254
abrechnung/models.py
Normal file
254
abrechnung/models.py
Normal 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
|
||||
Reference in New Issue
Block a user