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

0
abrechnung/__init__.py Normal file
View File

80
abrechnung/admin.py Normal file
View File

@@ -0,0 +1,80 @@
"""Admin-Registrierung. Stammdatenpflege läuft im MVP über den Django-Admin."""
from django.contrib import admin
from .models import (
Auftraggeber,
Firma,
InvoiceCounter,
InvoiceLog,
Leistung,
Leistungsart,
Teilnehmer,
Therapeut,
)
@admin.register(Firma)
class FirmaAdmin(admin.ModelAdmin):
list_display = ("name", "ort", "ust_idnr")
def has_add_permission(self, request):
# Singleton: nur eine Firma zulassen.
return not Firma.objects.exists()
@admin.register(Therapeut)
class TherapeutAdmin(admin.ModelAdmin):
list_display = ("name", "kuerzel", "aktiv")
list_filter = ("aktiv",)
search_fields = ("name", "kuerzel")
@admin.register(Auftraggeber)
class AuftraggeberAdmin(admin.ModelAdmin):
list_display = ("name", "typ", "ort", "ust_idnr")
list_filter = ("typ",)
search_fields = ("name",)
@admin.register(Teilnehmer)
class TeilnehmerAdmin(admin.ModelAdmin):
list_display = ("name", "auftraggeber", "therapeut", "aktiv")
list_filter = ("aktiv", "therapeut", "auftraggeber")
search_fields = ("name", "aktenzeichen")
autocomplete_fields = ("auftraggeber", "therapeut")
@admin.register(Leistungsart)
class LeistungsartAdmin(admin.ModelAdmin):
list_display = ("bezeichnung", "standard_stundensatz", "aktiv")
list_filter = ("aktiv",)
search_fields = ("bezeichnung",)
@admin.register(Leistung)
class LeistungAdmin(admin.ModelAdmin):
list_display = (
"datum", "teilnehmer", "therapeut", "leistungsart",
"stundenanzahl", "stundensatz", "abgerechnet_mit",
)
list_filter = ("therapeut", "leistungsart", "datum")
search_fields = ("teilnehmer__name", "beschreibung")
autocomplete_fields = ("teilnehmer", "therapeut", "leistungsart")
date_hierarchy = "datum"
@admin.register(InvoiceLog)
class InvoiceLogAdmin(admin.ModelAdmin):
list_display = (
"rechnungsnummer", "datum", "auftraggeber", "gesamtbetrag",
"typ", "status", "storno_von",
)
list_filter = ("status", "typ", "datum")
search_fields = ("rechnungsnummer", "auftraggeber__name")
readonly_fields = ("created_at", "created_by")
@admin.register(InvoiceCounter)
class InvoiceCounterAdmin(admin.ModelAdmin):
list_display = ("praefix", "letzter_wert")

6
abrechnung/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AbrechnungConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "abrechnung"

View File

@@ -0,0 +1,493 @@
# Generated by Django 5.2.16 on 2026-07-10 22:50
import django.core.validators
import django.db.models.deletion
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Auftraggeber",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=200, verbose_name="Name")),
(
"typ",
models.CharField(
choices=[
("AMT", "Amt / Behörde"),
("FIRMA", "Firma"),
("PRIVAT", "Privatzahler"),
],
default="AMT",
max_length=10,
verbose_name="Typ",
),
),
(
"strasse",
models.CharField(
max_length=200, verbose_name="Straße & Hausnummer"
),
),
("plz", models.CharField(max_length=10, verbose_name="PLZ")),
("ort", models.CharField(max_length=100, verbose_name="Ort")),
(
"land",
models.CharField(
default="DE", max_length=2, verbose_name="Land (ISO)"
),
),
(
"ust_idnr",
models.CharField(
blank=True, max_length=20, verbose_name="USt-IdNr."
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="E-Mail"
),
),
(
"leitweg_id",
models.CharField(
blank=True,
help_text="Nur für Behörden mit XRechnung-Pflicht.",
max_length=50,
verbose_name="Leitweg-ID",
),
),
],
options={
"verbose_name": "Auftraggeber",
"verbose_name_plural": "Auftraggeber",
"ordering": ["name"],
},
),
migrations.CreateModel(
name="Firma",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=200, verbose_name="Firmenname")),
(
"strasse",
models.CharField(
max_length=200, verbose_name="Straße & Hausnummer"
),
),
("plz", models.CharField(max_length=10, verbose_name="PLZ")),
("ort", models.CharField(max_length=100, verbose_name="Ort")),
(
"land",
models.CharField(
default="DE", max_length=2, verbose_name="Land (ISO)"
),
),
(
"ust_idnr",
models.CharField(
blank=True, max_length=20, verbose_name="USt-IdNr."
),
),
(
"steuernummer",
models.CharField(
blank=True, max_length=30, verbose_name="Steuernummer"
),
),
(
"iban",
models.CharField(blank=True, max_length=34, verbose_name="IBAN"),
),
(
"bic",
models.CharField(blank=True, max_length=11, verbose_name="BIC"),
),
(
"bank",
models.CharField(blank=True, max_length=100, verbose_name="Bank"),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="E-Mail"
),
),
(
"telefon",
models.CharField(blank=True, max_length=40, verbose_name="Telefon"),
),
(
"logo",
models.ImageField(
blank=True, null=True, upload_to="firma/", verbose_name="Logo"
),
),
(
"ust_befreiungsgrund",
models.CharField(
default="Steuerfrei gemäß § 4 UStG",
help_text="Erscheint als Befreiungsgrund auf Rechnung und im ZUGFeRD-XML.",
max_length=255,
verbose_name="USt-Befreiungsgrund",
),
),
],
options={
"verbose_name": "Firma",
"verbose_name_plural": "Firma",
},
),
migrations.CreateModel(
name="InvoiceCounter",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"praefix",
models.CharField(max_length=20, unique=True, verbose_name="Präfix"),
),
(
"letzter_wert",
models.PositiveIntegerField(
default=0, verbose_name="letzter vergebener Wert"
),
),
],
options={
"verbose_name": "Nummernkreis-Zähler",
"verbose_name_plural": "Nummernkreis-Zähler",
},
),
migrations.CreateModel(
name="Leistungsart",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"bezeichnung",
models.CharField(
max_length=100, unique=True, verbose_name="Bezeichnung"
),
),
(
"standard_stundensatz",
models.DecimalField(
blank=True,
decimal_places=2,
max_digits=8,
null=True,
validators=[
django.core.validators.MinValueValidator(Decimal("0"))
],
verbose_name="Standard-Stundensatz (€)",
),
),
("aktiv", models.BooleanField(default=True, verbose_name="aktiv")),
],
options={
"verbose_name": "Leistungsart",
"verbose_name_plural": "Leistungsarten",
"ordering": ["bezeichnung"],
},
),
migrations.CreateModel(
name="Therapeut",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=150, verbose_name="Name")),
(
"kuerzel",
models.CharField(max_length=10, unique=True, verbose_name="Kürzel"),
),
("aktiv", models.BooleanField(default=True, verbose_name="aktiv")),
],
options={
"verbose_name": "Therapeut",
"verbose_name_plural": "Therapeuten",
"ordering": ["name"],
},
),
migrations.CreateModel(
name="InvoiceLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"rechnungsnummer",
models.CharField(
max_length=30, unique=True, verbose_name="Rechnungsnummer"
),
),
("datum", models.DateField(verbose_name="Rechnungsdatum")),
(
"gesamtbetrag",
models.DecimalField(
decimal_places=2, max_digits=12, verbose_name="Gesamtbetrag (€)"
),
),
(
"status",
models.CharField(
choices=[("GUELTIG", "Gültig"), ("STORNIERT", "Storniert")],
default="GUELTIG",
max_length=10,
verbose_name="Status",
),
),
(
"typ",
models.CharField(
choices=[("RECHNUNG", "Rechnung"), ("STORNO", "Storno")],
default="RECHNUNG",
max_length=10,
verbose_name="Typ",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="erstellt am"),
),
(
"auftraggeber",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="rechnungen",
to="abrechnung.auftraggeber",
verbose_name="Auftraggeber",
),
),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="erstellte_rechnungen",
to=settings.AUTH_USER_MODEL,
verbose_name="erstellt von",
),
),
(
"storno_von",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="storno_belege",
to="abrechnung.invoicelog",
verbose_name="Storno von",
),
),
],
options={
"verbose_name": "Rechnungsjournal-Eintrag",
"verbose_name_plural": "Rechnungsjournal",
"ordering": ["-datum", "-id"],
},
),
migrations.CreateModel(
name="Teilnehmer",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=200, verbose_name="Name")),
(
"aktenzeichen",
models.CharField(
blank=True, max_length=50, verbose_name="Aktenzeichen"
),
),
(
"geburtsdatum",
models.DateField(
blank=True, null=True, verbose_name="Geburtsdatum"
),
),
("aktiv", models.BooleanField(default=True, verbose_name="aktiv")),
(
"auftraggeber",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="teilnehmer",
to="abrechnung.auftraggeber",
verbose_name="Auftraggeber",
),
),
(
"therapeut",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="teilnehmer",
to="abrechnung.therapeut",
verbose_name="Zugeordneter Therapeut",
),
),
],
options={
"verbose_name": "Teilnehmer",
"verbose_name_plural": "Teilnehmer",
"ordering": ["name"],
},
),
migrations.CreateModel(
name="Leistung",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("datum", models.DateField(verbose_name="Datum")),
(
"stundenanzahl",
models.DecimalField(
decimal_places=2,
max_digits=6,
validators=[
django.core.validators.MinValueValidator(Decimal("0"))
],
verbose_name="Stunden",
),
),
(
"stundensatz",
models.DecimalField(
decimal_places=2,
max_digits=8,
validators=[
django.core.validators.MinValueValidator(Decimal("0"))
],
verbose_name="Stundensatz (€)",
),
),
(
"beschreibung",
models.CharField(
blank=True, max_length=255, verbose_name="Beschreibung"
),
),
(
"abgerechnet_mit",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="leistungen",
to="abrechnung.invoicelog",
verbose_name="abgerechnet mit",
),
),
(
"leistungsart",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="leistungen",
to="abrechnung.leistungsart",
verbose_name="Leistungsart",
),
),
(
"teilnehmer",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="leistungen",
to="abrechnung.teilnehmer",
verbose_name="Teilnehmer",
),
),
(
"therapeut",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="leistungen",
to="abrechnung.therapeut",
verbose_name="Therapeut",
),
),
],
options={
"verbose_name": "Leistung",
"verbose_name_plural": "Leistungen",
"ordering": ["datum", "id"],
"indexes": [
models.Index(
fields=["therapeut", "datum"],
name="abrechnung__therape_3934a3_idx",
),
models.Index(
fields=["abgerechnet_mit"],
name="abrechnung__abgerec_00377c_idx",
),
],
},
),
]

View File

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

View File

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Dashboard · Abrechnungssystem{% endblock %}
{% block content %}
<div class="card">
<h1>Willkommen, {{ user.get_username }}</h1>
<p>Das Grundgerüst steht (Meilenstein&nbsp;1). Über <a href="{% url 'admin:index' %}">Stammdaten</a>
lassen sich Firma, Auftraggeber, Therapeuten, Teilnehmer, Leistungsarten und Leistungen pflegen.</p>
<p>Leistungserfassung, Rechnungslegung, Storno und das Controlling-Dashboard folgen in den
nächsten Meilensteinen.</p>
</div>
{% endblock %}

View File

View File

@@ -0,0 +1,46 @@
"""Grundlegende Modelltests (M1). Umfangreichere Tests folgen in M5."""
from datetime import date
from decimal import Decimal
import pytest
from abrechnung.models import (
Auftraggeber,
Leistung,
Leistungsart,
Teilnehmer,
Therapeut,
)
@pytest.fixture
def leistung(db):
therapeut = Therapeut.objects.create(name="Anna Muster", kuerzel="AM")
auftraggeber = Auftraggeber.objects.create(
name="Jugendamt Musterstadt", strasse="Amtsweg 1", plz="12345", ort="Musterstadt",
)
teilnehmer = Teilnehmer.objects.create(
name="Max Klient", auftraggeber=auftraggeber, therapeut=therapeut,
)
art = Leistungsart.objects.create(
bezeichnung="Sitzung", standard_stundensatz=Decimal("80.00"),
)
return Leistung.objects.create(
therapeut=therapeut, teilnehmer=teilnehmer, leistungsart=art,
datum=date(2026, 5, 4), stundenanzahl=Decimal("1.50"),
stundensatz=Decimal("80.00"),
)
def test_betrag_is_hours_times_rate(leistung):
assert leistung.betrag == Decimal("120.00")
def test_auftraggeber_derived_via_teilnehmer(leistung):
assert leistung.auftraggeber == leistung.teilnehmer.auftraggeber
assert leistung.auftraggeber.name == "Jugendamt Musterstadt"
def test_leistung_starts_unbilled(leistung):
assert leistung.abgerechnet_mit is None

10
abrechnung/views.py Normal file
View File

@@ -0,0 +1,10 @@
"""Views. In M1 nur ein Platzhalter-Dashboard; wird in späteren Meilensteinen ausgebaut."""
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
@login_required
def dashboard(request):
"""Platzhalter-Startseite (echtes Controlling-Dashboard folgt in M5)."""
return render(request, "abrechnung/dashboard_placeholder.html")