Files
rechnungsgenerator/abrechnung/models.py
Claude cfa2266dd9 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
2026-07-10 23:53:56 +00:00

255 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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