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:
0
abrechnung/__init__.py
Normal file
0
abrechnung/__init__.py
Normal file
80
abrechnung/admin.py
Normal file
80
abrechnung/admin.py
Normal 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
6
abrechnung/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AbrechnungConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "abrechnung"
|
||||
493
abrechnung/migrations/0001_initial.py
Normal file
493
abrechnung/migrations/0001_initial.py
Normal 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",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
abrechnung/migrations/__init__.py
Normal file
0
abrechnung/migrations/__init__.py
Normal file
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
|
||||
0
abrechnung/services/__init__.py
Normal file
0
abrechnung/services/__init__.py
Normal file
11
abrechnung/templates/abrechnung/dashboard_placeholder.html
Normal file
11
abrechnung/templates/abrechnung/dashboard_placeholder.html
Normal 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 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 %}
|
||||
0
abrechnung/tests/__init__.py
Normal file
0
abrechnung/tests/__init__.py
Normal file
46
abrechnung/tests/test_models.py
Normal file
46
abrechnung/tests/test_models.py
Normal 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
10
abrechnung/views.py
Normal 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")
|
||||
Reference in New Issue
Block a user