Miethai 1: Geschäftslogik

Nach rund 10 Jahren bastele ich erstmals eine neue Django-App, diesmal geht es um Nebenkostenabrechnungen und damit erneut um Zeiträume. Datumskalkulationen sind offenbar mein Schicksal. Das Datenmodell ist auf den ersten Blick recht einfach: Jedes Gebäude besteht aus einer Anzahl Wohnungen. Mit jeder Wohnung ist zu jedem Zeitpunkt ein Mietvertrag (und indirekt eine Mieterin) sowie eine Reihe von Dienstleistungen (Heizung, Abwasser etc) verbunden. Für jede Dienstleistung gibt es einen Vertrag, dem Rechnungen zugeordnet sind. Außerdem sind in jeder Wohnung Zähler verbaut, die den Verbrauch für bestimmte Services messen. Jede Nebenkostenabrechnung enthält Abrechnungsposten, die sich jeweils auf einen Service und die zugehörige Rechnung beziehen.

Ganz so einfach ist es leider nicht, denn einige Dienstleistungen werden aufgeteilt und nach unterschiedlichen Schlüsseln abgerechnet. Entsprechend muss die Verbindung zwischen Contract und Service als ManyToManyField abgebildet und für jeden Service ein Rechnungsanteil (<= 100%) ausgewiesen werden:

Erschwerend kommt hinzu, dass in einem Fall die Einheit der Zählerstände nicht identisch ist mit den in Rechnung gestellten Verbrauchseinheiten – der Warmwasserverbrauch wird über die Wassermenge (in m³) erfasst, aber über die eingesetzte Energiemenge (kWh) abgerechnet. Entsprechend muss der Rechnung eine Information über den Verbrauch hinzugefügt werden:

class Invoice(models.Model): consumption = models.DecimalField('Leistung/Verbrauch', max_digits=10, decimal_places=3, blank=True, null=True) counter_consumption = models.DecimalField('Verbrauch laut Zähler', max_digits=10, decimal_places=3, blank=True, null=True)

Tatsächlich ist die Handhabung bestimmter Rechnungen noch komplexer: Die gelieferte Gasmenge wird für Heizung und Warmwasserbereitung eingesetzt und muss entsprechend nach Verbrauch aufgeteilt werden, bevor der feste Schlüssel (share_of_invoice) für die Grund- und Verbrauchsanteile der Heizkosten greift. Die Berücksichtigung dieses mehrstufigen Verfahrens würde das Datenmodell aber so sehr verkomplizieren, dass ich die Gasrechnungen im ersten Schritt manuell und als Teilrechnungen (Heizung/Warmwasser) erfasse.

Der wesentliche Teil der business logic für die Abrechnungen steckt im Service-Modell:

class Service(models.Model): SERVICE_TYPE_CHOICES = [ ('AR', 'Wohnfläche'), ('OC', 'Personen'), ('CO', 'Verbrauch'), ] service_type = models.CharField(max_length=2, choices=SERVICE_TYPE_CHOICES, default='AR') MEASUREMENT_UNIT_CHOICES = [ ('NA', 'n/a'), ('KW', 'kWh'), ('CB', 'm³'), ] measurement_unit = models.CharField(max_length=2, choices=MEASUREMENT_UNIT_CHOICES, default='NA') building = models.ForeignKey(Building, verbose_name='Gebäude', on_delete=models.CASCADE) name = models.CharField('Bezeichnung', max_length=100) share_of_invoice = models.DecimalField('Rechnungsanteil', max_digits=4, decimal_places=3, default=1)

Jedem service_type entspricht ein proxy model, dem das betreffende Objekt während der Initialisierung zugeordnet wird:

def __init__(self, *args, **kwargs): super(Service, self).__init__(*args, **kwargs) subclasses = { 'AR' : AreaService, 'OC' : OccupancyService, 'CO' : ConsumptionService, } if self.pk: self.__class__ = subclasses[self.service_type]

Die Kosten für einen AreaService werden entsprechend der anteiligen Wohnfläche umgelegt, für einen OccupancyService ist die Anzahl der Personen ausschlaggebend und für den ConsumptionService der tatsächliche Verbrauch.

Jedes der drei proxy models verwendet individuelle Versionen der Manager-Schnittstelle und der save()-Methode, um für Konsistenz zu sorgen:

class AreaServiceManager(models.Manager): def get_queryset(self): return super(AreaServiceManager, self).get_queryset().filter(service_type='AR') def create(self, **kwargs): kwargs.update({'service_type': 'AR'}) return super(AreaServiceManager, self).create(**kwargs) class AreaService(Service): objects = AreaServiceManager() def save(self, *args, **kwargs): self.service_type = 'AR' return super(AreaService, self).save(*args, **kwargs) class Meta: proxy = True

Das Modell für eine Nebenkostenabrechnung enthält nur wenige Felder, aber eine umfangreiche save()-Methode. Balance.save() nimmt zunächst Bezug auf alle Rechnungen im Abrechnungszeitraum, und ruft für jede Rechnung die create_balance_entries()-Methode aller Service-Objekte auf, die – vermittelt über einen Vertrag – mit der Rechnung verknüpft sind:

class Balance(models.Model): tenancy_agreement = models.ForeignKey(TenancyAgreement, verbose_name='Mietvertrag', on_delete=models.CASCADE) begin = models.DateField('Abrechnungszeitraum (Beginn)') end = models.DateField('Abrechnungszeitraum (Ende)') date = models.DateField('Abrechnungsdatum', auto_now_add=True) def save(self, *args, **kwargs): super().save(*args, **kwargs) invoices = Invoice.objects.filter(models.Q(contract__services__building=self.tenancy_agreement.unit.building), models.Q(begin__lte=self.begin, end__gt=self.begin) | models.Q(begin__gt=self.begin, begin__lt=self.end)).distinct() existing_entries = BalanceEntry.objects.filter(balance=self) if invoices and not existing_entries: entries = [] for invoice in invoices: for service in invoice.contract.services.all(): print('Creating entry...') entries = entries + service.create_balance_entries(invoice, self) for service, invoice, service_share, service_total, number_of_days, share_of_invoice in entries: print('Saving entry...') balance_entry = BalanceEntry(balance=self, service=service, invoice=invoice, days_share=number_of_days, service_share=service_share, service_total=service_total, share_of_invoice=share_of_invoice) balance_entry.save()

Die AreaService-Variante von create_balance_entries() ist am simpelsten, da sie nur die zeitliche Überlappung von Rechnung und Nebenkostenabrechnung errechnen muss:

def create_balance_entries(self, invoice, balance): latest_start = max(invoice.begin, balance.begin) earliest_end = min(invoice.end, balance.end) delta = (earliest_end - latest_start).days + 1 number_of_days = max(0, delta) return [(self, invoice, balance.tenancy_agreement.unit.area, self.building.area, number_of_days, self.share_of_invoice)]

Für einen OccupancyService muss dagegen berücksichtigt werden, dass (theoretisch) an jedem einzelnen Tag eine andere Relation von anteiliger Belegung und Gesamtbelegung existieren kann, und entsprechend mehrere Abrechnungsposten im Abrechnungszeitraum entstehen können:

def create_balance_entries(self, invoice, balance): latest_start = max(balance.begin, invoice.begin) earliest_end = min(balance.end, invoice.end) occupancy_dict = {} current_day = latest_start while current_day <= earliest_end: if balance.tenancy_agreement.unit.occupancy_key(current_day) in occupancy_dict: occupancy_dict[balance.tenancy_agreement.unit.occupancy_key(current_day)][2] += 1 else: occupancy_dict[balance.tenancy_agreement.unit.occupancy_key(current_day)] = [ balance.tenancy_agreement.unit.occupancy_unit(current_day), balance.tenancy_agreement.unit.occupancy_building(current_day), 1 ] current_day += datetime.timedelta(days=1) entries = [] for occupancy_share, occupancy_values in occupancy_dict.items(): unit_occupancy = occupancy_values[0] building_occupancy = occupancy_values[1] number_of_days = occupancy_values[2] entries.append((self, invoice, unit_occupancy, building_occupancy, number_of_days, self.share_of_invoice)) return entries

Ein verbrauchsbezogener ConsumptionService schließlich muss die Zählerstände für die einzelnen Wohnungen berücksichtigen und kann die zeitliche Dimension ignorieren:

def create_balance_entries(self, invoice, balance): counters = Counter.objects.filter(service=self, unit=balance.tenancy_agreement.unit) consumption = 0 for counter in counters: latest = CounterValue.objects.filter(counter=counter).latest('date') try: baseline = CounterValue.objects.get(counter=counter, current_baseline=True) print(f'Adding difference between {latest.value} and {baseline.value}') consumption += (latest.value - baseline.value) baseline.current_baseline=False baseline.save() except ObjectDoesNotExist: consumption += latest.value latest.current_baseline=True latest.save() if invoice.counter_consumption: consumption = consumption / invoice.counter_consumption * invoice.consumption number_of_days = invoice.days return [(self, invoice, consumption, invoice.consumption, number_of_days, self.share_of_invoice)]

Der Verbrauch wird aus der Summe der Differenzen zwischen Ausgangswert und aktuellem Zählerstand ermittelt und (ggf.) in die in Rechnung gestellte Einheit umgerechnet (m³ → kWh). Weil die aktuellen Zählerstände im Zuge der Berechnung als die neuen Ausgangswerte markiert werden, kann eine Nebenkostenabrechnung nur einmal erstellt werden. Die Berechnung des Rechnungsanteils funktioniert außerdem nur, wenn für jeden ConsumptionService im Abrechnungszeitraum genau eine Rechnung vorliegt, auf die der Verbrauch anhand der Zählerstände bezogen werden kann.