diff --git a/db.sqlite3 b/db.sqlite3 index 95ff630..35a321a 100644 Binary files a/db.sqlite3 and b/db.sqlite3 differ diff --git a/round_entries.py b/round_entries.py new file mode 100644 index 0000000..0c86f1b --- /dev/null +++ b/round_entries.py @@ -0,0 +1,48 @@ +from decimal import Decimal, ROUND_HALF_UP +import django +import os + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "excel_mimic.settings") +django.setup() + +from sheets.models import ExcelEntry + + +TWO_DEC = Decimal("0.00") + +def q2(x): + return Decimal(str(x)).quantize(TWO_DEC, rounding=ROUND_HALF_UP) + +fields = ["korrig_druck", "nm3", "lhe", "lhe_ges"] + +to_update = [] +total = 0 +changed_count = 0 + +qs = ExcelEntry.objects.all().only("id", *fields) + +for e in qs.iterator(chunk_size=500): + total += 1 + changed = False + + for f in fields: + val = getattr(e, f) + if val is not None: + new = q2(val) + if new != val: + setattr(e, f, new) + changed = True + + if changed: + to_update.append(e) + changed_count += 1 + + if len(to_update) >= 500: + ExcelEntry.objects.bulk_update(to_update, fields) + to_update = [] + print(f"processed={total}, changed={changed_count}") + +if to_update: + ExcelEntry.objects.bulk_update(to_update, fields) + +print(f"DONE. processed={total}, changed={changed_count}") diff --git a/sheets/migrations/0014_delete_rowcalculation_betriebskosten_gegenstand_and_more.py b/sheets/migrations/0014_delete_rowcalculation_betriebskosten_gegenstand_and_more.py new file mode 100644 index 0000000..74d549b --- /dev/null +++ b/sheets/migrations/0014_delete_rowcalculation_betriebskosten_gegenstand_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 5.1.4 on 2026-02-15 06:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sheets', '0013_monthlysummary'), + ] + + operations = [ + migrations.DeleteModel( + name='RowCalculation', + ), + migrations.AddField( + model_name='betriebskosten', + name='gegenstand', + field=models.CharField(default=1, max_length=200, verbose_name='Gegenstand'), + preserve_default=False, + ), + migrations.AlterField( + model_name='betriebskosten', + name='buchungsdatum', + field=models.DateField(verbose_name='Zahlungsdatum'), + ), + migrations.AlterField( + model_name='betriebskosten', + name='gas_volume', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Gasvolumen (m³)'), + ), + migrations.AlterField( + model_name='betriebskosten', + name='kostentyp', + field=models.CharField(choices=[('sach', 'Sach'), ('helium', 'Helium')], max_length=10, verbose_name='Kostentyp'), + ), + migrations.AlterField( + model_name='betriebskosten', + name='rechnungsnummer', + field=models.CharField(max_length=50, verbose_name='Firma'), + ), + ] diff --git a/sheets/migrations/0015_betriebskostensummary.py b/sheets/migrations/0015_betriebskostensummary.py new file mode 100644 index 0000000..cf8bf79 --- /dev/null +++ b/sheets/migrations/0015_betriebskostensummary.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.4 on 2026-02-15 07:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sheets', '0014_delete_rowcalculation_betriebskosten_gegenstand_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='BetriebskostenSummary', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('personalkosten', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('instandhaltung', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('heliumkosten', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('bezugskosten_gashe', models.DecimalField(decimal_places=4, default=0, max_digits=12)), + ('umlage_personal', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ], + ), + ] diff --git a/sheets/migrations/0016_abrechnungcell.py b/sheets/migrations/0016_abrechnungcell.py new file mode 100644 index 0000000..7c9bf1a --- /dev/null +++ b/sheets/migrations/0016_abrechnungcell.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.4 on 2026-02-15 11:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sheets', '0015_betriebskostensummary'), + ] + + operations = [ + migrations.CreateModel( + name='AbrechnungCell', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('interval_year', models.IntegerField()), + ('interval_start_month', models.IntegerField()), + ('row_key', models.CharField(max_length=60)), + ('col_key', models.CharField(max_length=60)), + ('value', models.DecimalField(blank=True, decimal_places=6, max_digits=18, null=True)), + ], + options={ + 'unique_together': {('interval_year', 'interval_start_month', 'row_key', 'col_key')}, + }, + ), + ] diff --git a/sheets/migrations/0017_alter_secondtableentry_options.py b/sheets/migrations/0017_alter_secondtableentry_options.py new file mode 100644 index 0000000..0d793b9 --- /dev/null +++ b/sheets/migrations/0017_alter_secondtableentry_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.5 on 2026-02-16 10:47 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('sheets', '0016_abrechnungcell'), + ] + + operations = [ + migrations.AlterModelOptions( + name='secondtableentry', + options={'ordering': ['date', 'id']}, + ), + ] diff --git a/sheets/migrations/0018_secondtableentry_nach_secondtableentry_vor.py b/sheets/migrations/0018_secondtableentry_nach_secondtableentry_vor.py new file mode 100644 index 0000000..71b1901 --- /dev/null +++ b/sheets/migrations/0018_secondtableentry_nach_secondtableentry_vor.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.5 on 2026-02-17 08:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sheets', '0017_alter_secondtableentry_options'), + ] + + operations = [ + migrations.AddField( + model_name='secondtableentry', + name='nach', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True), + ), + migrations.AddField( + model_name='secondtableentry', + name='vor', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True), + ), + ] diff --git a/sheets/models.py b/sheets/models.py index 2cb7f8f..09dddcc 100644 --- a/sheets/models.py +++ b/sheets/models.py @@ -73,47 +73,45 @@ class CellReference(models.Model): class Meta: unique_together = ['source_cell', 'target_cell'] +from decimal import Decimal +from django.db import models + class Betriebskosten(models.Model): KOSTENTYP_CHOICES = [ - ('sach', 'Sachkosten'), - ('ln2', 'LN2'), + ('sach', 'Sach'), ('helium', 'Helium'), - ('inv', 'Inventar'), ] - - buchungsdatum = models.DateField('Buchungsdatum') - rechnungsnummer = models.CharField('Rechnungsnummer', max_length=50) + gegenstand = models.CharField("Gegenstand", max_length=200) + buchungsdatum = models.DateField('Zahlungsdatum') + rechnungsnummer = models.CharField('Firma', max_length=50) kostentyp = models.CharField('Kostentyp', max_length=10, choices=KOSTENTYP_CHOICES) - gas_volume = models.DecimalField('Gasvolumen (Liter)', max_digits=10, decimal_places=2, null=True, blank=True) + + # IMPORTANT: now this field stores m³ (not liters) + gas_volume = models.DecimalField('Gasvolumen (m³)', max_digits=10, decimal_places=2, null=True, blank=True) + betrag = models.DecimalField('Betrag (€)', max_digits=10, decimal_places=2) beschreibung = models.TextField('Beschreibung', blank=True) - + @property - def price_per_liter(self): + def gas_volume_liter(self): + # Liter = m³ / 0.75 + if self.kostentyp == 'helium' and self.gas_volume: + return self.gas_volume / Decimal("0.75") + return None + + @property + def price_per_m3(self): if self.kostentyp == 'helium' and self.gas_volume: return self.betrag / self.gas_volume return None - - def __str__(self): - return f"{self.buchungsdatum} - {self.get_kostentyp_display()} - {self.betrag}€" -class RowCalculation(models.Model): - """Define calculations for specific rows""" - table_type = models.CharField(max_length=20, choices=[ - ('top_left', 'Top Left Table'), - ('top_right', 'Top Right Table'), - ('bottom_1', 'Bottom Table 1'), - ('bottom_2', 'Bottom Table 2'), - ('bottom_3', 'Bottom Table 3'), - ]) - row_index = models.IntegerField() # Which row has the formula - formula = models.TextField() # e.g., "row_10 + row_9" - description = models.CharField(max_length=200, blank=True) - - class Meta: - unique_together = ['table_type', 'row_index'] - - def __str__(self): - return f"{self.table_type}[{self.row_index}]: {self.formula}" + + @property + def price_per_liter(self): + # Preis(Liter) = betrag / liter + liters = self.gas_volume_liter + if self.kostentyp == 'helium' and liters: + return self.betrag / liters + return None # Or simpler: Just store row calculations in a JSONField class TableConfig(models.Model): @@ -201,6 +199,8 @@ class SecondTableEntry(models.Model): date = models.DateField(default=timezone.now) is_warm = models.BooleanField(default=False) lhe_delivery = models.CharField(max_length=100, blank=True, null=True) + vor = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + nach = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) lhe_output = models.DecimalField( max_digits=10, decimal_places=2, @@ -210,7 +210,8 @@ class SecondTableEntry(models.Model): ) notes = models.TextField(blank=True, null=True) date_joined = models.DateField(auto_now_add=True) - + class Meta: + ordering = ["date", "id"] def __str__(self): return f"{self.client.name} - {self.date}" @@ -250,4 +251,63 @@ class MonthlySummary(models.Model): ) def __str__(self): - return f"Summary {self.sheet.year}-{self.sheet.month:02d}" \ No newline at end of file + return f"Summary {self.sheet.year}-{self.sheet.month:02d}" + + +class BetriebskostenSummary(models.Model): + personalkosten = models.DecimalField(max_digits=12, decimal_places=2, default=0) + + instandhaltung = models.DecimalField(max_digits=12, decimal_places=2, default=0) + heliumkosten = models.DecimalField(max_digits=12, decimal_places=2, default=0) + bezugskosten_gashe = models.DecimalField(max_digits=12, decimal_places=4, default=0) + umlage_personal = models.DecimalField(max_digits=12, decimal_places=2, default=0) + + def recalculate(self): + from django.db.models import Sum + from django.db.models.functions import Coalesce + from django.db.models import DecimalField, Value + from .models import Betriebskosten + + items = Betriebskosten.objects.all() + + sach_sum = items.filter(kostentyp='sach').aggregate( + total=Coalesce(Sum('betrag'), Value(0, output_field=DecimalField())) + )['total'] or Decimal('0') + + helium_sum = items.filter(kostentyp='helium').aggregate( + total=Coalesce(Sum('betrag'), Value(0, output_field=DecimalField())) + )['total'] or Decimal('0') + + helium_m3_sum = items.filter(kostentyp='helium').aggregate( + total=Coalesce(Sum('gas_volume'), Value(0, output_field=DecimalField())) + )['total'] or Decimal('0') + + bezug = (helium_sum / helium_m3_sum) if helium_m3_sum not in (None, 0, Decimal('0')) else Decimal('0') + + self.instandhaltung = sach_sum + self.heliumkosten = helium_sum + self.bezugskosten_gashe = bezug + self.umlage_personal = self.personalkosten / 2 + self.save() + +# models.py +from django.db import models + +class AbrechnungCell(models.Model): + """ + Storage for the 'Abrechnung' page where columns are custom (not 1:1 with Client). + Values are saved by: (interval_year, interval_start_month, row_key, col_key). + """ + interval_year = models.IntegerField() + interval_start_month = models.IntegerField() # first month of the 6-month window + + row_key = models.CharField(max_length=60) + col_key = models.CharField(max_length=60) + + value = models.DecimalField(max_digits=18, decimal_places=6, null=True, blank=True) + + class Meta: + unique_together = ("interval_year", "interval_start_month", "row_key", "col_key") + + def __str__(self): + return f"Abrechnung {self.interval_year}/{self.interval_start_month} {self.row_key}:{self.col_key}={self.value}" diff --git a/sheets/new/views.py b/sheets/new/views.py new file mode 100644 index 0000000..d089af7 --- /dev/null +++ b/sheets/new/views.py @@ -0,0 +1,5041 @@ +from django.shortcuts import render, redirect +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.db.models import Sum, Value, DecimalField +from django.http import JsonResponse +from django.db.models import Q +from decimal import Decimal, InvalidOperation +from django.apps import apps +from datetime import date, datetime +import calendar +from django.utils import timezone +from django.views.generic import TemplateView, View +from .models import ( + Client, SecondTableEntry, Institute, ExcelEntry, + Betriebskosten, MonthlySheet, Cell, CellReference, MonthlySummary ,BetriebskostenSummary,AbrechnungCell +) +from django.db.models import Sum +from django.urls import reverse +from django.db.models.functions import Coalesce +from .forms import BetriebskostenForm +from django.utils.dateparse import parse_date +from django.contrib.auth.mixins import LoginRequiredMixin +import json +FIRST_SHEET_YEAR = 2025 +FIRST_SHEET_MONTH = 1 + +CLIENT_GROUPS = { + 'ikp': { + 'label': 'IKP', + # exactly as in the Clients admin + 'names': ['IKP'], + }, + 'phys_chem_bunt_fohrer': { + 'label': 'Buntkowsky + Dr. Fohrer', + # include all variants you might have used for Buntkowsky + 'names': [ + 'AG Buntk.', # the one in your new entry + 'AG Buntkowsky.', # from your original list + 'AG Buntkowsky', + 'Dr. Fohrer', + ], + }, + 'mawi_alff_gutfleisch': { + 'label': 'Alff + AG Gutfleisch', + # include both short and full forms + 'names': [ + 'AG Alff', + 'AG Gutfl.', + 'AG Gutfleisch', + ], + }, + 'm3_group': { + 'label': 'M3 Buntkowsky + M3 Thiele + M3 Gutfleisch', + 'names': [ + 'M3 Buntkowsky', + 'M3 Thiele', + 'M3 Gutfleisch', + ], + }, +} + +# Add this CALCULATION_CONFIG at the top of views.py + +CALCULATION_CONFIG = { + 'top_left': { + # Row mappings: Django row_index (0-based) to Excel row + # Excel B4 -> Django row_index 1 (UI row 2) + # Excel B5 -> Django row_index 2 (UI row 3) + # Excel B6 -> Django row_index 3 (UI row 4) + + # B6 (row_index 3) = B5 (row_index 2) / 0.75 + 3: "2 / 0.75", + + # B11 (row_index 10) = B9 (row_index 8) + 10: "8", + + # B14 (row_index 13) = B13 (row_index 12) - B11 (row_index 10) + B12 (row_index 11) + 13: "12 - 10 + 11", + + # Note: B5, B17, B19, B20 require IF logic, so they'll be handled separately + }, + + # other tables (top_right, bottom_1, ...) stay as they are + + + ' top_right': { + # UI Row 1 (Excel Row 4): Stand der Gaszähler (Vormonat) (Nm³) + 0: { + 'L': "9 / (9 + 9) if (9 + 9) > 0 else 0", # L4 = L13/(L13+M13) + 'M': "9 / (9 + 9) if (9 + 9) > 0 else 0", # M4 = M13/(L13+M13) + 'N': "9 / (9 + 9) if (9 + 9) > 0 else 0", # N4 = N13/(N13+O13) + 'O': "9 / (9 + 9) if (9 + 9) > 0 else 0", # O4 = O13/(N13+O13) + 'P': None, # Editable + 'Q': None, # Editable + 'R': None, # Editable + }, + + # UI Row 2 (Excel Row 5): Gasrückführung (Nm³) + 1: { + 'L': "4", # L5 = L8 + 'M': "4", # M5 = L8 (merged) + 'N': "4", # N5 = N8 + 'O': "4", # O5 = N8 (merged) + 'P': "4 * 0", # P5 = P8 * P4 + 'Q': "4 * 0", # Q5 = P8 * Q4 + 'R': "4 * 0", # R5 = P8 * R4 + }, + + # UI Row 3 (Excel Row 6): Rückführung flüssig (Lit. L-He) + 2: { + 'L': "4", # L6 = L8 (Sammelrückführungen) + 'M': "4", + 'N': "4", + 'O': "4", + 'P': "4", + 'Q': "4", + 'R': "4", +}, + + # UI Row 4 (Excel Row 7): Sonderrückführungen (Lit. L-He) - EDITABLE + 3: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 5 (Excel Row 8): Sammelrückführungen (Lit. L-He) + 4: { + 'L': None, # Will be populated from ExcelEntry + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 6 (Excel Row 9): Bestand in Kannen-1 (Lit. L-He) - EDITABLE + 5: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 7 (Excel Row 10): Summe Bestand (Lit. L-He) + 6: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 11 (Excel Row 14): Rückführ. Soll (Lit. L-He) + # handled in calculate_top_right_dependents (merged pairs + M3) + 10: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 12 (Excel Row 15): Verluste (Soll-Rückf.) (Lit. L-He) + # handled in calculate_top_right_dependents + 11: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 14 (Excel Row 17): Kaltgas Rückgabe (Lit. L-He) – Faktor + # handled in calculate_top_right_dependents (different formulas for pair 1 vs pair 2 + M3) + 13: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 16 (Excel Row 19): Verbraucherverluste (Liter L-He) + # handled in calculate_top_right_dependents + 15: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 17 (Excel Row 20): % + # handled in calculate_top_right_dependents + 16: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + } +}, + 'bottom_1': { + 5: "4 + 3 + 2", + 8: "7 - 6", + }, + 'bottom_2': { + 3: "1 + 2", + 6: "5 - 4", + }, + 'bottom_3': { + 2: "0 + 1", + 5: "3 + 4", + }, + # Special configuration for summation column (last column) + 'summation_column': { + # For each row that should be summed across columns + 'rows_to_sum': [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23], # All rows + # OR specify specific rows: + # 'rows_to_sum': [0, 5, 10, 15, 20], # Only specific rows + # The last column index (0-based) + 'sum_column_index': 5, # 6th column (0-5) since you have 6 clients + } +} +def build_halfyear_window(interval_year: int, start_month: int): + """ + Build a list of (year, month) for the 6-month interval, possibly crossing into the next year. + Example: (2025, 10) -> [(2025,10), (2025,11), (2025,12), (2026,1), (2026,2), (2026,3)] + """ + window = [] + for offset in range(6): + total_index = (start_month - 1) + offset # 0-based + y = interval_year + (total_index // 12) + m = (total_index % 12) + 1 + window.append((y, m)) + return window +# --------------------------------------------------------------------------- +# Halbjahres-Bilanz helpers +# --------------------------------------------------------------------------- + +# You can adjust these indices if needed. +# Assuming: +# - bottom_1.table has row "Gasbestand" at some fixed row index, +# and columns: ... Nm³, Lit. LHe +GASBESTAND_ROW_INDEX = 9 # <-- adjust if your bottom_1 has a different row index +GASBESTAND_COL_NM3 = 3 # <-- adjust to the column index for Nm³ in bottom_1 + +# In top_left / top_right, "Bestand in Kannen-1 (Lit. L-He)" is row_index 5 +BESTAND_KANNEN_ROW_INDEX = 5 + +def pick_sheet_by_gasbestand(window, sheets_by_ym, prev_sheet): + """ + Returns the last sheet in the window whose Gasbestand (J36, Nm³ column) != 0. + If none found, returns prev_sheet (Übertrag_Dez__Vorjahr equivalent). + """ + for (y, m) in reversed(window): + sheet = sheets_by_ym.get((y, m)) + if not sheet: + continue + gasbestand_nm3 = get_bottom1_value(sheet, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) + if gasbestand_nm3 != 0: + return sheet + return prev_sheet +def get_bottom1_value(sheet, row_index: int, col_index: int) -> Decimal: + """Get a numeric value from bottom_1, or 0 if missing.""" + if sheet is None: + return Decimal('0') + + cell = Cell.objects.filter( + sheet=sheet, + table_type='bottom_1', + row_index=row_index, + column_index=col_index, + ).first() + + if cell is None or cell.value in (None, ''): + return Decimal('0') + + try: + return Decimal(str(cell.value)) + except Exception: + return Decimal('0') + +# MUST match the column order in your monthly_sheets top-right table + + + +def get_top_right_value(sheet, client_name: str, row_index: int) -> Decimal: + """ + Read a numeric value from the top_right table of a MonthlySheet for + a given client (by column) and row_index. + + top_right cells are keyed by (sheet, table_type='top_right', + row_index, column_index), where column_index is the position of the + client in HALFYEAR_RIGHT_CLIENTS. + """ + if sheet is None: + return Decimal('0') + + col_index = RIGHT_CLIENT_INDEX.get(client_name) + if col_index is None: + return Decimal('0') + + cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + row_index=row_index, + column_index=col_index, + ).first() + + if cell is None or cell.value in (None, ''): + return Decimal('0') + + try: + return Decimal(str(cell.value)) + except Exception: + return Decimal('0') + +TR_RUECKF_FLUESSIG_ROW = 2 # confirmed by your March value 172.840560 +TR_BESTAND_KANNEN_ROW = 5 # confirmed by your earlier query +def get_bestand_kannen_for_month(sheet, client_name: str) -> Decimal: + """ + 'B9' in your description: Bestand in Kannen-1 (Lit. L-He) + For this implementation we take it from top_left row_index = 5 for that client. + """ + return get_top_left_value(sheet, client_name, row_index=BESTAND_KANNEN_ROW_INDEX) + +from decimal import Decimal +from django.db.models import Sum +from django.db.models.functions import Coalesce +from django.db.models import DecimalField, Value + +from .models import MonthlySheet, SecondTableEntry, Client, Cell +from django.shortcuts import redirect, render + +# You already have HALFYEAR_CLIENTS for the left table (AG Vogel, AG Halfm, IKP) +HALFYEAR_CLIENTS = ["AG Vogel", "AG Halfm", "IKP"] + +# NEW: clients for the top-right half-year table +HALFYEAR_RIGHT_CLIENTS = [ + "Dr. Fohrer", + "AG Buntk.", + "AG Alff", + "AG Gutfl.", + "M3 Thiele", + "M3 Buntkowsky", + "M3 Gutfleisch", +] +BOTTOM1_COL_VOLUME = 0 +BOTTOM1_COL_BAR = 1 +BOTTOM1_COL_KORR = 2 +BOTTOM1_COL_NM3 = 3 +BOTTOM1_COL_LHE = 4 +BOTTOM2_ROW_ANLAGE = 0 +BOTTOM2_COL_G39 = 0 # "Gefäss 2,5" (cell id shows column_index=0) +BOTTOM2_COL_I39 = 1 # "Gefäss 1,0" (cell id shows column_index=1) +BOTTOM2_ROW_INPUTS = { + "g39": (0, 0), # row_index=0, column_index=0 (your G39) + "i39": (0, 1), # row_index=0, column_index=1 (your I39) +} +FACTOR_NM3_TO_LHE = Decimal("0.75") +RIGHT_CLIENT_INDEX = {name: idx for idx, name in enumerate(HALFYEAR_RIGHT_CLIENTS)} +def halfyear_balance_view(request): + """ + Read-only Halbjahres-Bilanz view. + + LEFT table: AG Vogel / AG Halfm / IKP (exactly as in your last working version) + RIGHT table: Dr. Fohrer / AG Buntk. / AG Alff / AG Gutfl. / + M3 Thiele / M3 Buntkowsky / M3 Gutfleisch + using the Excel formulas you described. + + Uses the global 6-month interval from the main page (clients_list). + """ + # 1) Read half-year interval from the session + interval_year = request.session.get('halfyear_year') + interval_start = request.session.get('halfyear_start_month') + + if not interval_year or not interval_start: + # No interval chosen yet -> redirect to main page + return redirect('clients_list') + + interval_year = int(interval_year) + interval_start = int(interval_start) + + # You already have this helper in your code + window = build_halfyear_window(interval_year, interval_start) + # window = [(y1, m1), (y2, m2), ..., (y6, m6)] + + # (Year, month) of the first month + start_year, start_month = window[0] + + # Previous month (for "Stand ... (Vorjahr)" and "Best. in Kannen Vormonat") + prev_total_index = (start_month - 1) - 1 # one month back, 0-based + if prev_total_index >= 0: + prev_year = start_year + (prev_total_index // 12) + prev_month = (prev_total_index % 12) + 1 + else: + prev_year = start_year - 1 + prev_month = 12 + + # Load MonthlySheet objects for the window and for the previous month + sheets_by_ym = {} + for (y, m) in window: + sheet = MonthlySheet.objects.filter(year=y, month=m).first() + sheets_by_ym[(y, m)] = sheet + + prev_sheet = MonthlySheet.objects.filter(year=prev_year, month=prev_month).first() + def pick_bottom2_from_window(window, sheets_by_ym, prev_sheet): + # choose sheet (same logic you already use) + chosen = None + for (y, m) in reversed(window): + s = sheets_by_ym.get((y, m)) + # use your existing condition for choosing month + if s: + chosen = s + break + if chosen is None: + chosen = prev_sheet + + # Now read the two inputs safely + bottom2_inputs = {} + for key, (row_idx, col_idx) in BOTTOM2_ROW_INPUTS.items(): + bottom2_inputs[key] = get_bottom2_value(chosen, row_idx, col_idx) + + return chosen, bottom2_inputs + + + chosen_sheet_bottom2, bottom2_inputs = pick_bottom2_from_window(window, sheets_by_ym, prev_sheet) + bottom2_g39 = bottom2_inputs["g39"] + bottom2_i39 = bottom2_inputs["i39"] + # ---------------------------- + # HALF-YEAR BOTTOM TABLE 1 (Bilanz) - Read only + # ---------------------------- + chosen_sheet_bottom1 = pick_sheet_by_gasbestand(window, sheets_by_ym, prev_sheet) + + # IMPORTANT: define which bottom_1 row_index corresponds to Excel rows 27..35 + # If your bottom_1 starts at Excel row 27 => row_index 0 == Excel 27 + # then row_index = excel_row - 27 + BOTTOM1_EXCEL_START_ROW = 27 + + bottom1_excel_rows = list(range(27, 37)) # 27..36 + BOTTOM1_LABELS = [ + "Batterie 1", + "2", + "3", + "4", + "5", + "Batterie Links", + "2 Bündel", + "2 Ballone", + "Reingasspeicher", + "Gasbestand", + ] + + BOTTOM1_VOLUMES = [ + Decimal("2.4"), + Decimal("5.1"), + Decimal("4.0"), + Decimal("1.0"), + Decimal("4.0"), + Decimal("0.6"), + Decimal("1.2"), + Decimal("20.0"), + Decimal("5.0"), + None, # Gasbestand row has no volume + ] + nm3_sum_27_35 = Decimal("0") + lhe_sum_27_35 = Decimal("0") + bottom1_rows = [] + + for excel_row in bottom1_excel_rows: + row_index = excel_row - BOTTOM1_EXCEL_START_ROW + + chosen_sheet_bottom1 = None + for (y, m) in reversed(window): + s = sheets_by_ym.get((y, m)) + gasbestand = get_bottom1_value(s, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) # J36 (Nm3) + if gasbestand != 0: + chosen_sheet_bottom1 = s + break + + if chosen_sheet_bottom1 is None: + chosen_sheet_bottom1 = prev_sheet + + # Normal rows (27..35): read from chosen sheet and accumulate sums + if excel_row != 36: + nm3_val = get_bottom1_value(chosen_sheet_bottom1, row_index, BOTTOM1_COL_NM3) + lhe_val = get_bottom1_value(chosen_sheet_bottom1, row_index, BOTTOM1_COL_LHE) + + nm3_sum_27_35 += nm3_val + lhe_sum_27_35 += lhe_val + + bottom1_rows.append({ + "label": BOTTOM1_LABELS[row_index], + "volume": BOTTOM1_VOLUMES[row_index], + "bar": get_bottom1_value(chosen_sheet_bottom1, row_index, BOTTOM1_COL_BAR), + "korr": get_bottom1_value(chosen_sheet_bottom1, row_index, BOTTOM1_COL_KORR), + "nm3": nm3_val, + "lhe": lhe_val, + }) + + # Gasbestand row (36): show sums (J36 = SUM(J27:J35), K36 = SUM(K27:K35)) + else: + bottom1_rows.append({ + "label": "Gasbestand", + "volume": "", + "bar": "", + "korr": "", + "nm3": nm3_sum_27_35, + "lhe": lhe_sum_27_35, + }) + start_sheet = sheets_by_ym.get((start_year, start_month)) + # ------------------------------------------------------------ + # Bottom Table 2 (Halbjahres Bilanz) – server-side recalcBottom2() + # ------------------------------------------------------------ + + FACTOR_BT2 = Decimal("0.75") + + # 1) Helper: pick last-nonzero value of bottom_2 row0 col0/col1 from the window (fallback: prev_sheet) + def pick_last_nonzero_bottom2(row_index: int, col_index: int) -> Decimal: + # Scan from last month in window backwards + for (y, m) in reversed(window): + s = sheets_by_ym.get((y, m)) + if not s: + continue + v = get_bottom2_value(s, row_index, col_index) + if v is not None and v != 0: + return v + # fallback to month before window + v_prev = get_bottom2_value(prev_sheet, row_index, col_index) + return v_prev if v_prev is not None else Decimal("0") + + # 2) K38 comes from Overall Summary: "Summe Bestand (Lit. L-He)" + # Find it from your already built overall summary rows list. + + k38 = Decimal("0") + j38 = Decimal("0") + # 3) Inputs G39 / I39 (picked from last non-zero month in window) + g39 = pick_last_nonzero_bottom2(row_index=0, col_index=0) # G39 + i39 = pick_last_nonzero_bottom2(row_index=0, col_index=1) # I39 + + k39 = (g39 or Decimal("0")) + (i39 or Decimal("0")) + j39 = k39 * FACTOR_BT2 + + # 4) +Kaltgas (row 40) + # JS: + # g40 = (2500 - g39)/100*10 + # i40 = (1000 - i39)/100*10 + g40 = None + i40 = None + if g39 is not None: + g40 = (Decimal("2500") - g39) / Decimal("100") * Decimal("10") + if i39 is not None: + i40 = (Decimal("1000") - i39) / Decimal("100") * Decimal("10") + + k40 = (g40 or Decimal("0")) + (i40 or Decimal("0")) + j40 = k40 * FACTOR_BT2 + + # 5) Bestand flüssig He (row 43) + k43 = ( + (k38 or Decimal("0")) + + (k39 or Decimal("0")) + + (k40 or Decimal("0")) + ) + j43 = k43 * FACTOR_BT2 + + # 6) Gesamtbestand neu (row 44) = Gasbestand(Lit) from Bottom Table 1 + k43 + gasbestand_lit = Decimal("0") + for r in bottom1_rows: + if (r.get("label") or "").strip().startswith("Gasbestand"): + gasbestand_lit = r.get("lhe") or Decimal("0") + break + + k44 = (gasbestand_lit or Decimal("0")) + (k43 or Decimal("0")) + j44 = k44 * FACTOR_BT2 + + bottom2 = { + "j38": j38, "k38": k38, + "g39": g39, "i39": i39, "j39": j39, "k39": k39, + "g40": g40, "i40": i40, "j40": j40, "k40": k40, + "j43": j43, "k43": k43, + "j44": j44, "k44": k44, + } + + # ------------------------------------------------------------------ + # 2) LEFT TABLE (your existing, working logic) + # ------------------------------------------------------------------ + HALFYEAR_CLIENTS_LEFT = ["AG Vogel", "AG Halfm", "IKP"] + + # We'll collect client-wise values first for clarity. + client_data_left = {name: {} for name in HALFYEAR_CLIENTS_LEFT} + + # --- Row B3: Stand der Gaszähler (Nm³) + # = MAX(B3 from previous month, and B3 from each of the 6 months in the window) + # row_index 0 in top_left = "Stand der Gaszähler (Nm³)" + months_for_max = [(prev_year, prev_month)] + window + + for cname in HALFYEAR_CLIENTS_LEFT: + max_val = Decimal('0') + for (y, m) in months_for_max: + sheet = sheets_by_ym.get((y, m)) + if sheet is None and (y, m) == (prev_year, prev_month): + sheet = prev_sheet + val_b3 = get_top_left_value(sheet, cname, row_index=0) + if val_b3 > max_val: + max_val = val_b3 + client_data_left[cname]['stand_gas'] = max_val + + # --- Row B4: Stand der Gaszähler (Vorjahr) (Nm³) -> previous month same row --- + for cname in HALFYEAR_CLIENTS_LEFT: + val_b4 = get_top_left_value(prev_sheet, cname, row_index=0) + client_data_left[cname]['stand_gas_prev'] = val_b4 + + # --- Row B5: Gasrückführung (Nm³) = B3 - B4 --- + for cname in HALFYEAR_CLIENTS_LEFT: + b3 = client_data_left[cname]['stand_gas'] + b4 = client_data_left[cname]['stand_gas_prev'] + client_data_left[cname]['gasrueckf'] = b3 - b4 + + # --- Row B6: Rückführung flüssig (Lit. L-He) = B5 / 0.75 --- + for cname in HALFYEAR_CLIENTS_LEFT: + b5 = client_data_left[cname]['gasrueckf'] + client_data_left[cname]['rueckf_fluessig'] = (b5 / Decimal('0.75')) if b5 != 0 else Decimal('0') + + # --- Row B7: Sonderrückführungen (Lit. L-He) = sum over 6 months of that row --- + # That row index is 4 in your top_left table. + for cname in HALFYEAR_CLIENTS_LEFT: + sonder_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + sonder_total += get_top_left_value(sheet, cname, row_index=4) + client_data_left[cname]['sonder'] = sonder_total + + # --- Row B8: Bestand in Kannen-1 (Lit. L-He) --- + # Excel-style logic with Gasbestand (J36) and fallback to previous month. + for cname in HALFYEAR_CLIENTS_LEFT: + chosen_value = None + + # Go from last month (window[5]) backwards to first (window[0]) + for (y, m) in reversed(window): + sheet = sheets_by_ym.get((y, m)) + gasbestand = get_bottom1_value(sheet, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) + if gasbestand != 0: + chosen_value = get_bestand_kannen_for_month(sheet, cname) + break + + # If still None -> use previous month (Übertrag_Dez__Vorjahr equivalent) + if chosen_value is None: + sheet_prev = prev_sheet + chosen_value = get_bestand_kannen_for_month(sheet_prev, cname) + + client_data_left[cname]['bestand_kannen'] = chosen_value if chosen_value is not None else Decimal('0') + + # --- Row B9: Summe Bestand (Lit. L-He) = equal to previous row --- + for cname in HALFYEAR_CLIENTS_LEFT: + client_data_left[cname]['summe_bestand'] = client_data_left[cname]['bestand_kannen'] + + # --- Row B10: Best. in Kannen Vormonat (Lit. L-He) + # = Bestand in Kannen-1 from the month BEFORE the window (prev_year, prev_month) + for cname in HALFYEAR_CLIENTS_LEFT: + client_data_left[cname]['best_kannen_vormonat'] = get_bestand_kannen_for_month(prev_sheet, cname) + + # --- Row B13: Bezug (Liter L-He) --- + for cname in HALFYEAR_CLIENTS_LEFT: + total_bezug = Decimal('0') + for (y, m) in window: + qs = SecondTableEntry.objects.filter( + client__name=cname, + date__year=y, + date__month=m, + ).aggregate( + total=Coalesce(Sum('lhe_output'), Value(0, output_field=DecimalField())) + ) + total_bezug += Decimal(str(qs['total'])) + client_data_left[cname]['bezug'] = total_bezug + + # --- Row B14: Rückführ. Soll (Lit. L-He) = Bezug - Summe Bestand + Best. in Kannen Vormonat --- + for cname in HALFYEAR_CLIENTS_LEFT: + b13 = client_data_left[cname]['bezug'] + b11 = client_data_left[cname]['summe_bestand'] + b12 = client_data_left[cname]['best_kannen_vormonat'] + client_data_left[cname]['rueckf_soll'] = b13 - b11 + b12 + + # --- Row B15: Verluste (Soll-Rückf.) (Lit. L-He) = B14 - B6 --- + for cname in HALFYEAR_CLIENTS_LEFT: + b14 = client_data_left[cname]['rueckf_soll'] + b6 = client_data_left[cname]['rueckf_fluessig'] + client_data_left[cname]['verluste'] = b14 - b6 + + # --- Row B16: Füllungen warm (Lit. L-He) = sum over 6 months (row_index=11) --- + for cname in HALFYEAR_CLIENTS_LEFT: + total_warm = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + total_warm += get_top_left_value(sheet, cname, row_index=11) + client_data_left[cname]['fuellungen_warm'] = total_warm + + # --- Row B17: Kaltgas Rückgabe (Lit. L-He) = Bezug * 0.06 --- + factor = Decimal('0.06') + for cname in HALFYEAR_CLIENTS_LEFT: + b13 = client_data_left[cname]['bezug'] + client_data_left[cname]['kaltgas_rueckgabe'] = b13 * factor + + # --- Row B18: Verbraucherverluste (Liter L-He) = Verluste - Kaltgas Rückgabe --- + for cname in HALFYEAR_CLIENTS_LEFT: + b15 = client_data_left[cname]['verluste'] + b17 = client_data_left[cname]['kaltgas_rueckgabe'] + client_data_left[cname]['verbraucherverluste'] = b15 - b17 + + # --- Row B19: % = Verbraucherverluste / Bezug --- + for cname in HALFYEAR_CLIENTS_LEFT: + bezug = client_data_left[cname]['bezug'] + verb = client_data_left[cname]['verbraucherverluste'] + if bezug != 0: + client_data_left[cname]['percent'] = verb / bezug + else: + client_data_left[cname]['percent'] = None + + # Build LEFT rows structure + left_row_defs = [ + ('Stand der Gaszähler (Nm³)', 'stand_gas'), + ('Stand der Gaszähler (Vorjahr) (Nm³)', 'stand_gas_prev'), + ('Gasrückführung (Nm³)', 'gasrueckf'), + ('Rückführung flüssig (Lit. L-He)', 'rueckf_fluessig'), + ('Sonderrückführungen (Lit. L-He)', 'sonder'), + ('Bestand in Kannen-1 (Lit. L-He)', 'bestand_kannen'), + ('Summe Bestand (Lit. L-He)', 'summe_bestand'), + ('Best. in Kannen Vormonat (Lit. L-He)', 'best_kannen_vormonat'), + ('Bezug (Liter L-He)', 'bezug'), + ('Rückführ. Soll (Lit. L-He)', 'rueckf_soll'), + ('Verluste (Soll-Rückf.) (Lit. L-He)', 'verluste'), + ('Füllungen warm (Lit. L-He)', 'fuellungen_warm'), + ('Kaltgas Rückgabe (Lit. L-He) – Faktor', 'kaltgas_rueckgabe'), + ('Verbraucherverluste (Liter L-He)', 'verbraucherverluste'), + ('%', 'percent'), + ] + + rows_left = [] + for label, key in left_row_defs: + values = [client_data_left[cname][key] for cname in HALFYEAR_CLIENTS_LEFT] + if key == 'percent': + total_bezug = sum((client_data_left[c]['bezug'] for c in HALFYEAR_CLIENTS_LEFT), Decimal('0')) + total_verb = sum((client_data_left[c]['verbraucherverluste'] for c in HALFYEAR_CLIENTS_LEFT), Decimal('0')) + total = (total_verb / total_bezug) if total_bezug != 0 else None + else: + total = sum((v for v in values if v is not None), Decimal('0')) + rows_left.append({ + 'label': label, + 'values': values, + 'total': total, + 'is_percent': key == 'percent', + }) + + # ------------------------------------------------------------------ + # 3) RIGHT TABLE (top-right half-year aggregation) + # ------------------------------------------------------------------ + RIGHT_CLIENTS = HALFYEAR_RIGHT_CLIENTS # for brevity + + right_data = {name: {} for name in RIGHT_CLIENTS} + + # --- Bezug (Liter L-He) for each right client (same as for left) --- + for cname in RIGHT_CLIENTS: + total_bezug = Decimal('0') + for (y, m) in window: + qs = SecondTableEntry.objects.filter( + client__name=cname, + date__year=y, + date__month=m, + ).aggregate( + total=Coalesce(Sum('lhe_output'), Value(0, output_field=DecimalField())) + ) + total_bezug += Decimal(str(qs['total'])) + right_data[cname]['bezug'] = total_bezug + def find_bestand_from_window(reference_client: str) -> Decimal: + """ + Implements: + WENN(last_month!J36=0; WENN(prev_month!J36=0; ...; prev_sheet!9); last_month!9) + reference_client decides which column (L/N/P/Q/R) we read from monthly top_right row_index=5. + """ + # scan backward through window + for (y, m) in reversed(window): + sh = sheets_by_ym.get((y, m)) + if not sh: + continue + gasbestand = get_bottom1_value(sh, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) + if gasbestand != 0: + return get_top_right_value(sh, reference_client, TR_BESTAND_KANNEN_ROW) + + # fallback to previous month (Übertrag_Dez__Vorjahr equivalent) + return get_top_right_value(prev_sheet, reference_client, TR_BESTAND_KANNEN_ROW) + + # Fohrer+Buntk merged: BOTH use Fohrer column (L9) + val_L = find_bestand_from_window("Dr. Fohrer") + right_data["Dr. Fohrer"]["bestand_kannen"] = val_L + right_data["AG Buntk."]["bestand_kannen"] = val_L + + # Alff+Gutfl merged: BOTH use Alff column (N9) + val_N = find_bestand_from_window("AG Alff") + right_data["AG Alff"]["bestand_kannen"] = val_N + right_data["AG Gutfl."]["bestand_kannen"] = val_N + + # M3 each uses its own column (P9/Q9/R9) + right_data["M3 Thiele"]["bestand_kannen"] = find_bestand_from_window("M3 Thiele") + right_data["M3 Buntkowsky"]["bestand_kannen"] = find_bestand_from_window("M3 Buntkowsky") + right_data["M3 Gutfleisch"]["bestand_kannen"] = find_bestand_from_window("M3 Gutfleisch") + # Helper for pair shares (L13/($L13+$M13), etc.) + def pair_share(c1, c2): + total = right_data[c1]['bezug'] + right_data[c2]['bezug'] + if total == 0: + return (Decimal('0'), Decimal('0')) + return ( + right_data[c1]['bezug'] / total, + right_data[c2]['bezug'] / total, + ) + + # --- "Stand der Gaszähler (Vorjahr) (Nm³)" row: share based on Bezug --- + # Dr. Fohrer / AG Buntk. + s_fohrer, s_buntk = pair_share("Dr. Fohrer", "AG Buntk.") + right_data["Dr. Fohrer"]['stand_prev_share'] = s_fohrer + right_data["AG Buntk."]['stand_prev_share'] = s_buntk + + # AG Alff / AG Gutfl. + s_alff, s_gutfl = pair_share("AG Alff", "AG Gutfl.") + right_data["AG Alff"]['stand_prev_share'] = s_alff + right_data["AG Gutfl."]['stand_prev_share'] = s_gutfl + + # M3 Thiele / M3 Buntkowsky / M3 Gutfleisch → empty in Excel → None + for cname in ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"]: + right_data[cname]['stand_prev_share'] = None + + # --- Rückführung flüssig per month (raw sums) --- + # top_right row_index=2 is "Rückführung flüssig (Lit. L-He)" + + # --- Sonderrückführungen (row_index=3 in top_right) --- + for cname in RIGHT_CLIENTS: + sonder_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + sonder_total += get_top_right_value(sheet, cname, row_index=3) + right_data[cname]['sonder'] = sonder_total + + # --- Sammelrückführung (row_index=4 in top_right), grouped & merged --- + # Group 1: Dr. Fohrer + AG Buntk. + group1_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + group1_total += get_top_right_value(sheet, "Dr. Fohrer", row_index=4) + right_data["Dr. Fohrer"]['sammel'] = group1_total + right_data["AG Buntk."]['sammel'] = group1_total + + # Group 2: AG Alff + AG Gutfl. + group2_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + group2_total += get_top_right_value(sheet, "AG Alff", row_index=4) + right_data["AG Alff"]['sammel'] = group2_total + right_data["AG Gutfl."]['sammel'] = group2_total + + # Group 3: M3 Thiele + M3 Buntkowsky + M3 Gutfleisch + group3_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + group3_total += get_top_right_value(sheet, "M3 Thiele", row_index=4) + right_data["M3 Thiele"]['sammel'] = group3_total + right_data["M3 Buntkowsky"]['sammel'] = group3_total + right_data["M3 Gutfleisch"]['sammel'] = group3_total + def safe_div(a: Decimal, b: Decimal) -> Decimal: + return (a / b) if b != 0 else Decimal("0") + + # --- Rückführung flüssig (Lit. L-He) for Halbjahres-Bilanz top-right --- + # Uses your exact formulas. + + # 1) Fohrer / Buntk split by BEZUG share times group SAMMEL (L8) + L13 = right_data["Dr. Fohrer"]["bezug"] + M13 = right_data["AG Buntk."]["bezug"] + L8 = right_data["Dr. Fohrer"]["sammel"] # merged group total + + den = (L13 + M13) + right_data["Dr. Fohrer"]["rueckf_fluessig"] = (safe_div(L13, den) * L8) if den != 0 else Decimal("0") + right_data["AG Buntk."]["rueckf_fluessig"] = (safe_div(M13, den) * L8) if den != 0 else Decimal("0") + + # 2) Alff / Gutfl split by BEZUG share times group SAMMEL (N8) + N13 = right_data["AG Alff"]["bezug"] + O13 = right_data["AG Gutfl."]["bezug"] + N8 = right_data["AG Alff"]["sammel"] # merged group total + + den = (N13 + O13) + right_data["AG Alff"]["rueckf_fluessig"] = (safe_div(N13, den) * N8) if den != 0 else Decimal("0") + right_data["AG Gutfl."]["rueckf_fluessig"] = (safe_div(O13, den) * N8) if den != 0 else Decimal("0") + + # 3) M3 Thiele = sum of monthly Rückführung flüssig (monthly top_right row_index=2) over window + P6_sum = Decimal("0") + for (y, m) in window: + sh = sheets_by_ym.get((y, m)) + P6_sum += get_top_right_value(sh, "M3 Thiele", TR_RUECKF_FLUESSIG_ROW) + right_data["M3 Thiele"]["rueckf_fluessig"] = P6_sum + + # 4) M3 Buntkowsky / M3 Gutfleisch split by BEZUG share times M3-group SAMMEL (P8) + P13 = right_data["M3 Thiele"]["bezug"] + Q13 = right_data["M3 Buntkowsky"]["bezug"] + R13 = right_data["M3 Gutfleisch"]["bezug"] + P8 = right_data["M3 Thiele"]["sammel"] # merged group total + + den = (P13 + Q13 + R13) + right_data["M3 Buntkowsky"]["rueckf_fluessig"] = (safe_div(Q13, den) * P8) if den != 0 else Decimal("0") + right_data["M3 Gutfleisch"]["rueckf_fluessig"] = (safe_div(R13, den) * P8) if den != 0 else Decimal("0") + # --- Bestand in Kannen-1 (Lit. L-He) for right table (grouped) --- + # Use Gasbestand (J36) and fallback logic, but now reading top_right B9 for each group. + TOP_RIGHT_ROW_BESTAND_KANNEN = 6 # <-- most likely correct in your setup + + def pick_bestand_top_right(base_client: str) -> Decimal: + # Go from last month in window backwards: if Gasbestand != 0, use that month's Bestand in Kannen + for (y, m) in reversed(window): + sh = sheets_by_ym.get((y, m)) + if not sh: + continue + + gasbestand = get_bottom1_value(sh, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) + if gasbestand != 0: + return get_top_right_value(sh, base_client, TOP_RIGHT_ROW_BESTAND_KANNEN) + + # Fallback to previous month (Übertrag_Dez__Vorjahr equivalent) + return get_top_right_value(prev_sheet, base_client, TOP_RIGHT_ROW_BESTAND_KANNEN) + + # Group 1 merged (Fohrer + Buntk.) + g1_best = pick_bestand_top_right("Dr. Fohrer") + right_data["Dr. Fohrer"]["bestand_kannen"] = g1_best + right_data["AG Buntk."]["bestand_kannen"] = g1_best + + # Group 2 merged (Alff + Gutfl.) + g2_best = pick_bestand_top_right("AG Alff") + right_data["AG Alff"]["bestand_kannen"] = g2_best + right_data["AG Gutfl."]["bestand_kannen"] = g2_best + + # Group 3 merged (M3 Thiele + M3 Buntkowsky + M3 Gutfleisch) + g3_best = pick_bestand_top_right("M3 Thiele") + right_data["M3 Thiele"]["bestand_kannen"] = g3_best + right_data["M3 Buntkowsky"]["bestand_kannen"] = g3_best + right_data["M3 Gutfleisch"]["bestand_kannen"] = g3_best + + # Summe Bestand = same as previous row + for cname in RIGHT_CLIENTS: + right_data[cname]['summe_bestand'] = right_data[cname]['bestand_kannen'] + + # Best. in Kannen Vormonat (Lit. L-He) from previous month top_right row_index=7 + g1_prev = get_top_right_value(prev_sheet, "Dr. Fohrer", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["Dr. Fohrer"]['best_kannen_vormonat'] = g1_prev + right_data["AG Buntk."]['best_kannen_vormonat'] = g1_prev + + g2_prev = get_top_right_value(prev_sheet, "AG Alff", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["AG Alff"]['best_kannen_vormonat'] = g2_prev + right_data["AG Gutfl."]['best_kannen_vormonat'] = g2_prev + + + # Group 1 merged (Fohrer + Buntk.) + g1_prev = get_top_right_value(prev_sheet, "Dr. Fohrer", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["Dr. Fohrer"]["best_kannen_vormonat"] = g1_prev + right_data["AG Buntk."]["best_kannen_vormonat"] = g1_prev + + # Group 2 merged (Alff + Gutfl.) + g2_prev = get_top_right_value(prev_sheet, "AG Alff", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["AG Alff"]["best_kannen_vormonat"] = g2_prev + right_data["AG Gutfl."]["best_kannen_vormonat"] = g2_prev + + # Group 3 UNMERGED (each one reads its own cell) + right_data["M3 Thiele"]["best_kannen_vormonat"] = get_top_right_value(prev_sheet, "M3 Thiele", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["M3 Buntkowsky"]["best_kannen_vormonat"] = get_top_right_value(prev_sheet, "M3 Buntkowsky", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["M3 Gutfleisch"]["best_kannen_vormonat"] = get_top_right_value(prev_sheet, "M3 Gutfleisch", TOP_RIGHT_ROW_BESTAND_KANNEN) + + # --- Rückführ. Soll (Lit. L-He) according to your formulas --- + + # Group 1: Dr. Fohrer / AG Buntk. + total_bestand_1 = right_data["Dr. Fohrer"]['summe_bestand'] + best_vormonat_1 = right_data["Dr. Fohrer"]['best_kannen_vormonat'] + diff1 = total_bestand_1 - best_vormonat_1 + share_fohrer = right_data["Dr. Fohrer"]['stand_prev_share'] or Decimal('0') + + right_data["Dr. Fohrer"]['rueckf_soll'] = ( + right_data["Dr. Fohrer"]['bezug'] - diff1 * share_fohrer + ) + right_data["AG Buntk."]['rueckf_soll'] = ( + right_data["AG Buntk."]['bezug'] - total_bestand_1 + best_vormonat_1 + ) + + # Group 2: AG Alff / AG Gutfl. + total_bestand_2 = right_data["AG Alff"]['summe_bestand'] + best_vormonat_2 = right_data["AG Alff"]['best_kannen_vormonat'] + diff2 = total_bestand_2 - best_vormonat_2 + share_alff = right_data["AG Alff"]['stand_prev_share'] or Decimal('0') + share_gutfl = right_data["AG Gutfl."]['stand_prev_share'] or Decimal('0') + + right_data["AG Alff"]['rueckf_soll'] = ( + right_data["AG Alff"]['bezug'] - diff2 * share_alff + ) + right_data["AG Gutfl."]['rueckf_soll'] = ( + right_data["AG Gutfl."]['bezug'] - diff2 * share_gutfl + ) + + # Group 3: M3 Thiele / M3 Buntkowsky / M3 Gutfleisch + for cname in ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"]: + b13 = right_data[cname]['bezug'] + b12 = right_data[cname]['best_kannen_vormonat'] + b11 = right_data[cname]['summe_bestand'] + # Excel: P13+P12-P11 etc. + right_data[cname]['rueckf_soll'] = b13 + b12 - b11 + + # --- Verluste (Soll-Rückf.) (Lit. L-He) = B14 - B6 - B7 --- + for cname in RIGHT_CLIENTS: + b14 = right_data[cname]['rueckf_soll'] + b6 = right_data[cname]['rueckf_fluessig'] + b7 = right_data[cname]['sonder'] + right_data[cname]['verluste'] = b14 - b6 - b7 + + # --- Füllungen warm (Lit. L-He) = sum of monthly 'Füllungen warm' (row_index=11 top_right) --- + for cname in RIGHT_CLIENTS: + total_warm = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + total_warm += get_top_right_value(sheet, cname, row_index=11) + right_data[cname]['fuellungen_warm'] = total_warm + + # --- Kaltgas Rückgabe (Lit. L-He) – Faktor = Bezug * 0.06 --- + for cname in RIGHT_CLIENTS: + b13 = right_data[cname]['bezug'] + right_data[cname]['kaltgas_rueckgabe'] = b13 * factor + + # --- Verbraucherverluste (Liter L-He) = Verluste - Kaltgas Rückgabe --- + for cname in RIGHT_CLIENTS: + b15 = right_data[cname]['verluste'] + b17 = right_data[cname]['kaltgas_rueckgabe'] + right_data[cname]['verbraucherverluste'] = b15 - b17 + + # --- % = Verbraucherverluste / Bezug --- + for cname in RIGHT_CLIENTS: + bezug = right_data[cname]['bezug'] + verb = right_data[cname]['verbraucherverluste'] + if bezug != 0: + right_data[cname]['percent'] = verb / bezug + else: + right_data[cname]['percent'] = None + + # Build RIGHT rows structure + right_row_defs = [ + ('Stand der Gaszähler (Vorjahr) (Nm³)', 'stand_prev_share'), + # We skip the pure-text "Gasrückführung (Nm³)" line here, + # because it’s only text (Aufteilung nach Verbrauch / Gaszähler) + # and easier to render directly in the template if needed. + ('Rückführung flüssig (Lit. L-He)', 'rueckf_fluessig'), + ('Sonderrückführungen (Lit. L-He)', 'sonder'), + ('Sammelrückführung (Lit. L-He)', 'sammel'), + ('Bestand in Kannen-1 (Lit. L-He)', 'bestand_kannen'), + ('Summe Bestand (Lit. L-He)', 'summe_bestand'), + ('Best. in Kannen Vormonat (Lit. L-He)', 'best_kannen_vormonat'), + ('Bezug (Liter L-He)', 'bezug'), + ('Rückführ. Soll (Lit. L-He)', 'rueckf_soll'), + ('Verluste (Soll-Rückf.) (Lit. L-He)', 'verluste'), + ('Füllungen warm (Lit. L-He)', 'fuellungen_warm'), + ('Kaltgas Rückgabe (Lit. L-He) – Faktor', 'kaltgas_rueckgabe'), + ('Verbraucherverluste (Liter L-He)', 'verbraucherverluste'), + ('%', 'percent'), + ] + + rows_right = [] + for label, key in right_row_defs: + values = [right_data[cname].get(key) for cname in RIGHT_CLIENTS] + + if key == 'percent': + total_bezug = sum((right_data[c]['bezug'] for c in RIGHT_CLIENTS), Decimal('0')) + total_verb = sum((right_data[c]['verbraucherverluste'] for c in RIGHT_CLIENTS), Decimal('0')) + total = (total_verb / total_bezug) if total_bezug != 0 else None + else: + total = sum((v for v in values if isinstance(v, Decimal)), Decimal('0')) + + rows_right.append({ + 'label': label, + 'values': values, + 'total': total, + 'is_percent': key == 'percent', + }) + SUM_TABLE_ROWS = [ + ("Rückführung flüssig (Lit. L-He)", "rueckf_fluessig"), + ("Sonderrückführungen (Lit. L-He)", "sonder"), + ("Sammelrückführungen (Lit. L-He)", "sammel"), + ("Bestand in Kannen-1 (Lit. L-He)", "bestand_kannen"), + ("Summe Bestand (Lit. L-He)", "summe_bestand"), + ("Best. in Kannen Vormonat (Lit. L-He)", "best_kannen_vormonat"), + ("Bezug (Liter L-He)", "bezug"), + ("Rückführ. Soll (Lit. L-He)", "rueckf_soll"), + ("Verluste (Soll-Rückf.) (Lit. L-He)", "verluste"), + ("Füllungen warm (Lit. L-He)", "fuellungen_warm"), + ("Kaltgas Rückgabe (Lit. L-He) – Faktor", "kaltgas_rueckgabe"), + ("Faktor 0.06", "factor_row"), + ("Verbraucherverluste (Liter L-He)", "verbraucherverluste"), + ("%", "percent"), + ] + + RIGHT_GROUPS = { + "chemie": ["Dr. Fohrer", "AG Buntk."], + "mawi": ["AG Alff", "AG Gutfl."], + "m3": ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"], + } + + RIGHT_ALL = ["Dr. Fohrer", "AG Buntk.", "AG Alff", "AG Gutfl.", "M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"] + LEFT_ALL = HALFYEAR_CLIENTS_LEFT + + def safe_pct(verb, bez): + return (verb / bez) if bez != 0 else None + rows_sum = [] + def d(x): + return x if isinstance(x, Decimal) else Decimal("0") + for label, key in SUM_TABLE_ROWS: + + if key == "factor_row": + lichtwiese = chemie = mawi = m3 = total = Decimal("0.06") + + elif key == "percent": + # Right totals + rw_bez = sum(d(right_data[c].get("bezug")) for c in RIGHT_ALL) + rw_verb = sum(d(right_data[c].get("verbraucherverluste")) for c in RIGHT_ALL) + lichtwiese = safe_pct(rw_verb, rw_bez) + + # Chemie + ch_bez = sum(d(right_data[c].get("bezug")) for c in RIGHT_GROUPS["chemie"]) + ch_verb = sum(d(right_data[c].get("verbraucherverluste")) for c in RIGHT_GROUPS["chemie"]) + chemie = safe_pct(ch_verb, ch_bez) + + # MaWi + mw_bez = sum(d(right_data[c].get("bezug")) for c in RIGHT_GROUPS["mawi"]) + mw_verb = sum(d(right_data[c].get("verbraucherverluste")) for c in RIGHT_GROUPS["mawi"]) + mawi = safe_pct(mw_verb, mw_bez) + + # M3 + m3_bez = sum(d(right_data[c].get("bezug")) for c in RIGHT_GROUPS["m3"]) + m3_verb = sum(d(right_data[c].get("verbraucherverluste")) for c in RIGHT_GROUPS["m3"]) + m3 = safe_pct(m3_verb, m3_bez) + + # Σ column = (left verb + right verb) / (left bez + right bez) + left_bez = sum(d(client_data_left[c].get("bezug")) for c in LEFT_ALL) + left_verb = sum(d(client_data_left[c].get("verbraucherverluste")) for c in LEFT_ALL) + total = safe_pct(left_verb + rw_verb, left_bez + rw_bez) + + else: + # normal rows = sums + lichtwiese = sum(d(right_data[c].get(key)) for c in RIGHT_ALL) + chemie = sum(d(right_data[c].get(key)) for c in RIGHT_GROUPS["chemie"]) + mawi = sum(d(right_data[c].get(key)) for c in RIGHT_GROUPS["mawi"]) + m3 = sum(d(right_data[c].get(key)) for c in RIGHT_GROUPS["m3"]) + + left_total = sum(d(client_data_left[c].get(key)) for c in LEFT_ALL) + total = left_total + lichtwiese + + rows_sum.append({ + "row_index": row_index, + "label": label, + "total": total, + "lichtwiese": lichtwiese, + "chemie": chemie, + "mawi": mawi, + "m3": m3, + "is_percent": (key == "percent"), + }) + def find_sum_row(rows, label_startswith: str): + for r in rows: + if str(r.get("label", "")).strip().startswith(label_startswith): + return r + return None + + summe_bestand_row = find_sum_row(rows_sum, "Summe Bestand") + k38 = (summe_bestand_row.get("total") if summe_bestand_row else Decimal("0")) or Decimal("0") + j38 = k38 * Decimal("0.75") + # --- FIX: now that k38 is known, update bottom2 + recompute dependent rows --- + bottom2["k38"] = k38 + bottom2["j38"] = j38 + + k39 = bottom2.get("k39") or Decimal("0") + k40 = bottom2.get("k40") or Decimal("0") + + # Row 43: Bestand flüssig He = SUMME(K38:K40) + k43 = (k38 or Decimal("0")) + k39 + k40 + j43 = k43 * Decimal("0.75") + + bottom2["k43"] = k43 + bottom2["j43"] = j43 + + # Row 44: Gesamtbestand neu = Gasbestand(Lit) from bottom table 1 + k43 + gasbestand_lit = Decimal("0") + for r in bottom1_rows: + if (r.get("label") or "").strip().startswith("Gasbestand"): + gasbestand_lit = r.get("lhe") or Decimal("0") + break + + k44 = gasbestand_lit + k43 + j44 = k44 * Decimal("0.75") + + bottom2["k44"] = k44 + bottom2["j44"] = j44 + def d(x): + return x if isinstance(x, Decimal) else Decimal("0") + + # ---- Bottom2: J38/K38 depend on rows_sum (overall summary), so do it HERE ---- + k38 = Decimal("0") + for r in rows_sum: + if r.get("label") == "Summe Bestand (Lit. L-He)": + k38 = r.get("total") or Decimal("0") + break + + j38 = k38 * FACTOR_NM3_TO_LHE # 0.75 + bottom2["k38"] = k38 + bottom2["j38"] = j38 + factor = Decimal("0.75") + + # window = the 6-month list you already build in this view: [(y,m), (y,m), ...] + # bottom2 = dict with "k44" already computed in your view + # rows_sum = overall sum group rows list (your existing halfyear logic) + + # 1) K46 = K44 from the month BEFORE the global month (interval start) + start_year = interval_year + start_month = interval_start # whatever you named your start month variable + + if start_month == 1: + prev_year, prev_month = start_year - 1, 12 + else: + prev_year, prev_month = start_year, start_month - 1 + + prev_sheet = MonthlySheet.objects.filter(year=prev_year, month=prev_month).first() + + # This assumes you have MonthlySummary.gesamtbestand_neu_lhe as K44 equivalent. + # If your field name differs, tell me your MonthlySummary model fields. + prev_k44 = Decimal("0") + if prev_sheet: + prev_sum = MonthlySummary.objects.filter(sheet=prev_sheet).first() + if prev_sum and prev_sum.gesamtbestand_neu_lhe is not None: + prev_k44 = Decimal(str(prev_sum.gesamtbestand_neu_lhe)) + + # helpers: read bottom_3 values for a given sheet/month + def get_bottom3_value(sheet, row_index, col_index): + if not sheet: + return Decimal("0") + c = Cell.objects.filter( + sheet=sheet, table_type="bottom_3", + row_index=row_index, column_index=col_index + ).first() + if not c or c.value in (None, "", "None"): + return Decimal("0") + try: + return Decimal(str(c.value)) + except Exception: + return Decimal("0") + + # 2) Sum rows across the 6-month window + g47_sum = Decimal("0") + i47_sum = Decimal("0") + j47_sum = Decimal("0") + + g50_sum = Decimal("0") + i50_sum = Decimal("0") + + for (yy, mm) in window: + s = MonthlySheet.objects.filter(year=yy, month=mm).first() + ## Excel row 47 maps to bottom_3 row_index=1 in DB (see monthly_sheet.html) + g = get_bottom3_value(s, 1, 1) # G47 editable + i = get_bottom3_value(s, 1, 2) # I47 editable + + g47_sum += g + i47_sum += i + + # In monthly_sheet, J47 is CALCULATED as (G47 + I47), not stored as an editable cell + j47_sum += (g + i) + + # row 50: G(2), I(4) + g50_sum += get_bottom3_value(s, 50, 2) + i50_sum += get_bottom3_value(s, 50, 4) + + # 3) K52 = Verbraucherverlust from overall sum group first column (global Σ) + k52 = Decimal("0") + for r in rows_sum: + label = (r.get("label") or "") + if label.startswith("Verbraucherverluste"): + k52 = r.get("total") or Decimal("0") + break + + # --- apply the SAME monthly formulas, with your overrides --- + # Row 46 + k46 = prev_k44 + j46 = k46 * factor + + # Row 47 + g47 = g47_sum + i47 = i47_sum + j47 = j47_sum + k47 = (j47 / factor) + g47 if (j47 != 0 or g47 != 0) else Decimal("0") + + # Row 48 + k48 = k46 + k47 + j48 = k48 * factor + + # Row 49 (akt. Monat) -> in halfyear we use current bottom2 K44 + k49 = bottom2.get("k44") or Decimal("0") + j49 = k49 * factor + + # Row 50 + g50 = g50_sum + i50 = i50_sum + j50 = i50 + k50 = (j50 / factor) if j50 != 0 else Decimal("0") + + # Row 51 + k51 = k48 - k49 - k50 + j51 = k51 * factor + + # Row 52 + j52 = k52 * factor + + # Row 53 + k53 = k51 - k52 + j53 = j51 - j52 + + bottom3 = { + "j46": j46, "k46": k46, + "g47": g47, "i47": i47, "j47": j47, "k47": k47, + "j48": j48, "k48": k48, + "j49": j49, "k49": k49, + "g50": g50, "i50": i50, "j50": j50, "k50": k50, + "j51": j51, "k51": k51, + "j52": j52, "k52": k52, + "j53": j53, "k53": k53, + } + # ------------------------------------------------------------------ + # 4) Context – keep old keys AND new ones + # ------------------------------------------------------------------ + context = { + 'interval_year': interval_year, + 'interval_start_month': interval_start, + 'window': window, + + # Left table – old names (for your first template) + 'clients': HALFYEAR_CLIENTS_LEFT, + 'rows': rows_left, + + # Left table – explicit + 'clients_left': HALFYEAR_CLIENTS_LEFT, + 'rows_left': rows_left, + + # Right table + 'clients_right': RIGHT_CLIENTS, + 'rows_right': rows_right, + 'rows_sum': rows_sum, + 'bottom1_rows': bottom1_rows, + + } + context["bottom2"] = bottom2 + context["bottom3"] = bottom3 + context["context_bottom2_g39"] = bottom2_inputs["g39"] + context["context_bottom2_i39"] = bottom2_inputs["i39"] + return render(request, 'halfyear_balance.html', context) + +def get_bottom2_value(sheet, row_index: int, col_index: int) -> Decimal: + """Get numeric value from bottom_2 or 0 if missing.""" + if sheet is None: + return Decimal("0") + cell = Cell.objects.filter( + sheet=sheet, + table_type="bottom_2", + row_index=row_index, + column_index=col_index, + ).first() + if cell is None or cell.value in (None, ""): + return Decimal("0") + try: + return Decimal(str(cell.value)) + except Exception: + return Decimal("0") + +def get_top_left_value(sheet, client_name: str, row_index: int) -> Decimal: + """ + Read a numeric value from the top_left table for a given month, client and row. + Does NOT use column_index, because top_left is keyed only by client + row_index. + """ + if sheet is None: + return Decimal('0') + + client_obj = Client.objects.filter(name=client_name).first() + if not client_obj: + return Decimal('0') + + cell = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client_obj, + row_index=row_index, + ).first() + + if cell is None or cell.value in (None, ''): + return Decimal('0') + + try: + return Decimal(str(cell.value)) + except Exception: + return Decimal('0') +def get_group_clients(group_key): + """Return queryset of clients that belong to a logical group.""" + from .models import Client # local import to avoid circulars + + group = CLIENT_GROUPS.get(group_key) + if not group: + return Client.objects.none() + return Client.objects.filter(name__in=group['names']) + +def calculate_summation(sheet, table_type, row_index, sum_column_index): + """Calculate summation for a row, with special handling for % row""" + from decimal import Decimal + from .models import Cell + + try: + # Special case: top_left, % row (Excel B20 -> row_index 19) + if table_type == 'top_left' and row_index == 19: + # K13 = sum of row 13 (Excel B13 -> row_index 12) across all clients + cells_row13 = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + row_index=12, # Excel B13 = row_index 12 + column_index__lt=sum_column_index # Exclude sum column itself + ) + total_13 = Decimal('0') + for cell in cells_row13: + if cell.value is not None: + total_13 += Decimal(str(cell.value)) + + # K19 = sum of row 19 (Excel B19 -> row_index 18) across all clients + cells_row19 = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + row_index=18, # Excel B19 = row_index 18 + column_index__lt=sum_column_index + ) + total_19 = Decimal('0') + for cell in cells_row19: + if cell.value is not None: + total_19 += Decimal(str(cell.value)) + + # Calculate: IF(K13=0; 0; K19/K13) + if total_13 == 0: + return Decimal('0') + return total_19 / total_13 + + # Normal summation for other rows + cells_in_row = Cell.objects.filter( + sheet=sheet, + table_type=table_type, + row_index=row_index, + column_index__lt=sum_column_index + ) + + total = Decimal('0') + for cell in cells_in_row: + if cell.value is not None: + total += Decimal(str(cell.value)) + + return total + + except Exception as e: + print(f"Error calculating summation for {table_type}[{row_index}]: {e}") + return None + +# Helper function for calculations +def evaluate_formula(formula, values_dict): + """ + Safely evaluate a formula like "10 + 9" where numbers are row indices + values_dict: {row_index: decimal_value} + """ + from decimal import Decimal + import re + + try: + # Create a copy of the formula to work with + expr = formula + + # Find all row numbers in the formula + row_refs = re.findall(r'\b\d+\b', expr) + + for row_ref in row_refs: + row_num = int(row_ref) + if row_num in values_dict and values_dict[row_num] is not None: + # Replace row reference with actual value + expr = expr.replace(row_ref, str(values_dict[row_num])) + else: + # Missing value - can't calculate + return None + + # Evaluate the expression + # Note: In production, use a safer evaluator like `asteval` + result = eval(expr, {"__builtins__": {}}, {}) + + # Convert to Decimal with proper rounding + return Decimal(str(round(result, 6))) + + except Exception: + return None + +# Monthly Sheet View +class MonthlySheetView(TemplateView): + template_name = 'monthly_sheet.html' + + def populate_helium_input_to_top_right(self, sheet): + """Populate bezug data from SecondTableEntry to top-right table (row 8 = Excel row 12)""" + from .models import SecondTableEntry, Cell, Client + from django.db.models.functions import Coalesce + from decimal import Decimal + + year = sheet.year + month = sheet.month + + TOP_RIGHT_CLIENTS = [ + "Dr. Fohrer", # Column index 0 (L) + "AG Buntk.", # Column index 1 (M) + "AG Alff", # Column index 2 (N) + "AG Gutfl.", # Column index 3 (O) + "M3 Thiele", # Column index 4 (P) + "M3 Buntkowsky", # Column index 5 (Q) + "M3 Gutfleisch", # Column index 6 (R) + ] + + # For each client in top-right table + for client_name in TOP_RIGHT_CLIENTS: + try: + client = Client.objects.get(name=client_name) + column_index = TOP_RIGHT_CLIENTS.index(client_name) + + # Calculate total LHe_output for this client in this month from SecondTableEntry + total_lhe_output = SecondTableEntry.objects.filter( + client=client, + date__year=year, + date__month=month + ).aggregate( + total=Coalesce(Sum('lhe_output'), Decimal('0')) + )['total'] + + # Get or create the cell for row_index 8 (Excel row 12) - Bezug + cell, created = Cell.objects.get_or_create( + sheet=sheet, + table_type='top_right', + client=client, + row_index=8, # Bezug row (Excel row 12) + column_index=column_index, + defaults={'value': total_lhe_output} + ) + + if not created and cell.value != total_lhe_output: + cell.value = total_lhe_output + cell.save() + + except Client.DoesNotExist: + continue + + # After populating bezug, trigger calculation for all dependent cells + # Get any cell to start the calculation + first_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right' + ).first() + + if first_cell: + save_view = SaveCellsView() + save_view.calculate_top_right_dependents(sheet, first_cell) + + return True + def calculate_bezug_from_entries(self, sheet, year, month): + """Calculate B11 (Bezug) from SecondTableEntry for all clients - ONLY for non-start sheets""" + from .models import SecondTableEntry, Cell, Client + from django.db.models import Sum + from django.db.models.functions import Coalesce + from decimal import Decimal + + # Check if this is the start sheet + if year == 2025 and month == 1: + return # Don't auto-calculate for start sheet + + for client in Client.objects.all(): + # Calculate total LHe output for this client in this month + lhe_output_sum = SecondTableEntry.objects.filter( + client=client, + date__year=year, + date__month=month + ).aggregate( + total=Coalesce(Sum('lhe_output'), Decimal('0')) + )['total'] + + # Update B11 cell (row_index 8 = UI Row 9) + b11_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client, + row_index=8 # Excel B11 + ).first() + + if b11_cell and (b11_cell.value != lhe_output_sum or b11_cell.value is None): + b11_cell.value = lhe_output_sum + b11_cell.save() + + # Also trigger dependent calculations + from .views import SaveCellsView + save_view = SaveCellsView() + save_view.calculate_top_left_dependents(sheet, b11_cell) + # In MonthlySheetView.get_context_data() method, update the TOP_RIGHT_CLIENTS and row count: + + + + return True + def get_context_data(self, **kwargs): + from decimal import Decimal + context = super().get_context_data(**kwargs) + year = self.kwargs.get('year', datetime.now().year) + month = self.kwargs.get('month', datetime.now().month) + is_start_sheet = (year == FIRST_SHEET_YEAR and month == FIRST_SHEET_MONTH) + + # Get or create the monthly sheet + sheet, created = MonthlySheet.objects.get_or_create( + year=year, month=month + ) + + # All clients (used for bottom tables etc.) + clients = Client.objects.all().order_by('name') + + # Pre-fill cells if creating new sheet + if created: + self.initialize_sheet_cells(sheet, clients) + + # Apply previous month links (for B4 and B12) + self.apply_previous_month_links(sheet, year, month) + self.calculate_bezug_from_entries(sheet, year, month) + self.populate_helium_input_to_top_right(sheet) + self.apply_previous_month_links_top_right(sheet, year, month) + + # Define client groups + TOP_LEFT_CLIENTS = [ + "AG Vogel", + "AG Halfm", + "IKP", + ] + + TOP_RIGHT_CLIENTS = [ + "Dr. Fohrer", + "AG Buntk.", + "AG Alff", + "AG Gutfl.", + "M3 Thiele", + "M3 Buntkowsky", + "M3 Gutfleisch", + ] + current_summary = MonthlySummary.objects.filter(sheet=sheet).first() + + # Get previous month summary (for Bottom Table 3: K46 = prev K44) + prev_month_info = self.get_prev_month(year, month) + prev_summary = None + if not is_start_sheet: + prev_sheet = MonthlySheet.objects.filter( + year=prev_month_info['year'], + month=prev_month_info['month'] + ).first() + if prev_sheet: + prev_summary = MonthlySummary.objects.filter(sheet=prev_sheet).first() + + context.update({ + # ... your existing context ... + 'current_summary': current_summary, + 'prev_summary': prev_summary, + }) + # Update row counts in build_group_rows function + # Update row counts in build_group_rows function + def build_group_rows(sheet, table_type, client_names): + """Build rows for display in monthly sheet.""" + from decimal import Decimal + from .models import Cell + MERGED_ROWS = {2, 3, 5, 6, 7, 9, 10, 12, 14, 15} + MERGED_PAIRS = [ + ("Dr. Fohrer", "AG Buntk."), + ("AG Alff", "AG Gutfl."), + ] + rows = [] + + # Determine row count + row_counts = { + "top_left": 16, + "top_right": 16, # rows 0–15 + "bottom_1": 10, + "bottom_2": 10, + "bottom_3": 10, + } + row_count = row_counts.get(table_type, 0) + + # Get all cells for this sheet and table + all_cells = ( + Cell.objects.filter( + sheet=sheet, + table_type=table_type, + ).select_related("client") + ) + + # Group cells by row index + cells_by_row = {} + for cell in all_cells: + if cell.row_index not in cells_by_row: + cells_by_row[cell.row_index] = {} + cells_by_row[cell.row_index][cell.client.name] = cell + + # We will store row sums for Bezug (row 8) and Verbraucherverluste (row 14) + # for top_left / top_right so we can compute the overall % in row 15. + sum_bezug = None + sum_verbrauch = None + + # Build each row + for row_idx in range(row_count): + display_cells = [] + row_cells_dict = cells_by_row.get(row_idx, {}) + + # Build cells in the requested client order + for name in client_names: + cell = row_cells_dict.get(name) + display_cells.append(cell) + + # Calculate sum for this row (includes editable + calculated cells) + sum_value = None + total = Decimal("0") + has_value = False + + merged_second_indices = set() + if table_type == 'top_right' and row_idx in MERGED_ROWS: + for left_name, right_name in MERGED_PAIRS: + try: + right_idx = client_names.index(right_name) + merged_second_indices.add(right_idx) + except ValueError: + # client not in this table; just ignore + pass + + for col_idx, cell in enumerate(display_cells): + # Skip the duplicate (second) column of each merged pair + if col_idx in merged_second_indices: + continue + + if cell and cell.value is not None: + try: + total += Decimal(str(cell.value)) + has_value = True + except Exception: + pass + + if has_value: + sum_value = total + + # Remember special rows for top tables + if table_type in ("top_left", "top_right"): + if row_idx == 8: # Bezug + sum_bezug = total + elif row_idx == 14: # Verbraucherverluste + sum_verbrauch = total + + rows.append( + { + "cells": display_cells, + "sum": sum_value, + "row_index": row_idx, + } + ) + + # Adjust the % row sum for top_left / top_right: + # Sum(%) = Sum(Verbraucherverluste) / Sum(Bezug) + if table_type in ("top_left", "top_right"): + perc_row_idx = 15 # % row + if 0 <= perc_row_idx < len(rows): + if sum_bezug is not None and sum_bezug != 0 and sum_verbrauch is not None: + rows[perc_row_idx]["sum"] = sum_verbrauch / sum_bezug + else: + rows[perc_row_idx]["sum"] = None + + return rows + + + # Now call the local function + top_left_rows = build_group_rows(sheet, 'top_left', TOP_LEFT_CLIENTS) + top_right_rows = build_group_rows(sheet, 'top_right', TOP_RIGHT_CLIENTS) + + # --- Build combined summary of top-left + top-right Sum columns --- + + # Helper to safely get the Sum for a given row index + def get_row_sum(rows, row_index): + if 0 <= row_index < len(rows): + return rows[row_index].get('sum') + return None + + # Row definitions we want in the combined Σ table + # (row_index in top tables, label shown in the small table) + summary_row_defs = [ + (2, "Rückführung flüssig (Lit. L-He)"), + (3, "Sonderrückführungen (Lit. L-He)"), + (4, "Sammelrückführungen (Lit. L-He)"), + (5, "Bestand in Kannen-1 (Lit. L-He)"), + (6, "Summe Bestand (Lit. L-He)"), + (7, "Best. in Kannen Vormonat (Lit. L-He)"), + (8, "Bezug (Liter L-He)"), + (9, "Rückführ. Soll (Lit. L-He)"), + (10, "Verluste (Soll-Rückf.) (Lit. L-He)"), + (11, "Füllungen warm (Lit. L-He)"), + (12, "Kaltgas Rückgabe (Lit. L-He) – Faktor"), + (13, "Faktor 0.06"), + (14, "Verbraucherverluste (Liter L-He)"), + (15, "%"), + ] + + # Precompute totals for Bezug and Verbraucherverluste across both tables + bezug_left = get_row_sum(top_left_rows, 8) or Decimal('0') + bezug_right = get_row_sum(top_right_rows, 8) or Decimal('0') + total_bezug = bezug_left + bezug_right + + verb_left = get_row_sum(top_left_rows, 14) or Decimal('0') + verb_right = get_row_sum(top_right_rows, 14) or Decimal('0') + total_verbrauch = verb_left + verb_right + + summary_rows = [] + + for row_index, label in summary_row_defs: + # Faktor row: always fixed 0.06 + if row_index == 13: + summary_value = Decimal('0.06') + + # % row: total Verbraucherverluste / total Bezug + elif row_index == 15: + if total_bezug != 0: + summary_value = total_verbrauch / total_bezug + else: + summary_value = None + + else: + left_sum = get_row_sum(top_left_rows, row_index) + right_sum = get_row_sum(top_right_rows, row_index) + + # Sammelrückführungen: only from top-right table + if row_index == 4: + left_sum = None + + total = Decimal('0') + + has_any = False + if left_sum is not None: + total += Decimal(str(left_sum)) + has_any = True + if right_sum is not None: + total += Decimal(str(right_sum)) + has_any = True + + summary_value = total if has_any else None + + summary_rows.append({ + 'row_index': row_index, + 'label': label, + 'sum': summary_value, + }) + + # Get cells for bottom tables + cells_by_table = self.get_cells_by_table(sheet) + + context.update({ + 'sheet': sheet, + 'clients': clients, + 'year': year, + 'month': month, + 'month_name': calendar.month_name[month], + 'prev_month': self.get_prev_month(year, month), + 'next_month': self.get_next_month(year, month), + 'cells_by_table': cells_by_table, + 'top_left_headers': TOP_LEFT_CLIENTS + ['Sum'], + 'top_right_headers': TOP_RIGHT_CLIENTS + ['Sum'], + 'top_left_rows': top_left_rows, + 'top_right_rows': top_right_rows, + 'summary_rows': summary_rows, # 👈 NEW + 'is_start_sheet': is_start_sheet, + }) + return context + + def get_cells_by_table(self, sheet): + """Organize cells by table type for easy template rendering""" + cells = sheet.cells.select_related('client').all() + organized = { + 'top_left': [[] for _ in range(16)], + 'top_right': [[] for _ in range(16)], # now 16 rows + 'bottom_1': [[] for _ in range(10)], + 'bottom_2': [[] for _ in range(10)], + 'bottom_3': [[] for _ in range(10)], + } + + for cell in cells: + if cell.table_type not in organized: + continue + + max_rows = len(organized[cell.table_type]) + if cell.row_index < 0 or cell.row_index >= max_rows: + # This is an "extra" cell from an older layout (e.g. top_left rows 18–23) + continue + + row_list = organized[cell.table_type][cell.row_index] + while len(row_list) <= cell.column_index: + row_list.append(None) + + row_list[cell.column_index] = cell + + return organized + + + def initialize_sheet_cells(self, sheet, clients): + """Create all empty cells for a new monthly sheet""" + cells_to_create = [] + summation_config = CALCULATION_CONFIG.get('summation_column', {}) + sum_column_index = summation_config.get('sum_column_index', len(clients) - 1) # Last column + + # For each table type and row + table_configs = [ + ('top_left', 16), + ('top_right', 16), + ('bottom_1', 10), + ('bottom_2', 10), + ('bottom_3', 10), + ] + + for table_type, row_count in table_configs: + for row_idx in range(row_count): + for col_idx, client in enumerate(clients): + is_summation = (col_idx == sum_column_index) + cells_to_create.append(Cell( + sheet=sheet, + client=client, + table_type=table_type, + row_index=row_idx, + column_index=col_idx, + value=None, + is_formula=is_summation, # Mark summation cells as formulas + )) + + # Bulk create all cells at once + Cell.objects.bulk_create(cells_to_create) + def get_prev_month(self, year, month): + """Get previous month year and month""" + if month == 1: + return {'year': year - 1, 'month': 12} + return {'year': year, 'month': month - 1} + + def get_next_month(self, year, month): + """Get next month year and month""" + if month == 12: + return {'year': year + 1, 'month': 1} + return {'year': year, 'month': month + 1} + def apply_previous_month_links(self, sheet, year, month): + """ + For non-start sheets: + B4 (row 2) = previous sheet B3 (row 1) + B10 (row 8) = previous sheet B9 (row 7) + """ + # Do nothing on the first sheet + if year == FIRST_SHEET_YEAR and month == FIRST_SHEET_MONTH: + return + + # Figure out previous month + if month == 1: + prev_year = year - 1 + prev_month = 12 + else: + prev_year = year + prev_month = month - 1 + + from .models import MonthlySheet, Cell, Client + + prev_sheet = MonthlySheet.objects.filter( + year=prev_year, + month=prev_month + ).first() + + if not prev_sheet: + # No previous sheet created yet → nothing to copy + return + + # For each client, copy values + for client in Client.objects.all(): + # B3(prev) → B4(curr): UI row 1 → row 2 → row_index 0 → 1 + prev_b3 = Cell.objects.filter( + sheet=prev_sheet, + table_type='top_left', + client=client, + row_index=0, # UI row 1 + ).first() + + curr_b4 = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client, + row_index=1, # UI row 2 + ).first() + + if prev_b3 and curr_b4: + curr_b4.value = prev_b3.value + curr_b4.save() + + # B9(prev) → B10(curr): UI row 7 → row 8 → row_index 6 → 7 + prev_b9 = Cell.objects.filter( + sheet=prev_sheet, + table_type='top_left', + client=client, + row_index=6, # UI row 7 (Summe Bestand) + ).first() + + curr_b10 = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client, + row_index=7, # UI row 8 (Best. in Kannen Vormonat) + ).first() + + if prev_b9 and curr_b10: + curr_b10.value = prev_b9.value + curr_b10.save() + + def apply_previous_month_links_top_right(self, sheet, year, month): + """ + top_right row 7: Best. in Kannen Vormonat (Lit. L-He) + = previous sheet's Summe Bestand (row 6). + + For merged pairs: + - Dr. Fohrer + AG Buntk. share the SAME value (from previous month's AG Buntk. or Dr. Fohrer) + - AG Alff + AG Gutfl. share the SAME value (from previous month's AG Alff or AG Gutfl.) + M3 clients just copy their own value. + """ + from .models import MonthlySheet, Cell, Client + from decimal import Decimal + + # Do nothing on first sheet + if year == FIRST_SHEET_YEAR and month == FIRST_SHEET_MONTH: + return + + # find previous month + if month == 1: + prev_year = year - 1 + prev_month = 12 + else: + prev_year = year + prev_month = month - 1 + + prev_sheet = MonthlySheet.objects.filter( + year=prev_year, + month=prev_month + ).first() + + if not prev_sheet: + return # nothing to copy from + + TOP_RIGHT_CLIENTS = [ + "Dr. Fohrer", + "AG Buntk.", + "AG Alff", + "AG Gutfl.", + "M3 Thiele", + "M3 Buntkowsky", + "M3 Gutfleisch", + ] + + # Helper function to get a cell value + def get_cell_value(sheet_obj, client_name, row_index): + client_obj = Client.objects.filter(name=client_name).first() + if not client_obj: + return None + + try: + col_idx = TOP_RIGHT_CLIENTS.index(client_name) + except ValueError: + return None + + cell = Cell.objects.filter( + sheet=sheet_obj, + table_type='top_right', + client=client_obj, + row_index=row_index, + column_index=col_idx, + ).first() + + return cell.value if cell else None + + # Helper function to set a cell value + def set_cell_value(sheet_obj, client_name, row_index, value): + client_obj = Client.objects.filter(name=client_name).first() + if not client_obj: + return False + + try: + col_idx = TOP_RIGHT_CLIENTS.index(client_name) + except ValueError: + return False + + cell, created = Cell.objects.get_or_create( + sheet=sheet_obj, + table_type='top_right', + client=client_obj, + row_index=row_index, + column_index=col_idx, + defaults={'value': value} + ) + + if not created and cell.value != value: + cell.value = value + cell.save() + elif created: + cell.save() + + return True + + # ----- Pair 1: Dr. Fohrer + AG Buntk. ----- + # Get previous month's Summe Bestand (row 6) for either client in the pair + pair1_prev_val = None + + # Try AG Buntk. first + prev_buntk_val = get_cell_value(prev_sheet, "AG Buntk.", 6) + if prev_buntk_val is not None: + pair1_prev_val = prev_buntk_val + else: + # Try Dr. Fohrer if AG Buntk. is empty + prev_fohrer_val = get_cell_value(prev_sheet, "Dr. Fohrer", 6) + if prev_fohrer_val is not None: + pair1_prev_val = prev_fohrer_val + + # Apply the value to both clients in the pair + if pair1_prev_val is not None: + set_cell_value(sheet, "Dr. Fohrer", 7, pair1_prev_val) + set_cell_value(sheet, "AG Buntk.", 7, pair1_prev_val) + + # ----- Pair 2: AG Alff + AG Gutfl. ----- + pair2_prev_val = None + + # Try AG Alff first + prev_alff_val = get_cell_value(prev_sheet, "AG Alff", 6) + if prev_alff_val is not None: + pair2_prev_val = prev_alff_val + else: + # Try AG Gutfl. if AG Alff is empty + prev_gutfl_val = get_cell_value(prev_sheet, "AG Gutfl.", 6) + if prev_gutfl_val is not None: + pair2_prev_val = prev_gutfl_val + + # Apply the value to both clients in the pair + if pair2_prev_val is not None: + set_cell_value(sheet, "AG Alff", 7, pair2_prev_val) + set_cell_value(sheet, "AG Gutfl.", 7, pair2_prev_val) + + # ----- M3 clients: copy their own Summe Bestand ----- + for name in ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"]: + prev_val = get_cell_value(prev_sheet, name, 6) + if prev_val is not None: + set_cell_value(sheet, name, 7, prev_val) +# Add this helper function to views.py +def get_factor_value(table_type, row_index): + """Get factor value (like 0.06 for top_left row 17)""" + factors = { + ('top_left', 17): Decimal('0.06'), # A18 in Excel (UI row 17, 0-based index 16) + } + return factors.get((table_type, row_index), Decimal('0')) +# Save Cells View +# views.py - Updated SaveCellsView +# views.py - Update SaveCellsView class +def debug_cell_values(self, sheet, client_id): + """Debug method to check cell values""" + from .models import Cell + cells = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client_id=client_id + ).order_by('row_index') + + debug_info = {} + for cell in cells: + debug_info[f"row_{cell.row_index}"] = { + 'value': str(cell.value) if cell.value else 'None', + 'ui_row': cell.row_index + 1, + 'excel_ref': f"B{cell.row_index + 3}" + } + + return debug_info +class DebugCalculationView(View): + """Debug view to test calculations directly""" + def get(self, request): + sheet_id = request.GET.get('sheet_id', 1) + client_name = request.GET.get('client', 'AG Vogel') + + try: + sheet = MonthlySheet.objects.get(id=sheet_id) + client = Client.objects.get(name=client_name) + + # Get SaveCellsView instance + save_view = SaveCellsView() + + # Create a dummy cell to trigger calculations + dummy_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client, + row_index=0 # B3 + ).first() + + if not dummy_cell: + return JsonResponse({'error': 'No cells found for this client'}) + + # Trigger calculation + updated = save_view.calculate_top_left_dependents(sheet, dummy_cell) + + # Get updated cell values + cells = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client + ).order_by('row_index') + + cell_data = [] + for cell in cells: + cell_data.append({ + 'row_index': cell.row_index, + 'ui_row': cell.row_index + 1, + 'excel_ref': f"B{cell.row_index + 3}", + 'value': str(cell.value) if cell.value else 'None', + 'description': self.get_row_description(cell.row_index) + }) + + return JsonResponse({ + 'sheet': f"{sheet.year}-{sheet.month:02d}", + 'client': client.name, + 'cells': cell_data, + 'updated_count': len(updated), + 'calculation': 'B5 = IF(B3>0; B3-B4; 0)' + }) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=400) + + def get_row_description(self, row_index): + """Get description for row index""" + descriptions = { + 0: "B3: Stand der Gaszähler (Nm³)", + 1: "B4: Stand der Gaszähler (Vormonat) (Nm³)", + 2: "B5: Gasrückführung (Nm³)", + 3: "B6: Rückführung flüssig (Lit. L-He)", + 4: "B7: Sonderrückführungen (Lit. L-He)", + 5: "B8: Bestand in Kannen-1 (Lit. L-He)", + 6: "B9: Summe Bestand (Lit. L-He)", + 7: "B10: Best. in Kannen Vormonat (Lit. L-He)", + 8: "B11: Bezug (Liter L-He)", + 9: "B12: Rückführ. Soll (Lit. L-He)", + 10: "B13: Verluste (Soll-Rückf.) (Lit. L-He)", + 11: "B14: Füllungen warm (Lit. L-He)", + 12: "B15: Kaltgas Rückgabe (Lit. L-He) – Faktor", + 13: "B16: Faktor", + 14: "B17: Verbraucherverluste (Liter L-He)", + 15: "B18: %" + } + return descriptions.get(row_index, f"Row {row_index}") +def recalculate_stand_der_gaszahler(self, sheet): + """Recalculate Stand der Gaszähler for all client pairs""" + from decimal import Decimal + + # For Dr. Fohrer and AG Buntk. (L & M columns) + try: + # Get Dr. Fohrer's bezug + dr_fohrer_client = Client.objects.get(name="Dr. Fohrer") + dr_fohrer_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=dr_fohrer_client, + row_index=9 # Row 9 = Bezug + ).first() + L13 = Decimal(str(dr_fohrer_cell.value)) if dr_fohrer_cell and dr_fohrer_cell.value else Decimal('0') + + # Get AG Buntk.'s bezug + ag_buntk_client = Client.objects.get(name="AG Buntk.") + ag_buntk_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_buntk_client, + row_index=9 + ).first() + M13 = Decimal(str(ag_buntk_cell.value)) if ag_buntk_cell and ag_buntk_cell.value else Decimal('0') + + total = L13 + M13 + if total > 0: + # Update Dr. Fohrer's row 0 + dr_fohrer_row0 = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=dr_fohrer_client, + row_index=0 + ).first() + if dr_fohrer_row0: + dr_fohrer_row0.value = L13 / total + dr_fohrer_row0.save() + + # Update AG Buntk.'s row 0 + ag_buntk_row0 = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_buntk_client, + row_index=0 + ).first() + if ag_buntk_row0: + ag_buntk_row0.value = M13 / total + ag_buntk_row0.save() + except Exception as e: + print(f"Error recalculating Stand der Gaszähler for Dr. Fohrer/AG Buntk.: {e}") + + # For AG Alff and AG Gutfl. (N & O columns) + try: + # Get AG Alff's bezug + ag_alff_client = Client.objects.get(name="AG Alff") + ag_alff_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_alff_client, + row_index=9 + ).first() + N13 = Decimal(str(ag_alff_cell.value)) if ag_alff_cell and ag_alff_cell.value else Decimal('0') + + # Get AG Gutfl.'s bezug + ag_gutfl_client = Client.objects.get(name="AG Gutfl.") + ag_gutfl_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_gutfl_client, + row_index=9 + ).first() + O13 = Decimal(str(ag_gutfl_cell.value)) if ag_gutfl_cell and ag_gutfl_cell.value else Decimal('0') + + total = N13 + O13 + if total > 0: + # Update AG Alff's row 0 + ag_alff_row0 = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_alff_client, + row_index=0 + ).first() + if ag_alff_row0: + ag_alff_row0.value = N13 / total + ag_alff_row0.save() + + # Update AG Gutfl.'s row 0 + ag_gutfl_row0 = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_gutfl_client, + row_index=0 + ).first() + if ag_gutfl_row0: + ag_gutfl_row0.value = O13 / total + ag_gutfl_row0.save() + except Exception as e: + print(f"Error recalculating Stand der Gaszähler for AG Alff/AG Gutfl.: {e}") +# In your SaveCellsView class in views.py +class DebugTopRightView(View): + """Debug view to check top_right calculations""" + def get(self, request): + sheet_id = request.GET.get('sheet_id', 1) + client_name = request.GET.get('client', 'Dr. Fohrer') + + try: + sheet = MonthlySheet.objects.get(id=sheet_id) + client = Client.objects.get(name=client_name) + + # Get all cells for this client in top_right + cells = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=client + ).order_by('row_index') + + cell_data = [] + descriptions = { + 0: "Stand der Gaszähler (Vormonat)", + 1: "Gasrückführung (Nm³)", + 2: "Rückführung flüssig", + 3: "Sonderrückführungen", + 4: "Sammelrückführungen", + 5: "Bestand in Kannen-1", + 6: "Summe Bestand", + 7: "Best. in Kannen Vormonat", + 8: "Same as row 9 from prev sheet", + 9: "Bezug", + 10: "Rückführ. Soll", + 11: "Verluste", + 12: "Füllungen warm", + 13: "Kaltgas Rückgabe", + 14: "Faktor 0.06", + 15: "Verbraucherverluste", + 16: "%" + } + + for cell in cells: + cell_data.append({ + 'row_index': cell.row_index, + 'ui_row': cell.row_index + 1, + 'description': descriptions.get(cell.row_index, f"Row {cell.row_index}"), + 'value': str(cell.value) if cell.value else 'Empty', + 'cell_id': cell.id + }) + + # Test calculation + row3_cell = cells.filter(row_index=3).first() + row5_cell = cells.filter(row_index=5).first() + row6_cell = cells.filter(row_index=6).first() + + calculation_info = { + 'row3_value': str(row3_cell.value) if row3_cell and row3_cell.value else '0', + 'row5_value': str(row5_cell.value) if row5_cell and row5_cell.value else '0', + 'row6_value': str(row6_cell.value) if row6_cell and row6_cell.value else '0', + 'expected_sum': '0' + } + + if row3_cell and row5_cell and row6_cell: + row3_val = Decimal(str(row3_cell.value)) if row3_cell.value else Decimal('0') + row5_val = Decimal(str(row5_cell.value)) if row5_cell.value else Decimal('0') + expected = row3_val + row5_val + calculation_info['expected_sum'] = str(expected) + calculation_info['is_correct'] = row6_cell.value == expected + + return JsonResponse({ + 'sheet': f"{sheet.year}-{sheet.month}", + 'client': client.name, + 'cells': cell_data, + 'calculation': calculation_info + }) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=400) +ABRECHNUNG_COL_CLIENTS = { + # first 3 columns + "pkm_vogel": ["AG Vogel"], + "iap_halfmann": ["AG Halfm"], # or whatever exact name you use in Client admin + "ikp": ["IKP"], + + # your “5 columns” group (excluding Chemie/MaWi/Physik summaries) + "orgchem_thiele": ["M3 Thiele"], # if OrgChem Thiele corresponds to M3 Thiele in your DB + "phychem_m3_buntkow": ["AG Buntk.", "M3 Buntkowsky"], + "orgchem_fohrer": ["Dr. Fohrer"], + "mawi_m3_gutfl": ["AG Gutfl.", "M3 Gutfleisch"], + "mawi_alff": ["AG Alff"], + + # summary columns at the end + "chemie": ["Dr. Fohrer", "AG Buntk.", "M3 Buntkowsky", "M3 Thiele"], + "mawi": ["AG Alff", "AG Gutfl.", "M3 Gutfleisch"], + "physik": ["AG Vogel", "AG Halfm", "IKP"], + + # “Gesamt-summe” is computed as sum of all columns (not client-driven) + "gesamt_summe": [], +} + +class AbrechnungView(LoginRequiredMixin, TemplateView): + template_name = "abrechnung.html" + + # Columns EXACTLY from your screenshot + COLUMNS = [ + ("pkm_vogel", "PKM Vogel"), + ("iap_halfmann", "IAP Halfmann"), + ("ikp", "IKP"), + ("orgchem_thiele", "OrgChem Thiele"), + ("phychem_m3_buntkow", "PhyChem + M3 Buntkow"), + ("orgchem_fohrer", "OrgChem Fohrer"), + ("mawi_m3_gutfl", "MaWi + M3 Gutfl"), + ("mawi_alff", "MaWi Alff"), + ("chemie", "Chemie"), + ("mawi", "MaWi"), + ("physik", "Physik"), + ("gesamt_summe", "Gesamt-summe"), + ] + + # Rows EXACTLY from your screenshot (Excel row 11 is skipped) + ROWS = [ + ("bezogen_menge", "Bezogen. Menge", "LHe"), + ("kaltg_warmfuell", "Kaltg. Warmfüll.", "Anzahl"), + ("anzahl_15", "Anzahl", "LHe"), + ("he_verbrauch", "He-Verbrauch (LHe)", "LHe"), + ("umlage_instandhaltung_1", "1-Umlage Instandhaltung", "EUR"), + ("lhe_verluste", "LHe – Verluste", "LHe"), + ("umlage_heliumkosten_3", "3 Umlage Heliumkosten", "EUR"), + ("ghe_bezug", "GHe - Bezug", "m³"), + ("kosten_he_gas_bezug_4", "4-Kosten He-Gas-Bezug", "EUR"), + ("rel_anteil", "rel. Anteil", "Anteil"), + ("summe_anteile_1_4", "Summe Anteile 1-4", "EUR"), + ("sonstiges", "Sonstiges", "EUR"), + ("betrag", "Betrag", "EUR"), + ("umlage_personal_5", "5-Umlage Personal", "EUR"), + ("gutschriften", "Gutschriften", "EUR"), + ("rechnungsbetrag", "Rechnungsbetrag", "EUR"), + ("eff_lhe_preis", "eff. L-He-Preis", "EUR/L"), +] + + + # Editable rows = your yellow rows + EDITABLE_ROW_KEYS = {"ghe_bezug", "betrag", "gutschriften"} + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + interval_year = self.request.session.get("halfyear_year") + interval_start = self.request.session.get("halfyear_start_month") + if not interval_year or not interval_start: + context["needs_interval"] = True + return context + + interval_year = int(interval_year) + interval_start = int(interval_start) + + qs = AbrechnungCell.objects.filter( + interval_year=interval_year, + interval_start_month=interval_start, + ) + value_map = {(c.row_key, c.col_key): c.value for c in qs} + def d(x): + """safe Decimal""" + if x in (None, ""): + return Decimal("0") + try: + return Decimal(str(x).replace(",", ".")) + except Exception: + return Decimal("0") + + def safe_div(a: Decimal, b: Decimal): + return (a / b) if b not in (None, 0, Decimal("0")) else Decimal("0") + + # ---- interval + window ---- + window = build_halfyear_window(interval_year, interval_start) + + # ---- BetriebskostenSummary values ---- + bs = BetriebskostenSummary.objects.first() + instandhaltung = d(bs.instandhaltung) if bs else Decimal("0") + heliumkosten = d(bs.heliumkosten) if bs else Decimal("0") + bezugskosten_gashe = d(bs.bezugskosten_gashe) if bs else Decimal("0") + umlage_personal_total = d(bs.umlage_personal) if bs else Decimal("0") + + # ---- helper: sum lhe_output in the 6-month window for a list of client names ---- + def sum_output_for_clients_exact(client_names): + total = Decimal("0") + if not client_names: + return total + + for (y, m) in window: + total += SecondTableEntry.objects.filter( + client__name__in=client_names, + date__year=y, + date__month=m, + ).aggregate( + total=Coalesce(Sum("lhe_output"), Value(0, output_field=DecimalField())) + )["total"] or Decimal("0") + + return total + # (NOTE: the date filter above is “wide”; if you prefer exact (y,m) matching, use the loop version below) + def sum_output_for_clients_exact(client_names): + total = Decimal("0") + if not client_names: + return total + for (y, m) in window: + total += SecondTableEntry.objects.filter( + client__name__in=client_names, + date__year=y, + date__month=m, + ).aggregate( + total=Coalesce(Sum("lhe_output"), Value(0, output_field=DecimalField())) + )["total"] or Decimal("0") + return total + + # Use exact to avoid edge cases + sum_output_for_clients = sum_output_for_clients_exact + + # ---- helper: warm fillings (from Halbjahresbilanz row “Füllungen warm”) ---- + # In your halfyear_balance logic, “Füllungen warm” is summed from MonthlySheet cells row_index=11 + # We replicate that part by reading stored top_left/top_right cells for each month. + def sum_fuellungen_warm(client_name): + total = Decimal("0") + for (y, m) in window: + sheet = MonthlySheet.objects.filter(year=y, month=m).first() + if not sheet: + continue + # warm row_index = 11 in both top_left and top_right tables in your existing code + # Try top_left first, then top_right + c = Cell.objects.filter(sheet=sheet, client__name=client_name, table_type="top_left", row_index=11).first() + if not c: + c = Cell.objects.filter(sheet=sheet, client__name=client_name, table_type="top_right", row_index=11).first() + total += d(c.value if c else None) + return total + + # ---- helper: verbraucherverluste (from Halbjahresbilanz) ---- + # In halfyear_balance view this is a *computed* metric, not directly stored. + # So we must either: + # A) replicate the whole halfyear_balance math (big), OR + # B) read it from stored cells *if you store it*. + # + # Right now your MonthlySheet stores the table rows, but “Verbraucherverluste” is row_index=15 + # in your debug mapping. If those cells are saved in DB (they usually are), we can sum row_index=15. + def sum_verbraucherverluste(client_name): + total = Decimal("0") + for (y, m) in window: + sheet = MonthlySheet.objects.filter(year=y, month=m).first() + if not sheet: + continue + # row_index=15 is “Verbraucherverluste” in your debug map + c = Cell.objects.filter(sheet=sheet, client__name=client_name, table_type="top_left", row_index=15).first() + if not c: + c = Cell.objects.filter(sheet=sheet, client__name=client_name, table_type="top_right", row_index=15).first() + total += d(c.value if c else None) + return total + + # ---------------------------------------------------------------------- + # Build computed row values per column + # ---------------------------------------------------------------------- + computed = {} # (row_key, col_key) -> Decimal OR special string + + # Gather base rows (bezug + warm fills) per column + bezogen = {} + warmfills = {} + + for col_key, _label in self.COLUMNS: + client_names = ABRECHNUNG_COL_CLIENTS.get(col_key, []) + bezogen[col_key] = sum_output_for_clients(client_names) + warmfills[col_key] = sum_fuellungen_warm(client_names[0]) if len(client_names) == 1 else sum(sum_fuellungen_warm(n) for n in client_names) + + # “Gesamt-summe” = sum of all “normal” columns (everything except itself) + def sum_all_cols(values_dict): + return sum(v for k, v in values_dict.items() if k != "gesamt_summe") + + bezogen["gesamt_summe"] = sum_all_cols(bezogen) + warmfills["gesamt_summe"] = sum_all_cols(warmfills) + + # ---- Row: Bezogen. Menge ---- + for col_key in bezogen: + computed[("bezogen_menge", col_key)] = bezogen[col_key] + + # ---- Row: Kaltg. Warmfüll. (á 15l L-He) ---- + for col_key in warmfills: + computed[("kaltg_warmfuell", col_key)] = warmfills[col_key] + + # ---- Row: Anzahl (15) = warmfills * 15 ---- + for col_key in warmfills: + computed[("anzahl_15", col_key)] = warmfills[col_key] * Decimal("15") + + # ---- Row: He-Verbrauch (LHe) = Bezogen + Anzahl_15 ---- + he_verbrauch = {} + for col_key in bezogen: + hv = bezogen[col_key] + (warmfills[col_key] * Decimal("15")) + he_verbrauch[col_key] = hv + computed[("he_verbrauch", col_key)] = hv + + # ---- Row: 1-Umlage Instandhaltung ---- + # Your rule (as you stated): + # - first 3 columns: instandhaltung * (He-Verbrauch of this column / He-Verbrauch of “Physik”) / 3 + # - next 3 columns: instandhaltung / 9 + # - next 2 columns: instandhaltung / 6 + # - next 2 columns: instandhaltung / 3 (if you later add more) + # - gesamt_summe: sum of first three columns (same row) + # + # We apply this to the current UI order. + first3 = ["pkm_vogel", "iap_halfmann", "ikp"] + next3 = ["orgchem_thiele", "phychem_m3_buntkow", "orgchem_fohrer"] + next2 = ["mawi_m3_gutfl", "mawi_alff"] + + physik_hv = he_verbrauch.get("physik", Decimal("0")) + + row1_vals = {} + for col_key in he_verbrauch: + if col_key in first3: + row1_vals[col_key] = instandhaltung * safe_div(he_verbrauch[col_key], physik_hv) / Decimal("3") + elif col_key in next3: + row1_vals[col_key] = instandhaltung / Decimal("9") + elif col_key in next2: + row1_vals[col_key] = instandhaltung / Decimal("6") + elif col_key == "gesamt_summe": + row1_vals[col_key] = sum(row1_vals.get(k, Decimal("0")) for k in first3) + else: + row1_vals[col_key] = Decimal("0") + + for col_key, v in row1_vals.items(): + computed[("umlage_instandhaltung_1", col_key)] = v + + # ---- Row: LHe – Verluste = Verbraucherverluste from Halbjahresbilanz (sum if combined) ---- + lhe_verluste = {} + for col_key, _label in self.COLUMNS: + if col_key in ("chemie", "mawi", "physik"): + # Special rules you stated: + if col_key == "chemie": + # Dr. Fohrer + AG Buntk. (NOT M3 here) + v = sum_verbraucherverluste("Dr. Fohrer") + sum_verbraucherverluste("AG Buntk.") + elif col_key == "mawi": + v = sum_verbraucherverluste("AG Alff") + sum_verbraucherverluste("AG Gutfl.") + else: # physik + v = sum_verbraucherverluste("M3 Thiele") + sum_verbraucherverluste("M3 Gutfleisch") + sum_verbraucherverluste("M3 Buntkowsky") + else: + client_names = ABRECHNUNG_COL_CLIENTS.get(col_key, []) + v = sum(sum_verbraucherverluste(n) for n in client_names) + + lhe_verluste[col_key] = v + computed[("lhe_verluste", col_key)] = v + + lhe_verluste["gesamt_summe"] = sum_all_cols(lhe_verluste) + computed[("lhe_verluste", "gesamt_summe")] = lhe_verluste["gesamt_summe"] + + # ---- Row: 3 Umlage Heliumkosten = heliumkosten * (LHe-Verluste col / LHe-Verluste gesamt_summe) ---- + for col_key in lhe_verluste: + computed[("umlage_heliumkosten_3", col_key)] = heliumkosten * safe_div(lhe_verluste[col_key], lhe_verluste["gesamt_summe"]) + + # ---- Row: 4-Kosten He-Gas-Bezug = Umlage Heliumkosten * Bezugskosten-GasHe ---- + for col_key in lhe_verluste: + computed[("kosten_he_gas_bezug_4", col_key)] = d(computed[("umlage_heliumkosten_3", col_key)]) * bezugskosten_gashe + + # ---- Row: Summe Anteile 1-4 = (1) + (3) + (4) ---- + summe_anteile = {} + for col_key in lhe_verluste: + v = ( + d(computed.get(("umlage_instandhaltung_1", col_key))) + + d(computed.get(("umlage_heliumkosten_3", col_key))) + + d(computed.get(("kosten_he_gas_bezug_4", col_key))) + ) + summe_anteile[col_key] = v + computed[("summe_anteile_1_4", col_key)] = v + + # ---- Row: rel. Anteil = summe_anteile col / summe_anteile gesamt ---- + for col_key in summe_anteile: + computed[("rel_anteil", col_key)] = safe_div(summe_anteile[col_key], summe_anteile.get("gesamt_summe", Decimal("0"))) + + # ---- Row: 5-Umlage Personal ---- + # Your rules: + # - first 3 clients => 0 + # - next columns up to (excluding Chemie): weighted over the 5 columns: + # OrgChem Thiele, PhyChem+M3 Buntkow, OrgChem Fohrer, MaWi+M3 Gutfl, MaWi Alff + # - for Chemie/MaWi/Physik: weighted over the 3 summary columns (Chemie, MaWi, Physik) + five_cols = ["orgchem_thiele", "phychem_m3_buntkow", "orgchem_fohrer", "mawi_m3_gutfl", "mawi_alff"] + sum5 = sum(he_verbrauch.get(k, Decimal("0")) for k in five_cols) + + sum3 = ( + he_verbrauch.get("chemie", Decimal("0")) + + he_verbrauch.get("mawi", Decimal("0")) + + he_verbrauch.get("physik", Decimal("0")) + ) + + for col_key in he_verbrauch: + if col_key in first3: + v = Decimal("0") + elif col_key in five_cols: + v = safe_div(he_verbrauch[col_key], sum5) * umlage_personal_total + elif col_key in ("chemie", "mawi", "physik"): + v = safe_div(he_verbrauch[col_key], sum3) * umlage_personal_total + elif col_key == "gesamt_summe": + v = sum(d(computed.get(("umlage_personal_5", k))) for k in he_verbrauch if k != "gesamt_summe") + else: + v = Decimal("0") + computed[("umlage_personal_5", col_key)] = v + + # ---- Row: Rechnungsbetrag = Summe Anteile 1-4 − Gutschriften + Betrag + 5-Umlage Personal ---- + for col_key in he_verbrauch: + gutsch = d(value_map.get(("gutschriften", col_key))) + betrag = d(value_map.get(("betrag", col_key))) + personal = d(computed.get(("umlage_personal_5", col_key))) + sa = d(computed.get(("summe_anteile_1_4", col_key))) + computed[("rechnungsbetrag", col_key)] = sa - gutsch + betrag + personal + + # ---- Row: eff. L-He-Preis = Rechnungsbetrag / He-Verbrauch ---- + for col_key in he_verbrauch: + rb = d(computed.get(("rechnungsbetrag", col_key))) + hv = he_verbrauch.get(col_key, Decimal("0")) + computed[("eff_lhe_preis", col_key)] = safe_div(rb, hv) + + # ---- Row: Sonstiges (TEXT) based on Betrag ---- + # IF(Betrag=0,"--------------",IF(Betrag<0,"Gutschrift","Nachzahlung")) + sonstiges_text = {} + for col_key in he_verbrauch: + b = d(value_map.get(("betrag", col_key))) + if b == 0: + sonstiges_text[col_key] = "--------------" + elif b < 0: + sonstiges_text[col_key] = "Gutschrift" + else: + sonstiges_text[col_key] = "Nachzahlung" + # overwrite display values for non-editable computed rows + non_editable_formula_rows = { + "bezogen_menge", + "kaltg_warmfuell", + "anzahl_15", + "he_verbrauch", + "umlage_instandhaltung_1", + "lhe_verluste", + "umlage_heliumkosten_3", + "kosten_he_gas_bezug_4", + "rel_anteil", + "summe_anteile_1_4", + "umlage_personal_5", + "rechnungsbetrag", + "eff_lhe_preis", + } + + for (rk, ck), val in computed.items(): + if rk in non_editable_formula_rows: + value_map[(rk, ck)] = val + rows = [] + for row_key, label, unit in self.ROWS: + rows.append({ + "row_key": row_key, + "label": label, + "unit": unit, # 👈 ADD THIS + "editable": row_key in self.EDITABLE_ROW_KEYS, + "cells": [ + { + "col_key": col_key, + "value": value_map.get((row_key, col_key)), + } + for col_key, _ in self.COLUMNS + ] + }) + start_year = interval_year + start_month = interval_start + # end month = start + 5 months + end_total = (start_month - 1) + 5 + end_year = start_year + (end_total // 12) + end_month = (end_total % 12) + 1 + + start_date_str = f"01.{start_month:02d}.{start_year}" + last_day = calendar.monthrange(end_year, end_month)[1] + end_date_str = f"{last_day:02d}.{end_month:02d}.{end_year}" + + context["interval_text"] = f"{start_date_str} bis {end_date_str}" + context["sonstiges_text"] = sonstiges_text + context.update({ + "page_title": "Abrechnung", + "columns": self.COLUMNS, + "rows": rows, + "interval_year": interval_year, + "interval_start": interval_start, + }) + + return context +@method_decorator(csrf_exempt, name="dispatch") +class SaveAbrechnungCellsView(LoginRequiredMixin, View): + def post(self, request): + interval_year = request.session.get("halfyear_year") + interval_start = request.session.get("halfyear_start_month") + if not interval_year or not interval_start: + return JsonResponse({"ok": False, "error": "No halfyear interval selected."}, status=400) + + interval_year = int(interval_year) + interval_start = int(interval_start) + + try: + payload = json.loads(request.body.decode("utf-8")) + except Exception: + return JsonResponse({"ok": False, "error": "Invalid JSON."}, status=400) + + changes = payload.get("changes", []) + + for item in changes: + row_key = item.get("row_key") + col_key = item.get("col_key") + raw_val = item.get("value") + + # allow empty -> NULL + if raw_val in ("", None): + val = None + else: + try: + val = Decimal(str(raw_val).replace(",", ".")) + except Exception: + continue + + AbrechnungCell.objects.update_or_create( + interval_year=interval_year, + interval_start_month=interval_start, + row_key=row_key, + col_key=col_key, + defaults={"value": val}, + ) + + return JsonResponse({"ok": True}) +def build_halfyear_window(interval_year: int, interval_start_month: int): + window = [] + for offset in range(6): + total_index = (interval_start_month - 1) + offset + y = interval_year + (total_index // 12) + m = (total_index % 12) + 1 + window.append((y, m)) + return window +class SaveCellsView(View): + + def calculate_bottom_3_dependents(self, sheet): + updated_cells = [] + + def get_cell(row_idx, col_idx): + return Cell.objects.filter( + sheet=sheet, + table_type='bottom_3', + row_index=row_idx, + column_index=col_idx + ).first() + + def set_cell(row_idx, col_idx, value): + cell, created = Cell.objects.get_or_create( + sheet=sheet, + table_type='bottom_3', + row_index=row_idx, + column_index=col_idx, + defaults={'value': value} + ) + if not created and cell.value != value: + cell.value = value + cell.save() + elif created: + cell.save() + + updated_cells.append({ + 'id': cell.id, + 'value': str(cell.value) if cell.value is not None else '', + 'is_calculated': True, + }) + + def dec(x): + if x in (None, ''): + return Decimal('0') + try: + return Decimal(str(x)) + except Exception: + return Decimal('0') + + # ---- current summary ---- + cur_sum = MonthlySummary.objects.filter(sheet=sheet).first() + curr_k44 = dec(cur_sum.gesamtbestand_neu_lhe) if cur_sum else Decimal('0') + total_verb = dec(cur_sum.verbraucherverlust_lhe) if cur_sum else Decimal('0') + + # ---- previous month summary ---- + year, month = sheet.year, sheet.month + if month == 1: + prev_year, prev_month = year - 1, 12 + else: + prev_year, prev_month = year, month - 1 + + prev_sheet = MonthlySheet.objects.filter(year=prev_year, month=prev_month).first() + if prev_sheet: + prev_sum = MonthlySummary.objects.filter(sheet=prev_sheet).first() + prev_k44 = dec(prev_sum.gesamtbestand_neu_lhe) if prev_sum else Decimal('0') + else: + prev_k44 = Decimal('0') + + # ---- read editable inputs from bottom_3: F47,G47,I47,I50 ---- + def get_val(r, c): + cell = get_cell(r, c) + return dec(cell.value if cell else None) + + f47 = get_val(1, 0) + g47 = get_val(1, 1) + i47 = get_val(1, 2) + i50 = get_val(4, 2) + + # Now apply your formulas using prev_k44, curr_k44, total_verb, f47,g47,i47,i50 + # Row indices: 0..7 correspond to 46..53 + # col 3 = J, col 4 = K + + # Row 46 + k46 = prev_k44 + j46 = k46 * Decimal('0.75') + set_cell(0, 3, j46) + set_cell(0, 4, k46) + + # Row 47 + g47 = self._dec((get_cell(1, 1) or {}).value if get_cell(1, 1) else None) + i47 = self._dec((get_cell(1, 2) or {}).value if get_cell(1, 2) else None) + + j47 = g47 + i47 + k47 = (j47 / Decimal('0.75')) + g47 if j47 != 0 else g47 + + set_cell(1, 3, j47) + set_cell(1, 4, k47) + + # Row 48 + k48 = k46 + k47 + j48 = k48 * Decimal('0.75') + set_cell(2, 3, j48) + set_cell(2, 4, k48) + + # Row 49 + k49 = curr_k44 + j49 = k49 * Decimal('0.75') + set_cell(3, 3, j49) + set_cell(3, 4, k49) + + # Row 50 + j50 = i50 + k50 = j50 / Decimal('0.75') if j50 != 0 else Decimal('0') + set_cell(4, 3, j50) + set_cell(4, 4, k50) + + # Row 51 + k51 = k48 - k49 - k50 + j51 = k51 * Decimal('0.75') + set_cell(5, 3, j51) + set_cell(5, 4, k51) + + # Row 52 + k52 = total_verb + j52 = k52 * Decimal('0.75') + set_cell(6, 3, j52) + set_cell(6, 4, k52) + + # Row 53 + j53 = j51 - j52 + k53 = k51 - k52 + set_cell(7, 3, j53) + set_cell(7, 4, k53) + + return updated_cells + + + def _dec(self, value): + """Convert value to Decimal or return 0.""" + if value is None or value == '': + return Decimal('0') + try: + return Decimal(str(value)) + except Exception: + return Decimal('0') + + def post(self, request, *args, **kwargs): + """ + Handle AJAX saves from monthly_sheet.html + - Single-cell save: when cell_id is present (blur on one cell) + - Bulk save: when the 'Save All Cells' button is used (no cell_id) + """ + try: + sheet_id = request.POST.get('sheet_id') + if not sheet_id: + return JsonResponse({ + 'status': 'error', + 'message': 'Missing sheet_id' + }) + + sheet = MonthlySheet.objects.get(id=sheet_id) + + # -------- Single-cell update (blur) -------- + cell_id = request.POST.get('cell_id') + if cell_id: + value_raw = (request.POST.get('value') or '').strip() + + try: + cell = Cell.objects.get(id=cell_id, sheet=sheet) + except Cell.DoesNotExist: + return JsonResponse({ + 'status': 'error', + 'message': 'Cell not found' + }) + + # Convert value to Decimal or None + if value_raw == '': + new_value = None + else: + try: + # Allow comma or dot + value_clean = value_raw.replace(',', '.') + new_value = Decimal(value_clean) + except (InvalidOperation, ValueError): + # If conversion fails, treat as empty + new_value = None + + old_value = cell.value + cell.value = new_value + cell.save() + + updated_cells = [{ + 'id': cell.id, + 'value': '' if cell.value is None else str(cell.value), + 'is_calculated': cell.is_formula, # model field + }] + + # Recalculate dependents depending on table_type + if cell.table_type == 'top_left': + updated_cells.extend( + self.calculate_top_left_dependents(sheet, cell) + ) + elif cell.table_type == 'top_right': + updated_cells.extend( + self.calculate_top_right_dependents(sheet, cell) + ) + elif cell.table_type == 'bottom_1': + updated_cells.extend(self.calculate_bottom_1_dependents(sheet, cell)) + elif cell.table_type == 'bottom_3': + updated_cells.extend( + self.calculate_bottom_3_dependents(sheet) + ) + # bottom_1 / bottom_2 / bottom_3 currently have no formulas: + # they just save the new value. + updated_cells += self.calculate_bottom_3_dependents(sheet) + return JsonResponse({ + 'status': 'success', + 'updated_cells': updated_cells + }) + + + # -------- Bulk save (Save All button) -------- + return self.save_bulk_cells(request, sheet) + + except MonthlySheet.DoesNotExist: + return JsonResponse({ + 'status': 'error', + 'message': 'Sheet not found' + }) + except Exception as e: + # Generic safety net so the frontend sees an error message + return JsonResponse({ + 'status': 'error', + 'message': str(e) + }) + + # ... your other methods above ... + + # Update the calculate_top_right_dependents method in SaveCellsView class + + def calculate_top_right_dependents(self, sheet, changed_cell): + """ + Recalculate all dependent cells in the top-right table according to Excel formulas. + + Excel rows (4-20) -> 0-based indices (0-15) + Rows: + 0: Stand der Gaszähler (Vormonat) (Nm³) - shares for M3 clients + 1: Gasrückführung (Nm³) + 2: Rückführung flüssig (Lit. L-He) + 3: Sonderrückführungen (Lit. L-He) - editable + 4: Sammelrückführungen (Lit. L-He) - from helium_input groups + 5: Bestand in Kannen-1 (Lit. L-He) - editable, merged in pairs + 6: Summe Bestand (Lit. L-He) = row 5 + 7: Best. in Kannen Vormonat (Lit. L-He) - from previous month + 8: Bezug (Liter L-He) - from SecondTableEntry + 9: Rückführ. Soll (Lit. L-He) - calculated + 10: Verluste (Soll-Rückf.) (Lit. L-He) - calculated + 11: Füllungen warm (Lit. L-He) - from SecondTableEntry warm outputs + 12: Kaltgas Rückgabe (Lit. L-He) – Faktor - calculated + 13: Faktor 0.06 - fixed + 14: Verbraucherverluste (Liter L-He) - calculated + 15: % - calculated + """ + from decimal import Decimal + from django.db.models import Sum, Count + from django.db.models.functions import Coalesce + from .models import Client, Cell, ExcelEntry, SecondTableEntry + + TOP_RIGHT_CLIENTS = [ + "Dr. Fohrer", # L + "AG Buntk.", # M (merged with L) + "AG Alff", # N + "AG Gutfl.", # O (merged with N) + "M3 Thiele", # P + "M3 Buntkowsky", # Q + "M3 Gutfleisch", # R + ] + + # Define merged pairs + MERGED_PAIRS = [ + ("Dr. Fohrer", "AG Buntk."), # L and M are merged + ("AG Alff", "AG Gutfl."), # N and O are merged + ] + + # M3 clients (not merged, calculated individually) + M3_CLIENTS = ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"] + + # Groups for Sammelrückführungen (helium_input) + HELIUM_INPUT_GROUPS = { + "fohrer_buntk": ["Dr. Fohrer", "AG Buntk."], + "alff_gutfl": ["AG Alff", "AG Gutfl."], + "m3": ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"], + } + + year = sheet.year + month = sheet.month + factor = Decimal('0.06') # Fixed factor from Excel + updated_cells = [] + + # Helper functions + def get_val(client_name, row_idx): + """Get cell value for a client and row""" + try: + client = Client.objects.get(name=client_name) + col_idx = TOP_RIGHT_CLIENTS.index(client_name) + cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=client, + row_index=row_idx, + column_index=col_idx + ).first() + if cell and cell.value is not None: + return Decimal(str(cell.value)) + except (Client.DoesNotExist, ValueError, KeyError): + pass + return Decimal('0') + + def set_val(client_name, row_idx, value, is_calculated=True): + """Set cell value for a client and row""" + try: + client = Client.objects.get(name=client_name) + col_idx = TOP_RIGHT_CLIENTS.index(client_name) + + cell, created = Cell.objects.get_or_create( + sheet=sheet, + table_type='top_right', + client=client, + row_index=row_idx, + column_index=col_idx, + defaults={'value': value} + ) + + # Only update if value changed + if not created and cell.value != value: + cell.value = value + cell.save() + elif created: + cell.save() + + updated_cells.append({ + 'id': cell.id, + 'value': str(cell.value) if cell.value else '', + 'is_calculated': is_calculated + }) + + return True + except (Client.DoesNotExist, ValueError): + return False + + # 1. Update Summe Bestand (row 6) from Bestand in Kannen-1 (row 5) + # For merged pairs: copy value from changed cell to its pair + if changed_cell and changed_cell.table_type == 'top_right': + if changed_cell.row_index == 5: # Bestand in Kannen-1 + client_name = changed_cell.client.name + new_value = changed_cell.value + + # Check if this client is in a merged pair + for pair in MERGED_PAIRS: + if client_name in pair: + # Update both clients in the pair + for client_in_pair in pair: + if client_in_pair != client_name: + set_val(client_in_pair, 5, new_value, is_calculated=False) + break + + # 2. For all clients: Set Summe Bestand (row 6) = Bestand in Kannen-1 (row 5) + for client_name in TOP_RIGHT_CLIENTS: + bestand_value = get_val(client_name, 5) + set_val(client_name, 6, bestand_value, is_calculated=True) + + # 3. Update Sammelrückführungen (row 4) from helium_input groups + for group_name, client_names in HELIUM_INPUT_GROUPS.items(): + # Get total helium_input for this group + clients_in_group = Client.objects.filter(name__in=client_names) + total_helium = ExcelEntry.objects.filter( + client__in=clients_in_group, + date__year=year, + date__month=month + ).aggregate(total=Coalesce(Sum('lhe_ges'), Decimal('0')))['total'] + + # Set same value for all clients in group + for client_name in client_names: + set_val(client_name, 4, total_helium, is_calculated=True) + + # 4. Calculate Rückführung flüssig (row 2) + # For merged pairs: =L8 for Dr. Fohrer/AG Buntk., =N8 for AG Alff/AG Gutfl. + # For M3 clients: =$P$8 * P4, $P$8 * Q4, $P$8 * R4 + + # Get Sammelrückführungen values for groups + sammel_fohrer_buntk = get_val("Dr. Fohrer", 4) # L8 + sammel_alff_gutfl = get_val("AG Alff", 4) # N8 + sammel_m3_group = get_val("M3 Thiele", 4) # P8 (same for all M3) + + # For merged pairs + set_val("Dr. Fohrer", 2, sammel_fohrer_buntk, is_calculated=True) + set_val("AG Buntk.", 2, sammel_fohrer_buntk, is_calculated=True) + set_val("AG Alff", 2, sammel_alff_gutfl, is_calculated=True) + set_val("AG Gutfl.", 2, sammel_alff_gutfl, is_calculated=True) + + # For M3 clients: =$P$8 * column4 (Stand der Gaszähler) + for m3_client in M3_CLIENTS: + stand_value = get_val(m3_client, 0) # Stand der Gaszähler (row 0) + rueck_value = sammel_m3_group * stand_value + set_val(m3_client, 2, rueck_value, is_calculated=True) + + # 5. Calculate Füllungen warm (row 11) from SecondTableEntry warm outputs + # 5. Calculate Füllungen warm (row 11) as NUMBER of warm fillings + # (sum of 1s where each warm SecondTableEntry is one filling) + for client_name in TOP_RIGHT_CLIENTS: + client = Client.objects.get(name=client_name) + warm_count = SecondTableEntry.objects.filter( + client=client, + date__year=year, + date__month=month, + is_warm=True + ).aggregate( + total=Coalesce(Count('id'), 0) + )['total'] + + # store as Decimal so later formulas (warm * 15) still work nicely + warm_value = Decimal(warm_count) + set_val(client_name, 11, warm_value, is_calculated=True) + # 6. Set Faktor row (13) to 0.06 + for client_name in TOP_RIGHT_CLIENTS: + set_val(client_name, 13, factor, is_calculated=True) + + # 6a. Recalculate Stand der Gaszähler (row 0) for the merged pairs + # according to Excel: + # L4 = L13 / (L13 + M13), M4 = M13 / (L13 + M13) + # N4 = N13 / (N13 + O13), O4 = O13 / (N13 + O13) + + # Pair 1: Dr. Fohrer / AG Buntk. + bezug_dr = get_val("Dr. Fohrer", 8) # L13 + bezug_buntk = get_val("AG Buntk.", 8) # M13 + total_pair1 = bezug_dr + bezug_buntk + + if total_pair1 != 0: + set_val("Dr. Fohrer", 0, bezug_dr / total_pair1, is_calculated=True) + set_val("AG Buntk.", 0, bezug_buntk / total_pair1, is_calculated=True) + else: + # if no Bezug, both shares are 0 + set_val("Dr. Fohrer", 0, Decimal('0'), is_calculated=True) + set_val("AG Buntk.", 0, Decimal('0'), is_calculated=True) + + # Pair 2: AG Alff / AG Gutfl. + bezug_alff = get_val("AG Alff", 8) # N13 + bezug_gutfl = get_val("AG Gutfl.", 8) # O13 + total_pair2 = bezug_alff + bezug_gutfl + + if total_pair2 != 0: + set_val("AG Alff", 0, bezug_alff / total_pair2, is_calculated=True) + set_val("AG Gutfl.", 0, bezug_gutfl / total_pair2, is_calculated=True) + else: + set_val("AG Alff", 0, Decimal('0'), is_calculated=True) + set_val("AG Gutfl.", 0, Decimal('0'), is_calculated=True) + + + # 7. Calculate all other dependent rows for merged pairs + for pair in MERGED_PAIRS: + client1, client2 = pair + + # Get values for the pair + bezug1 = get_val(client1, 8) # Bezug client1 + bezug2 = get_val(client2, 8) # Bezug client2 + total_bezug = bezug1 + bezug2 # L13+M13 or N13+O13 + + summe_bestand = get_val(client1, 6) # L11 or N11 (merged, same value) + best_vormonat = get_val(client1, 7) # L12 or N12 (merged, same value) + rueck_fl = get_val(client1, 2) # L6 or N6 (merged, same value) + warm1 = get_val(client1, 11) # L16 or N16 + warm2 = get_val(client2, 11) # M16 or O16 + total_warm = warm1 + warm2 # L16+M16 or N16+O16 + + # Calculate Rückführ. Soll (row 9) + # = L13+M13 - L11 + L12 for first pair + # = N13+O13 - N11 + N12 for second pair + rueck_soll = total_bezug - summe_bestand + best_vormonat + set_val(client1, 9, rueck_soll, is_calculated=True) + set_val(client2, 9, rueck_soll, is_calculated=True) + + # Calculate Verluste (row 10) = Rückführ. Soll - Rückführung flüssig + verluste = rueck_soll - rueck_fl + set_val(client1, 10, verluste, is_calculated=True) + set_val(client2, 10, verluste, is_calculated=True) + + # Calculate Kaltgas Rückgabe (row 12) + # = (L13+M13)*$A18 + (L16+M16)*15 + kaltgas = (total_bezug * factor) + (total_warm * Decimal('15')) + set_val(client1, 12, kaltgas, is_calculated=True) + set_val(client2, 12, kaltgas, is_calculated=True) + + # Calculate Verbraucherverluste (row 14) = Verluste - Kaltgas + verbrauch = verluste - kaltgas + set_val(client1, 14, verbrauch, is_calculated=True) + set_val(client2, 14, verbrauch, is_calculated=True) + + # Calculate % (row 15) = Verbraucherverluste / (L13+M13) + if total_bezug != 0: + prozent = verbrauch / total_bezug + else: + prozent = Decimal('0') + set_val(client1, 15, prozent, is_calculated=True) + set_val(client2, 15, prozent, is_calculated=True) + + # 8. Calculate all dependent rows for M3 clients (individual calculations) + for m3_client in M3_CLIENTS: + # Get individual values + bezug = get_val(m3_client, 8) # Bezug for this M3 client + summe_bestand = get_val(m3_client, 6) # Summe Bestand + best_vormonat = get_val(m3_client, 7) # Best. in Kannen Vormonat + rueck_fl = get_val(m3_client, 2) # Rückführung flüssig + warm = get_val(m3_client, 11) # Füllungen warm + + # Calculate Rückführ. Soll (row 9) = Bezug - Summe Bestand + Best. Vormonat + rueck_soll = bezug - summe_bestand + best_vormonat + set_val(m3_client, 9, rueck_soll, is_calculated=True) + + # Calculate Verluste (row 10) = Rückführ. Soll - Rückführung flüssig + verluste = rueck_soll - rueck_fl + set_val(m3_client, 10, verluste, is_calculated=True) + + # Calculate Kaltgas Rückgabe (row 12) = Bezug * factor + warm * 15 + kaltgas = (bezug * factor) + (warm * Decimal('15')) + set_val(m3_client, 12, kaltgas, is_calculated=True) + + # Calculate Verbraucherverluste (row 14) = Verluste - Kaltgas + verbrauch = verluste - kaltgas + set_val(m3_client, 14, verbrauch, is_calculated=True) + + # Calculate % (row 15) = Verbraucherverluste / Bezug + if bezug != 0: + prozent = verbrauch / bezug + else: + prozent = Decimal('0') + set_val(m3_client, 15, prozent, is_calculated=True) + + return updated_cells + + + + def calculate_bottom_1_dependents(self, sheet, changed_cell): + """ + Recalculate Bottom Table 1 (table_type='bottom_1'). + + Layout (row_index 0–9, col_index 0–4): + + Rows 0–8: + 0: Batterie 1 + 1: 2 + 2: 3 + 3: 4 + 4: 5 + 5: 6 + 6: 2 Bündel + 7: 2 Ballone + 8: Reingasspeicher + + Row 9: + 9: Gasbestand (totals row) + + Columns: + 0: Volumen (fixed values, not editable) + 1: bar (editable for rows 0–8) + 2: korrigiert = bar / (bar/2000 + 1) + 3: Nm³ = Volumen * korrigiert + 4: Lit. LHe = Nm³ / 0.75 + + Row 9: + - Volumen (col 0): empty + - bar (col 1): empty + - korrigiert (col 2): empty + - Nm³ (col 3): SUM Nm³ rows 0–8 + - Lit. LHe (col 4): SUM Lit. LHe rows 0–8 + """ + from decimal import Decimal, InvalidOperation + from .models import Cell + + updated_cells = [] + + DATA_ROWS = list(range(0, 9)) # 0–8 + TOTAL_ROW = 9 + + COL_VOL = 0 + COL_BAR = 1 + COL_KORR = 2 + COL_NM3 = 3 + COL_LHE = 4 + + # Fixed Volumen values for rows 0–8 + VOLUMES = [ + Decimal("2.4"), # row 0 + Decimal("5.1"), # row 1 + Decimal("4.0"), # row 2 + Decimal("1.0"), # row 3 + Decimal("4.0"), # row 4 + Decimal("0.4"), # row 5 + Decimal("1.2"), # row 6 + Decimal("20.0"), # row 7 + Decimal("5.0"), # row 8 + ] + + def get_cell(row_idx, col_idx): + return Cell.objects.filter( + sheet=sheet, + table_type="bottom_1", + row_index=row_idx, + column_index=col_idx, + ).first() + + def set_calc(row_idx, col_idx, value): + """ + Set a calculated cell and add it to updated_cells. + If value is None, we clear the cell. + """ + cell = get_cell(row_idx, col_idx) + if not cell: + cell = Cell( + sheet=sheet, + table_type="bottom_1", + row_index=row_idx, + column_index=col_idx, + value=value, + ) + else: + cell.value = value + cell.save() + + updated_cells.append( + { + "id": cell.id, + "value": "" if cell.value is None else str(cell.value), + "is_calculated": True, + } + ) + + # ---------- Rows 0–8: per-gasspeicher calculations ---------- + for row_idx in DATA_ROWS: + bar_cell = get_cell(row_idx, COL_BAR) + + # Volumen: fixed constant or, if present, value from DB + vol = VOLUMES[row_idx] + vol_cell = get_cell(row_idx, COL_VOL) + if vol_cell and vol_cell.value is not None: + try: + vol = Decimal(str(vol_cell.value)) + except (InvalidOperation, ValueError): + # fall back to fixed constant + vol = VOLUMES[row_idx] + + bar = None + try: + if bar_cell and bar_cell.value is not None: + bar = Decimal(str(bar_cell.value)) + except (InvalidOperation, ValueError): + bar = None + + # korrigiert = bar / (bar/2000 + 1) + if bar is not None and bar != 0: + korr = bar / (bar / Decimal("2000") + Decimal("1")) + else: + korr = None + + # Nm³ = Volumen * korrigiert + if korr is not None: + nm3 = vol * korr + else: + nm3 = None + + # Lit. LHe = Nm³ / 0.75 + if nm3 is not None: + lit_lhe = nm3 / Decimal("0.75") + else: + lit_lhe = None + + # Write calculated cells back (NOT Volumen or bar) + set_calc(row_idx, COL_KORR, korr) + set_calc(row_idx, COL_NM3, nm3) + set_calc(row_idx, COL_LHE, lit_lhe) + + # ---------- Row 9: totals (Gasbestand) ---------- + total_nm3 = Decimal("0") + total_lhe = Decimal("0") + has_nm3 = False + has_lhe = False + + for row_idx in DATA_ROWS: + nm3_cell = get_cell(row_idx, COL_NM3) + if nm3_cell and nm3_cell.value is not None: + try: + total_nm3 += Decimal(str(nm3_cell.value)) + has_nm3 = True + except (InvalidOperation, ValueError): + pass + + lhe_cell = get_cell(row_idx, COL_LHE) + if lhe_cell and lhe_cell.value is not None: + try: + total_lhe += Decimal(str(lhe_cell.value)) + has_lhe = True + except (InvalidOperation, ValueError): + pass + + # Volumen (0), bar (1), korrigiert (2) on total row stay empty + set_calc(TOTAL_ROW, COL_KORR, None) # explicitly clear korrigiert + set_calc(TOTAL_ROW, COL_NM3, total_nm3 if has_nm3 else None) + set_calc(TOTAL_ROW, COL_LHE, total_lhe if has_lhe else None) + + return updated_cells + + + + + + def calculate_top_left_dependents(self, sheet, changed_cell): + """Calculate dependent cells in top_left table""" + from decimal import Decimal + from django.db.models import Sum + from django.db.models.functions import Coalesce + + client_id = changed_cell.client_id + updated_cells = [] + + # Get all cells for this client in top_left table + client_cells = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client_id=client_id + ) + + # Create a dict for easy access + cell_dict = {} + for cell in client_cells: + cell_dict[cell.row_index] = cell + + # Get values with safe defaults + def get_cell_value(row_idx): + cell = cell_dict.get(row_idx) + if cell and cell.value is not None: + try: + return Decimal(str(cell.value)) + except: + return Decimal('0') + return Decimal('0') + + # 1. B5 = IF(B3>0; B3-B4; 0) - Gasrückführung + b3 = get_cell_value(0) # row_index 0 = Excel B3 (UI row 1) + b4 = get_cell_value(1) # row_index 1 = Excel B4 (UI row 2) + + # Calculate B5 + if b3 > 0: + b5_value = b3 - b4 + if b5_value < 0: + b5_value = Decimal('0') + else: + b5_value = Decimal('0') + + # Update B5 - FORCE update even if value appears the same + b5_cell = cell_dict.get(2) # row_index 2 = Excel B5 (UI row 3) + if b5_cell: + # Always update B5 when B3 or B4 changes + b5_cell.value = b5_value + b5_cell.save() + updated_cells.append({ + 'id': b5_cell.id, + 'value': str(b5_cell.value), + 'is_calculated': True + }) + + # 2. B6 = B5 / 0.75 - Rückführung flüssig + b6_value = b5_value / Decimal('0.75') + b6_cell = cell_dict.get(3) # row_index 3 = Excel B6 (UI row 4) + if b6_cell: + b6_cell.value = b6_value + b6_cell.save() + updated_cells.append({ + 'id': b6_cell.id, + 'value': str(b6_cell.value), + 'is_calculated': True + }) + + # 3. B9 = B7 + B8 - Summe Bestand + b7 = get_cell_value(4) # row_index 4 = Excel B7 (UI row 5) + b8 = get_cell_value(5) # row_index 5 = Excel B8 (UI row 6) + b9_value = b7 + b8 + b9_cell = cell_dict.get(6) # row_index 6 = Excel B9 (UI row 7) + if b9_cell: + b9_cell.value = b9_value + b9_cell.save() + updated_cells.append({ + 'id': b9_cell.id, + 'value': str(b9_cell.value), + 'is_calculated': True + }) + + # 4. B11 = Sum of LHe Output from SecondTableEntry for this client/month - Bezug + from .models import SecondTableEntry + client = changed_cell.client + + # Calculate total LHe output for this client in this month + # 4. B11 = Bezug (Liter L-He) + # For start sheet: manual entry, for other sheets: auto-calculated from SecondTableEntry + from .models import SecondTableEntry + client = changed_cell.client + + b11_cell = cell_dict.get(8) # row_index 8 = Excel B11 (UI row 9) + + # Check if this is the start sheet (2025-01) + is_start_sheet = (sheet.year == 2025 and sheet.month == 1) + + # Get the B11 value for calculations + b11_value = Decimal('0') + + if is_start_sheet: + # For start sheet, keep whatever value is already there (manual entry) + if b11_cell and b11_cell.value is not None: + b11_value = Decimal(str(b11_cell.value)) + else: + # For non-start sheets: auto-calculate from SecondTableEntry + lhe_output_sum = SecondTableEntry.objects.filter( + client=client, + date__year=sheet.year, + date__month=sheet.month + ).aggregate( + total=Coalesce(Sum('lhe_output'), Decimal('0')) + )['total'] + + b11_value = lhe_output_sum + + if b11_cell and (b11_cell.value != b11_value or b11_cell.value is None): + b11_cell.value = b11_value + b11_cell.save() + updated_cells.append({ + 'id': b11_cell.id, + 'value': str(b11_cell.value), + 'is_calculated': True # Calculated from SecondTableEntry + }) + + # 5. B12 = B11 + B10 - B9 - Rückführ. Soll + b10 = get_cell_value(7) # row_index 7 = Excel B10 (UI row 8) + b12_value = b11_value + b10 - b9_value # Use b11_value instead of lhe_output_sum + b12_cell = cell_dict.get(9) # row_index 9 = Excel B12 (UI row 10) + if b12_cell: + b12_cell.value = b12_value + b12_cell.save() + updated_cells.append({ + 'id': b12_cell.id, + 'value': str(b12_cell.value), + 'is_calculated': True + }) + + # 6. B13 = B12 - B6 - Verluste (Soll-Rückf.) + b13_value = b12_value - b6_value + b13_cell = cell_dict.get(10) # row_index 10 = Excel B13 (UI row 11) + if b13_cell: + b13_cell.value = b13_value + b13_cell.save() + updated_cells.append({ + 'id': b13_cell.id, + 'value': str(b13_cell.value), + 'is_calculated': True + }) + + # 7. B14 = Count of warm outputs + warm_count = SecondTableEntry.objects.filter( + client=client, + date__year=sheet.year, + date__month=sheet.month, + is_warm=True + ).count() + + b14_cell = cell_dict.get(11) # row_index 11 = Excel B14 (UI row 12) + if b14_cell: + b14_cell.value = Decimal(str(warm_count)) + b14_cell.save() + updated_cells.append({ + 'id': b14_cell.id, + 'value': str(b14_cell.value), + 'is_calculated': True + }) + + # 8. B15 = IF(B11>0; B11 * factor + B14 * 15; 0) - Kaltgas Rückgabe + factor = get_cell_value(13) # row_index 13 = Excel B16 (Faktor) (UI row 14) + if factor == 0: + factor = Decimal('0.06') # default factor + + if b11_value > 0: # Use b11_value + b15_value = b11_value * factor + Decimal(str(warm_count)) * Decimal('15') + else: + b15_value = Decimal('0') + + b15_cell = cell_dict.get(12) # row_index 12 = Excel B15 (UI row 13) + if b15_cell: + b15_cell.value = b15_value + b15_cell.save() + updated_cells.append({ + 'id': b15_cell.id, + 'value': str(b15_cell.value), + 'is_calculated': True + }) + + # 9. B17 = B13 - B15 - Verbraucherverluste + b17_value = b13_value - b15_value + b17_cell = cell_dict.get(14) # row_index 14 = Excel B17 (UI row 15) + if b17_cell: + b17_cell.value = b17_value + b17_cell.save() + updated_cells.append({ + 'id': b17_cell.id, + 'value': str(b17_cell.value), + 'is_calculated': True + }) + + # 10. B18 = IF(B11=0; 0; B17/B11) - % + if b11_value == 0: # Use b11_value + b18_value = Decimal('0') + else: + b18_value = b17_value / b11_value # Use b11_value + + b18_cell = cell_dict.get(15) # row_index 15 = Excel B18 (UI row 16) + if b18_cell: + b18_cell.value = b18_value + b18_cell.save() + updated_cells.append({ + 'id': b18_cell.id, + 'value': str(b18_cell.value), + 'is_calculated': True + }) + + return updated_cells + def save_bulk_cells(self, request, sheet): + """Original bulk save logic (for backward compatibility)""" + # Get all cell updates + cell_updates = {} + for key, value in request.POST.items(): + if key.startswith('cell_'): + cell_id = key.replace('cell_', '') + cell_updates[cell_id] = value + + # Update cells and track which ones changed + updated_cells = [] + changed_clients = set() + + for cell_id, new_value in cell_updates.items(): + try: + cell = Cell.objects.get(id=cell_id, sheet=sheet) + old_value = cell.value + + # Convert new value + try: + if new_value.strip(): + cell.value = Decimal(new_value) + else: + cell.value = None + except Exception: + cell.value = None + + # Only save if value changed + if cell.value != old_value: + cell.save() + updated_cells.append({ + 'id': cell.id, + 'value': str(cell.value) if cell.value else '' + }) + # bottom_3 has no client, so this will just add None for those cells, + # which is harmless. Top-left cells still add their real client_id. + changed_clients.add(cell.client_id) + + except Cell.DoesNotExist: + continue + + # Recalculate for each changed client (top-left tables) + for client_id in changed_clients: + if client_id is not None: + self.recalculate_top_left_table(sheet, client_id) + + # --- NEW: recalc bottom_3 for the whole sheet, independent of clients --- + bottom3_updates = self.calculate_bottom_3_dependents(sheet) + + # Get all updated cells for response (top-left) + all_updated_cells = [] + for client_id in changed_clients: + if client_id is None: + continue # skip bottom_3 / non-client cells + client_cells = Cell.objects.filter( + sheet=sheet, + client_id=client_id + ) + for cell in client_cells: + all_updated_cells.append({ + 'id': cell.id, + 'value': str(cell.value) if cell.value else '', + 'is_calculated': cell.is_formula + }) + + # Add bottom_3 recalculated cells (J46..K53, etc.) + all_updated_cells.extend(bottom3_updates) + + return JsonResponse({ + 'status': 'success', + 'message': f'Saved {len(updated_cells)} cells', + 'updated_cells': all_updated_cells + }) + + + def recalculate_top_left_table(self, sheet, client_id): + """Recalculate the top-left table for a specific client""" + from decimal import Decimal + + # Get all cells for this client in top_left table + cells = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client_id=client_id + ).order_by('row_index') + + # Create a dictionary of cell values + cell_dict = {} + for cell in cells: + cell_dict[cell.row_index] = cell + + # Excel logic implementation for top-left table + # B3 (row_index 0) = Stand der Gaszähler (Nm³) - manual + # B4 (row_index 1) = Stand der Gaszähler (Vormonat) (Nm³) - from previous sheet + + # Get B3 and B4 + b3_cell = cell_dict.get(0) # UI Row 3 + b4_cell = cell_dict.get(1) # UI Row 4 + + if b3_cell and b3_cell.value and b4_cell and b4_cell.value: + # B5 = IF(B3>0; B3-B4; 0) + b3 = Decimal(str(b3_cell.value)) + b4 = Decimal(str(b4_cell.value)) + + if b3 > 0: + b5 = b3 - b4 + if b5 < 0: + b5 = Decimal('0') + else: + b5 = Decimal('0') + + # Update B5 (row_index 2) + b5_cell = cell_dict.get(2) + if b5_cell and (b5_cell.value != b5 or b5_cell.value is None): + b5_cell.value = b5 + b5_cell.save() + + # B6 = B5 / 0.75 (row_index 3) + b6 = b5 / Decimal('0.75') + b6_cell = cell_dict.get(3) + if b6_cell and (b6_cell.value != b6 or b6_cell.value is None): + b6_cell.value = b6 + b6_cell.save() + + # Get previous month's sheet for B10 + if sheet.month == 1: + prev_year = sheet.year - 1 + prev_month = 12 + else: + prev_year = sheet.year + prev_month = sheet.month - 1 + + prev_sheet = MonthlySheet.objects.filter( + year=prev_year, + month=prev_month + ).first() + + if prev_sheet: + # Get B9 from previous sheet (row_index 7 in previous) + prev_b9 = Cell.objects.filter( + sheet=prev_sheet, + table_type='top_left', + client_id=client_id, + row_index=7 # UI Row 9 + ).first() + + if prev_b9 and prev_b9.value: + # Update B10 in current sheet (row_index 8) + b10_cell = cell_dict.get(8) + if b10_cell and (b10_cell.value != prev_b9.value or b10_cell.value is None): + b10_cell.value = prev_b9.value + b10_cell.save() +@method_decorator(csrf_exempt, name='dispatch') +class SaveMonthSummaryView(View): + """ + Saves per-month summary values such as K44 (Gesamtbestand neu). + Called from JS after 'Save All' finishes. + """ + + def post(self, request, *args, **kwargs): + try: + data = json.loads(request.body.decode('utf-8')) + except json.JSONDecodeError: + return JsonResponse( + {'success': False, 'message': 'Invalid JSON'}, + status=400 + ) + + sheet_id = data.get('sheet_id') + if not sheet_id: + return JsonResponse( + {'success': False, 'message': 'Missing sheet_id'}, + status=400 + ) + + try: + sheet = MonthlySheet.objects.get(id=sheet_id) + except MonthlySheet.DoesNotExist: + return JsonResponse( + {'success': False, 'message': 'Sheet not found'}, + status=404 + ) + + # More tolerant decimal conversion: accepts "123.45" and "123,45" + def to_decimal(value): + if value is None: + return None + s = str(value).strip() + if s == '': + return None + s = s.replace(',', '.') + try: + return Decimal(s) + except (InvalidOperation, ValueError): + # Debug: show what failed in the dev server console + print("SaveMonthSummaryView to_decimal failed for:", repr(value)) + return None + + raw_k44 = data.get('gesamtbestand_neu_lhe') + raw_gas = data.get('gasbestand_lhe') + raw_verb = data.get('verbraucherverlust_lhe') + + gesamtbestand_neu_lhe = to_decimal(raw_k44) + gasbestand_lhe = to_decimal(raw_gas) + verbraucherverlust_lhe = to_decimal(raw_verb) + + summary, created = MonthlySummary.objects.get_or_create(sheet=sheet) + + if gesamtbestand_neu_lhe is not None: + summary.gesamtbestand_neu_lhe = gesamtbestand_neu_lhe + if gasbestand_lhe is not None: + summary.gasbestand_lhe = gasbestand_lhe + if verbraucherverlust_lhe is not None: + summary.verbraucherverlust_lhe = verbraucherverlust_lhe + + summary.save() + + # Small debug output so you can see in the server console what was saved + print( + f"Saved MonthlySummary for {sheet.year}-{sheet.month:02d}: " + f"K44={summary.gesamtbestand_neu_lhe}, " + f"Gasbestand={summary.gasbestand_lhe}, " + f"Verbraucherverlust={summary.verbraucherverlust_lhe}" + ) + + return JsonResponse({'success': True}) + + + +# Calculate View (placeholder for calculations) +class CalculateView(View): + def post(self, request): + # This will be implemented when you provide calculation rules + return JsonResponse({ + 'status': 'success', + 'message': 'Calculation endpoint ready' + }) + +# Summary Sheet View +class SummarySheetView(TemplateView): + template_name = 'summary_sheet.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + start_month = int(self.kwargs.get('start_month', 1)) + year = int(self.kwargs.get('year', datetime.now().year)) + + # Get 6 monthly sheets + months = [(year, m) for m in range(start_month, start_month + 6)] + sheets = MonthlySheet.objects.filter( + year=year, + month__in=list(range(start_month, start_month + 6)) + ).order_by('month') + + # Aggregate data across months + summary_data = self.calculate_summary(sheets) + + context.update({ + 'year': year, + 'start_month': start_month, + 'end_month': start_month + 5, + 'sheets': sheets, + 'clients': Client.objects.all(), + 'summary_data': summary_data, + }) + return context + + def calculate_summary(self, sheets): + """Calculate totals across 6 months""" + summary = {} + + for client in Client.objects.all(): + client_total = 0 + for sheet in sheets: + # Get specific cells and sum + cells = sheet.cells.filter( + client=client, + table_type='top_left', + row_index=0 # Example: first row + ) + for cell in cells: + if cell.value: + try: + client_total += float(cell.value) + except (ValueError, TypeError): + continue + + summary[client.id] = client_total + + return summary + +# Existing views below (keep all your existing functions) +def clients_list(request): + # --- Clients for the yearly summary table --- + clients = Client.objects.all() + + # --- Available years for output data (same as before) --- + available_years_qs = SecondTableEntry.objects.dates('date', 'year', order='DESC') + available_years = [y.year for y in available_years_qs] + + # === 1) Year used for the "Helium Output Yearly Summary" table === + # Uses ?year=... in the dropdown + year_param = request.GET.get('year') + if year_param: + selected_year = int(year_param) + else: + selected_year = available_years[0] if available_years else datetime.now().year + + # === 2) GLOBAL half-year interval (shared with other pages) ======= + # Try GET params first + interval_year_param = request.GET.get('interval_year') + start_month_param = request.GET.get('interval_start_month') + + # Fallbacks from session + session_year = request.session.get('halfyear_year') + session_start_month = request.session.get('halfyear_start_month') + + # Determine final interval_year + if interval_year_param: + interval_year = int(interval_year_param) + elif session_year: + interval_year = int(session_year) + else: + # default: same as selected_year for summary + interval_year = selected_year + + # Determine final interval_start_month + if start_month_param: + interval_start_month = int(start_month_param) + elif session_start_month: + interval_start_month = int(session_start_month) + else: + interval_start_month = 1 # default Jan + + # Store back into the session so other views can read them + request.session['halfyear_year'] = interval_year + request.session['halfyear_start_month'] = interval_start_month + + # === 3) Build a 6-month window, allowing wrap into the next year === + # Example: interval_year=2025, interval_start_month=12 + # window = [(2025,12), (2026,1), (2026,2), (2026,3), (2026,4), (2026,5)] + window = [] + for offset in range(6): + total_index = (interval_start_month - 1) + offset # 0-based index + y = interval_year + (total_index // 12) + m = (total_index % 12) + 1 + window.append((y, m)) + + # === 4) Totals per client in that 6-month window ================== + monthly_data = [] + for client in clients: + monthly_totals = [] + for (y, m) in window: + total = SecondTableEntry.objects.filter( + client=client, + date__year=y, + date__month=m + ).aggregate( + total=Coalesce( + Sum('lhe_output'), + Value(0, output_field=DecimalField()) + ) + )['total'] + monthly_totals.append(total) + + monthly_data.append({ + 'client': client, + 'monthly_totals': monthly_totals, + 'year_total': sum(monthly_totals), + }) + + # === 5) Month labels for the header (only the 6-month window) ===== + month_labels = [calendar.month_abbr[m] for (y, m) in window] + + # === 6) FINALLY: return the response ============================== + return render(request, 'clients_table.html', { + 'available_years': available_years, + 'current_year': selected_year, # used by year dropdown + 'interval_year': interval_year, # used by "Global 6-Month Interval" form + 'interval_start_month': interval_start_month, + 'months': month_labels, + 'monthly_data': monthly_data, + }) + + # === 5) Month labels for the header (only the 6-month window) ===== + month_labels = [calendar.month_abbr[m] for (y, m) in window] + + +def set_halfyear_interval(request): + if request.method == 'POST': + year = int(request.POST.get('year')) + start_month = int(request.POST.get('start_month')) + + request.session['halfyear_year'] = year + request.session['halfyear_start_month'] = start_month + + return redirect(request.META.get('HTTP_REFERER', 'clients_list')) + + return redirect('clients_list') +# Table One View (ExcelEntry) +def table_one_view(request): + from .models import ExcelEntry, Client, Institute + + # --- Base queryset for the main Helium Input table --- + base_entries = ExcelEntry.objects.all().select_related('client', 'client__institute') + + # Read the global 6-month interval from the session + interval_year = request.session.get('halfyear_year') + interval_start = request.session.get('halfyear_start_month') + + if interval_year and interval_start: + interval_year = int(interval_year) + interval_start = int(interval_start) + + # Build the same 6-month window as on the main page (can cross year) + window = [] + for offset in range(6): + total_index = (interval_start - 1) + offset # 0-based + y = interval_year + (total_index // 12) + m = (total_index % 12) + 1 + window.append((y, m)) + + # Build Q filter: (year=m_year AND month=m_month) for any of those 6 + q = Q() + for (y, m) in window: + q |= Q(date__year=y, date__month=m) + + entries_table1 = base_entries.filter(q).order_by('-date') + else: + # Fallback: if no global interval yet, show everything + entries_table1 = base_entries.order_by('-date') + clients = Client.objects.all().select_related('institute') + institutes = Institute.objects.all() + + # ---- Overview filters ---- + # years present in ExcelEntry.date + year_qs = ExcelEntry.objects.dates('date', 'year', order='DESC') + available_years = [d.year for d in year_qs] + + # default year/start month + # default year/start month (if no global interval yet) + if available_years: + default_year = available_years[0] # newest year in ExcelEntry + else: + default_year = timezone.now().year + + # 🔸 Read global half-year interval from session (set on main page) + session_year = request.session.get('halfyear_year') + session_start = request.session.get('halfyear_start_month') + + # If the user has set a global interval, use it. + # Otherwise fall back to default year / January. + year = int(session_year) if session_year else int(default_year) + start_month = int(session_start) if session_start else 1 + + + # six-month window + # --- Build a 6-month window, allowing wrap into the next year --- + # Example: year=2025, start_month=10 + # window = [(2025,10), (2025,11), (2025,12), (2026,1), (2026,2), (2026,3)] + window = [] + for offset in range(6): + total_index = (start_month - 1) + offset # 0-based + y_for_month = year + (total_index // 12) + m_for_month = (total_index % 12) + 1 + window.append((y_for_month, m_for_month)) + + overview = None + + if window: + # Build per-group data + groups_entries = [] # for internal calculations + + for key, group in CLIENT_GROUPS.items(): + clients_qs = get_group_clients(key) + + values = [] + group_total = Decimal('0') + + for (y_m, m_m) in window: + total = ExcelEntry.objects.filter( + client__in=clients_qs, + date__year=y_m, + date__month=m_m + ).aggregate( + total=Coalesce(Sum('lhe_ges'), Decimal('0')) + )['total'] + + values.append(total) + group_total += total + + groups_entries.append({ + 'key': key, + 'label': group['label'], + 'values': values, + 'total': group_total, + }) + + # month totals across all groups + month_totals = [] + for idx in range(len(window)): + s = sum((g['values'][idx] for g in groups_entries), Decimal('0')) + month_totals.append(s) + + grand_total = sum(month_totals, Decimal('0')) + + # Build rows for the template + rows = [] + for idx, (y_m, m_m) in enumerate(window): + row_values = [g['values'][idx] for g in groups_entries] + rows.append({ + 'month_number': m_m, + 'month_label': calendar.month_name[m_m], + 'values': row_values, + 'total': month_totals[idx], + }) + + groups_meta = [{'key': g['key'], 'label': g['label']} for g in groups_entries] + group_totals = [g['total'] for g in groups_entries] + + # Start/end for display – include years so wrap is clear + start_year = window[0][0] + start_month_disp = window[0][1] + end_year = window[-1][0] + end_month_disp = window[-1][1] + + overview = { + 'year': year, # keep for backwards compatibility if needed + 'start_month': start_month_disp, + 'start_year': start_year, + 'end_month': end_month_disp, + 'end_year': end_year, + 'rows': rows, + 'groups': groups_meta, + 'group_totals': group_totals, + 'grand_total': grand_total, + } + + + # Month dropdown labels + MONTH_CHOICES = [ + (1, 'Jan'), (2, 'Feb'), (3, 'Mar'), (4, 'Apr'), + (5, 'May'), (6, 'Jun'), (7, 'Jul'), (8, 'Aug'), + (9, 'Sep'), (10, 'Oct'), (11, 'Nov'), (12, 'Dec'), + ] + + return render(request, 'table_one.html', { + 'entries_table1': entries_table1, + 'clients': clients, + 'institutes': institutes, + 'available_years': available_years, + 'month_choices': MONTH_CHOICES, + 'overview': overview, + }) + +# Table Two View (SecondTableEntry) +def table_two_view(request): + try: + clients = Client.objects.all().select_related('institute') + institutes = Institute.objects.all() + + # 🔸 Read global half-year interval from session + interval_year = request.session.get('halfyear_year') + interval_start = request.session.get('halfyear_start_month') + + if interval_year and interval_start: + interval_year = int(interval_year) + interval_start = int(interval_start) + + # Build the same 6-month window as in clients_list (can cross years) + window = [] + for offset in range(6): + total_index = (interval_start - 1) + offset # 0-based + y = interval_year + (total_index // 12) + m = (total_index % 12) + 1 + window.append((y, m)) + + # Build a Q object matching any of those (year, month) pairs + q = Q() + for (y, m) in window: + q |= Q(date__year=y, date__month=m) + + entries = SecondTableEntry.objects.filter(q).order_by('-date') + else: + # Fallback if no global interval yet: show all + entries = SecondTableEntry.objects.all().order_by('-date') + + return render(request, 'table_two.html', { + 'entries_table2': entries, + 'clients': clients, + 'institutes': institutes, + 'interval_year': interval_year, + 'interval_start_month': interval_start, + }) + + except Exception as e: + return render(request, 'table_two.html', { + 'error_message': f"Failed to load data: {str(e)}", + 'entries_table2': [], + 'clients': clients, + 'institutes': institutes, + }) + + +def monthly_sheet_root(request): + """ + Redirect /sheet/ to the sheet matching the globally selected + half-year start (year + month). If not set, fall back to latest. + """ + year = request.session.get('halfyear_year') + start_month = request.session.get('halfyear_start_month') + + if year and start_month: + try: + year = int(year) + start_month = int(start_month) + return redirect('monthly_sheet', year=year, month=start_month) + except ValueError: + pass # fall through + + # Fallback: latest MonthlySheet if exists + latest_sheet = MonthlySheet.objects.order_by('-year', '-month').first() + if latest_sheet: + return redirect('monthly_sheet', year=latest_sheet.year, month=latest_sheet.month) + else: + now = timezone.now() + return redirect('monthly_sheet', year=now.year, month=now.month) +# Add Entry (Generic) +def add_entry(request, model_name): + if request.method == 'POST': + try: + if model_name == 'SecondTableEntry': + model = SecondTableEntry + + # Parse date + date_str = request.POST.get('date') + try: + date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else None + except (ValueError, TypeError): + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid date format. Use YYYY-MM-DD' + }, status=400) + + # NEW: robust parse of warm flag (handles 0/1, true/false, on/off) + raw_warm = request.POST.get('is_warm') + is_warm_bool = str(raw_warm).lower() in ('1', 'true', 'on', 'yes') + + # Handle Helium Output (Table Two) + lhe_output = request.POST.get('lhe_output') + + entry = model.objects.create( + client=Client.objects.get(id=request.POST.get('client_id')), + date=date_obj, + is_warm=is_warm_bool, # <-- use the boolean here + lhe_delivery=request.POST.get('lhe_delivery', ''), + lhe_output=Decimal(lhe_output) if lhe_output else None, + notes=request.POST.get('notes', '') + ) + + return JsonResponse({ + 'status': 'success', + 'id': entry.id, + 'client_name': entry.client.name, + 'institute_name': entry.client.institute.name, + 'date': entry.date.strftime('%Y-%m-%d') if entry.date else '', + 'is_warm': entry.is_warm, + 'lhe_delivery': entry.lhe_delivery, + 'lhe_output': str(entry.lhe_output) if entry.lhe_output else '', + 'notes': entry.notes + }) + + elif model_name == 'ExcelEntry': + model = ExcelEntry + + # Parse the date string into a date object + date_str = request.POST.get('date') + try: + date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else None + except (ValueError, TypeError): + date_obj = None + + try: + pressure = Decimal(request.POST.get('pressure', 0)) + purity = Decimal(request.POST.get('purity', 0)) + druckkorrektur = Decimal(request.POST.get('druckkorrektur', 1)) + lhe_zus = Decimal(request.POST.get('lhe_zus', 0)) + constant_300 = Decimal(request.POST.get('constant_300', 300)) + korrig_druck = Decimal(request.POST.get('korrig_druck', 0)) + nm3 = Decimal(request.POST.get('nm3', 0)) + lhe = Decimal(request.POST.get('lhe', 0)) + lhe_ges = Decimal(request.POST.get('lhe_ges', 0)) + except InvalidOperation: + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid numeric value in Helium Input' + }, status=400) + + # Create the entry with ALL fields + entry = model.objects.create( + client=Client.objects.get(id=request.POST.get('client_id')), + date=date_obj, + pressure=pressure, + purity=purity, + druckkorrektur=druckkorrektur, + lhe_zus=lhe_zus, + constant_300=constant_300, + korrig_druck=korrig_druck, + nm3=nm3, + lhe=lhe, + lhe_ges=lhe_ges, + notes=request.POST.get('notes', '') + ) + + # Prepare the response + response_data = { + 'status': 'success', + 'id': entry.id, + 'client_name': entry.client.name, + 'institute_name': entry.client.institute.name, + 'pressure': str(entry.pressure), + 'purity': str(entry.purity), + 'druckkorrektur': str(entry.druckkorrektur), + 'constant_300': str(entry.constant_300), + 'korrig_druck': str(entry.korrig_druck), + 'nm3': str(entry.nm3), + 'lhe': str(entry.lhe), + 'lhe_zus': str(entry.lhe_zus), + 'lhe_ges': str(entry.lhe_ges), + 'notes': entry.notes, + } + + if entry.date: + # JS uses this for the Date column and for the Month column + response_data['date'] = entry.date.strftime('%Y-%m-%d') + response_data['month'] = f"{entry.date.month:02d}" + else: + response_data['date'] = '' + response_data['month'] = '' + + return JsonResponse(response_data) + + + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}, status=400) + + return JsonResponse({'status': 'error', 'message': 'Invalid request'}, status=400) + +# Update Entry (Generic) +def update_entry(request, model_name): + if request.method == 'POST': + try: + if model_name == 'SecondTableEntry': + model = SecondTableEntry + elif model_name == 'ExcelEntry': + model = ExcelEntry + else: + return JsonResponse({'status': 'error', 'message': 'Invalid model'}, status=400) + + entry_id = int(request.POST.get('id')) + entry = model.objects.get(id=entry_id) + + # Common updates for both models + entry.client = Client.objects.get(id=request.POST.get('client_id')) + entry.notes = request.POST.get('notes', '') + + # Handle date properly for both models + date_str = request.POST.get('date') + if date_str: + try: + entry.date = datetime.strptime(date_str, '%Y-%m-%d').date() + except ValueError: + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid date format. Use YYYY-MM-DD' + }, status=400) + + if model_name == 'SecondTableEntry': + # Handle Helium Output specific fields + + raw_warm = request.POST.get('is_warm') + entry.is_warm = str(raw_warm).lower() in ('1', 'true', 'on', 'yes') + + entry.lhe_delivery = request.POST.get('lhe_delivery', '') + + lhe_output = request.POST.get('lhe_output') + try: + entry.lhe_output = Decimal(lhe_output) if lhe_output else None + except InvalidOperation: + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid LHe Output value' + }, status=400) + + entry.save() + + return JsonResponse({ + 'status': 'success', + 'id': entry.id, + 'client_name': entry.client.name, + 'institute_name': entry.client.institute.name, + 'date': entry.date.strftime('%Y-%m-%d') if entry.date else '', + 'is_warm': entry.is_warm, + 'lhe_delivery': entry.lhe_delivery, + 'lhe_output': str(entry.lhe_output) if entry.lhe_output else '', + 'notes': entry.notes + }) + + + elif model_name == 'ExcelEntry': + # Handle Helium Input specific fields + try: + entry.pressure = Decimal(request.POST.get('pressure', 0)) + entry.purity = Decimal(request.POST.get('purity', 0)) + entry.druckkorrektur = Decimal(request.POST.get('druckkorrektur', 1)) + entry.lhe_zus = Decimal(request.POST.get('lhe_zus', 0)) + entry.constant_300 = Decimal(request.POST.get('constant_300', 300)) + entry.korrig_druck = Decimal(request.POST.get('korrig_druck', 0)) + entry.nm3 = Decimal(request.POST.get('nm3', 0)) + entry.lhe = Decimal(request.POST.get('lhe', 0)) + entry.lhe_ges = Decimal(request.POST.get('lhe_ges', 0)) + except InvalidOperation: + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid numeric value in Helium Input' + }, status=400) + + entry.save() + + return JsonResponse({ + 'status': 'success', + 'id': entry.id, + 'client_name': entry.client.name, + 'institute_name': entry.client.institute.name, + 'date': entry.date.strftime('%Y-%m-%d') if entry.date else '', + 'month': f"{entry.date.month:02d}" if entry.date else '', + 'pressure': str(entry.pressure), + 'purity': str(entry.purity), + 'druckkorrektur': str(entry.druckkorrektur), + 'constant_300': str(entry.constant_300), + 'korrig_druck': str(entry.korrig_druck), + 'nm3': str(entry.nm3), + 'lhe': str(entry.lhe), + 'lhe_zus': str(entry.lhe_zus), + 'lhe_ges': str(entry.lhe_ges), + 'notes': entry.notes + }) + + + except model.DoesNotExist: + return JsonResponse({'status': 'error', 'message': 'Entry not found'}, status=404) + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}, status=400) + + return JsonResponse({'status': 'error', 'message': 'Invalid request method'}, status=400) + +# Delete Entry (Generic) +def delete_entry(request, model_name): + if request.method == 'POST': + try: + if model_name == 'SecondTableEntry': + model = SecondTableEntry + elif model_name == 'ExcelEntry': + model = ExcelEntry + else: + return JsonResponse({'status': 'error', 'message': 'Invalid model'}, status=400) + + entry_id = request.POST.get('id') + entry = model.objects.get(id=entry_id) + entry.delete() + return JsonResponse({'status': 'success', 'message': 'Entry deleted'}) + + except model.DoesNotExist: + return JsonResponse({'status': 'error', 'message': 'Entry not found'}, status=404) + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}, status=400) + + return JsonResponse({'status': 'error', 'message': 'Invalid request'}, status=400) + +def betriebskosten_list(request): + items = Betriebskosten.objects.all().order_by('-buchungsdatum') + + summary = get_summary() + summary.recalculate() + + context = { + 'items': items, + 'summary': summary, + } + return render(request, 'betriebskosten_list.html', context) + +def betriebskosten_create(request): + if request.method != 'POST': + return JsonResponse({'status': 'error', 'message': 'Invalid request method'}) + + try: + entry_id = request.POST.get('id') + if entry_id: + entry = Betriebskosten.objects.get(id=entry_id) + else: + entry = Betriebskosten() + gegenstand = request.POST.get("gegenstand", "").strip() + buchungsdatum_str = request.POST.get('buchungsdatum') + rechnungsnummer = request.POST.get('rechnungsnummer') # UI label: Firma + kostentyp = request.POST.get('kostentyp') + betrag_raw = request.POST.get('betrag') + beschreibung = request.POST.get('beschreibung') or '' + gas_volume_raw = request.POST.get('gas_volume') # UI label: Gasvolumen (m³) + + # Validate required fields + if not all([buchungsdatum_str, rechnungsnummer, kostentyp, betrag_raw]): + return JsonResponse({'status': 'error', 'message': 'All required fields must be filled'}) + + allowed = {'sach', 'helium'} + if kostentyp not in allowed: + return JsonResponse({'status': 'error', 'message': 'Invalid Kostentyp'}) + + # Date + buchungsdatum = parse_date(buchungsdatum_str) + if not buchungsdatum: + return JsonResponse({'status': 'error', 'message': 'Invalid date format'}) + + # Betrag + try: + betrag = Decimal(str(betrag_raw)) + except Exception: + return JsonResponse({'status': 'error', 'message': 'Invalid Betrag'}) + + # Gas volume (m³) only for helium + gas_m3 = None + if kostentyp == 'helium': + if gas_volume_raw not in (None, '', 'None'): + try: + gas_m3 = Decimal(str(gas_volume_raw)) + except Exception: + return JsonResponse({'status': 'error', 'message': 'Invalid Gasvolumen (m³)'}) + entry.gegenstand = gegenstand + entry.buchungsdatum = buchungsdatum + entry.rechnungsnummer = rechnungsnummer + entry.kostentyp = kostentyp + entry.betrag = betrag + entry.beschreibung = beschreibung + entry.gas_volume = gas_m3 + + entry.save() + summary = get_summary() + summary.recalculate() + + # Computed values for UI (read-only fields) + gas_liter = None + price_per_m3 = None + price_per_liter = None + + if entry.kostentyp == 'helium' and entry.gas_volume: + # Liter = m³ / 0.75 + gas_liter = (Decimal(entry.gas_volume) / Decimal('0.75')) + if entry.gas_volume != 0: + price_per_m3 = (Decimal(entry.betrag) / Decimal(entry.gas_volume)) + if gas_liter != 0: + price_per_liter = (Decimal(entry.betrag) / gas_liter) + + return JsonResponse({ + 'status': 'success', + 'id': entry.id, + 'gegenstand': entry.gegenstand, + 'buchungsdatum': entry.buchungsdatum.strftime('%Y-%m-%d'), + 'rechnungsnummer': entry.rechnungsnummer, + 'kostentyp': entry.kostentyp, + 'kostentyp_display': entry.get_kostentyp_display(), + 'gas_volume_m3': str(entry.gas_volume) if entry.gas_volume is not None else '-', + 'gas_volume_liter': str(gas_liter.quantize(Decimal("0.01"))) if gas_liter is not None else '', + 'price_per_m3': str(price_per_m3.quantize(Decimal("0.01"))) if price_per_m3 is not None else '', + 'price_per_liter': str(price_per_liter.quantize(Decimal("0.01"))) if price_per_liter is not None else '', + 'betrag': str(entry.betrag), + 'beschreibung': entry.beschreibung or '', + }) + + except Betriebskosten.DoesNotExist: + return JsonResponse({'status': 'error', 'message': 'Entry not found'}, status=404) + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}) + + +def betriebskosten_delete(request): + if request.method == 'POST': + entry_id = request.POST.get('id') + + if not entry_id or not entry_id.isdigit(): + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid ID' + }) + + try: + entry = Betriebskosten.objects.get(id=int(entry_id)) + entry.delete() + + summary = get_summary() + summary.recalculate() + + return JsonResponse({'status': 'success'}) + + except Betriebskosten.DoesNotExist: + return JsonResponse({ + 'status': 'error', + 'message': 'Entry not found' + }) + + return JsonResponse({'status': 'error'}) + + +def get_summary(): + summary, created = BetriebskostenSummary.objects.get_or_create(id=1) + return summary + +from django.http import JsonResponse + +def update_personalkosten(request): + if request.method == "POST": + value = request.POST.get("personalkosten") + summary = get_summary() + summary.personalkosten = Decimal(value or "0") + summary.recalculate() + return JsonResponse({ + "status": "success", + "umlage_personal": str(summary.umlage_personal), + }) + return JsonResponse({"status": "error"}) + + +class CheckSheetView(View): + def get(self, request): + # Get current month/year + current_year = datetime.now().year + current_month = datetime.now().month + + # Get all sheets + sheets = MonthlySheet.objects.all() + + sheet_data = [] + for sheet in sheets: + cells_count = sheet.cells.count() + # Count non-empty cells + non_empty = sheet.cells.exclude(value__isnull=True).count() + + sheet_data.append({ + 'id': sheet.id, + 'year': sheet.year, + 'month': sheet.month, + 'month_name': calendar.month_name[sheet.month], + 'total_cells': cells_count, + 'non_empty_cells': non_empty, + 'has_data': non_empty > 0 + }) + + # Also check what the default view would show + default_sheet = MonthlySheet.objects.filter( + year=current_year, + month=current_month + ).first() + + return JsonResponse({ + 'current_year': current_year, + 'current_month': current_month, + 'current_month_name': calendar.month_name[current_month], + 'default_sheet_exists': default_sheet is not None, + 'default_sheet_id': default_sheet.id if default_sheet else None, + 'sheets': sheet_data, + 'total_sheets': len(sheet_data) + }) + + +class QuickDebugView(View): + def get(self, request): + # Get ALL sheets + sheets = MonthlySheet.objects.all().order_by('year', 'month') + + result = { + 'sheets': [] + } + + for sheet in sheets: + sheet_info = { + 'id': sheet.id, + 'display': f"{sheet.year}-{sheet.month:02d}", + 'url': f"/sheet/{sheet.year}/{sheet.month}/", # CHANGED THIS LINE + 'sheet_url_pattern': 'sheet/{year}/{month}/', # Add this for clarity + } + + # Count cells with data for first client in top_left table + first_client = Client.objects.first() + if first_client: + test_cells = sheet.cells.filter( + client=first_client, + table_type='top_left', + row_index__in=[8, 9, 10] # Rows 9, 10, 11 + ).order_by('row_index') + + cell_values = {} + for cell in test_cells: + cell_values[f"row_{cell.row_index}"] = str(cell.value) if cell.value else "Empty" + + sheet_info['test_cells'] = cell_values + else: + sheet_info['test_cells'] = "No clients" + + result['sheets'].append(sheet_info) + + return JsonResponse(result) +class TestFormulaView(View): + def get(self, request): + # Test the formula evaluation directly + test_values = { + 8: 2, # Row 9 value (0-based index 8) + 9: 2, # Row 10 value (0-based index 9) + } + + # Test formula "9 + 8" (using 0-based indices) + formula = "9 + 8" + result = evaluate_formula(formula, test_values) + + return JsonResponse({ + 'test_values': test_values, + 'formula': formula, + 'result': str(result), + 'note': 'Formula uses 0-based indices. 9=Row10, 8=Row9' + }) + + +class SimpleDebugView(View): + """Simplest debug view to check if things are working""" + def get(self, request): + sheet_id = request.GET.get('sheet_id', 1) + + try: + sheet = MonthlySheet.objects.get(id=sheet_id) + + # Get first client + client = Client.objects.first() + if not client: + return JsonResponse({'error': 'No clients found'}) + + # Check a few cells + cells = Cell.objects.filter( + sheet=sheet, + client=client, + table_type='top_left', + row_index__in=[8, 9, 10] + ).order_by('row_index') + + cell_data = [] + for cell in cells: + cell_data.append({ + 'row_index': cell.row_index, + 'ui_row': cell.row_index + 1, + 'value': str(cell.value) if cell.value is not None else 'Empty', + 'cell_id': cell.id + }) + + return JsonResponse({ + 'sheet': f"{sheet.year}-{sheet.month}", + 'sheet_id': sheet.id, + 'client': client.name, + 'cells': cell_data, + 'note': 'Row 8 = UI Row 9, Row 9 = UI Row 10, Row 10 = UI Row 11' + }) + + except MonthlySheet.DoesNotExist: + return JsonResponse({'error': f'Sheet with id {sheet_id} not found'}) + +def halfyear_settings(request): + """ + Global settings page: choose a year + first month for the 6-month interval. + These values are stored in the session and used by other views. + """ + # Determine available years from your data (use ExcelEntry or SecondTableEntry) + # Here I use SecondTableEntry; you can switch to ExcelEntry if you prefer. + available_years_qs = SecondTableEntry.objects.dates('date', 'year', order='DESC') + available_years = [d.year for d in available_years_qs] + + if not available_years: + available_years = [timezone.now().year] + + # Defaults (if nothing in session yet) + default_year = request.session.get('halfyear_year', available_years[0]) + default_start_month = request.session.get('halfyear_start_month', 1) + + if request.method == 'POST': + year = int(request.POST.get('year', default_year)) + start_month = int(request.POST.get('start_month', default_start_month)) + + request.session['halfyear_year'] = year + request.session['halfyear_start_month'] = start_month + + # Redirect back to where user came from, or to this page again + next_url = request.POST.get('next') or request.GET.get('next') or 'halfyear_settings' + return redirect(next_url) + + # Month choices for the dropdown + month_choices = [(i, calendar.month_name[i]) for i in range(1, 13)] + + context = { + 'available_years': available_years, + 'selected_year': int(default_year), + 'selected_start_month': int(default_start_month), + 'month_choices': month_choices, + } + return render(request, 'halfyear_settings.html', context) \ No newline at end of file diff --git a/sheets/old/halfyear_balance.html b/sheets/old/halfyear_balance.html new file mode 100644 index 0000000..1a429db --- /dev/null +++ b/sheets/old/halfyear_balance.html @@ -0,0 +1,353 @@ +{% extends "base.html" %} + +{% block content %} +
+ + + +
+ ← Helium Output Übersicht +

+ {% with first=window.0 last=window|last %} + Halbjahres-Bilanz ({{ first.1 }}/{{ first.0 }} – {{ last.1 }}/{{ last.0 }}) + {% endwith %} +

+ {% with first=window.0 %} + Monatsblätter + {% endwith %} +
+ +
+ +
+

Top Left – Halbjahresbilanz

+ + + + + {% for c in clients_left %} + + {% endfor %} + + + + + {% for row in rows_left %} + + + {% for v in row.values %} + + {% endfor %} + + + {% endfor %} + +
Bezeichnung{{ c }}Σ
{{ row.label }} + {% if row.is_percent and v is not None %} + {{ v|floatformat:4 }} + {% elif v is not None %} + {{ v|floatformat:2 }} + {% endif %} + + {% if row.is_percent and row.total %} + {{ row.total|floatformat:4 }} + {% elif row.total is not None %} + {{ row.total|floatformat:2 }} + {% endif %} +
+
+ + +
+

Top Right – Halbjahresbilanz

+ + + + + {% for c in clients_right %} + + {% endfor %} + + + + + {% for row in rows_right %} + + + {% for v in row.values %} + + {% endfor %} + + + {% endfor %} + +
Bezeichnung{{ c }}Σ
{{ row.label }} + {% if row.is_text_row %} + {{ v }} + {% elif row.is_percent and v is not None %} + {{ v|floatformat:4 }} + {% elif v is not None %} + {{ v|floatformat:2 }} + {% endif %} + + {% if row.is_text_row %} + {{ row.total }} + {% elif row.is_percent and row.total %} + {{ row.total|floatformat:4 }} + {% elif row.total is not None %} + {{ row.total|floatformat:2 }} + {% endif %} +
+
+
+

Summe

+ + + + + + + + + + + + + {% for r in rows_sum %} + + + + + + + + + + + + + + {% endfor %} + +
BezeichnungΣLicht-wieseChemieMaWiM3
{{ r.label }} + {% if r.is_percent %} + {{ r.total|floatformat:2 }}% + {% else %} + {{ r.total|floatformat:2 }} + {% endif %} + + {% if r.is_percent %} + {{ r.lichtwiese|floatformat:2 }}% + {% else %} + {{ r.lichtwiese|floatformat:2 }} + {% endif %} + + {% if r.is_percent %} + {{ r.chemie|floatformat:2 }}% + {% else %} + {{ r.chemie|floatformat:2 }} + {% endif %} + + {% if r.is_percent %} + {{ r.mawi|floatformat:2 }}% + {% else %} + {{ r.mawi|floatformat:2 }} + {% endif %} + + {% if r.is_percent %} + {{ r.m3|floatformat:2 }}% + {% else %} + {{ r.m3|floatformat:2 }} + {% endif %} +
+
+
+

Bottom Table 1 – Bilanz (read-only)

+ + + + + + + + + + + + + + + {% for r in bottom1_rows %} + + + + + + + + + {% endfor %} + +
GasspeicherVolumenbarkorrigiertNm³Lit. LHe
{{ r.label }}{{ r.volume|floatformat:1 }}{{ r.bar|floatformat:0 }}{{ r.korr|floatformat:1 }}{{ r.nm3|floatformat:0 }}{{ r.lhe|floatformat:0 }}
+
+

Bottom Table 2 – Verbraucherbestand L-He (read-only)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Verbraucherbestand{{ bottom2.j38|floatformat:2 }}{{ bottom2.k38|floatformat:2 }}
+ Anlage:Gefäss 2,5:{{ bottom2.g39|floatformat:2 }}Gefäss 1,0:{{ bottom2.i39|floatformat:2 }}{{ bottom2.j39|floatformat:2 }}{{ bottom2.k39|floatformat:2 }}
+ Kaltgas:Gefäss 2,5:{{ bottom2.g40|default_if_none:""|floatformat:2 }}Gefäss 1,0:{{ bottom2.i40|default_if_none:""|floatformat:2 }}{{ bottom2.j40|floatformat:2 }}{{ bottom2.k40|floatformat:2 }}
Bestand flüssig He{{ bottom2.j43|floatformat:2 }}{{ bottom2.k43|floatformat:2 }}
Gesamtbestand neu:{{ bottom2.j44|floatformat:2 }}{{ bottom2.k44|floatformat:2 }}
+
+ +
+ + +
+
{# closes .spreadsheet-container #} +{% endblock %} diff --git a/sheets/old/views.py b/sheets/old/views.py new file mode 100644 index 0000000..b40f726 --- /dev/null +++ b/sheets/old/views.py @@ -0,0 +1,4401 @@ +from django.shortcuts import render, redirect +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.db.models import Sum, Value, DecimalField +from django.http import JsonResponse +from django.db.models import Q +from decimal import Decimal, InvalidOperation +from django.apps import apps +from datetime import date, datetime +import calendar +from django.utils import timezone +from django.views.generic import TemplateView, View +from .models import ( + Client, SecondTableEntry, Institute, ExcelEntry, + Betriebskosten, MonthlySheet, Cell, CellReference, MonthlySummary +) +from django.db.models import Sum +from django.urls import reverse +from django.db.models.functions import Coalesce +from .forms import BetriebskostenForm +from django.utils.dateparse import parse_date +from django.contrib.auth.mixins import LoginRequiredMixin +import json +FIRST_SHEET_YEAR = 2025 +FIRST_SHEET_MONTH = 1 + +CLIENT_GROUPS = { + 'ikp': { + 'label': 'IKP', + # exactly as in the Clients admin + 'names': ['IKP'], + }, + 'phys_chem_bunt_fohrer': { + 'label': 'Buntkowsky + Dr. Fohrer', + # include all variants you might have used for Buntkowsky + 'names': [ + 'AG Buntk.', # the one in your new entry + 'AG Buntkowsky.', # from your original list + 'AG Buntkowsky', + 'Dr. Fohrer', + ], + }, + 'mawi_alff_gutfleisch': { + 'label': 'Alff + AG Gutfleisch', + # include both short and full forms + 'names': [ + 'AG Alff', + 'AG Gutfl.', + 'AG Gutfleisch', + ], + }, + 'm3_group': { + 'label': 'M3 Buntkowsky + M3 Thiele + M3 Gutfleisch', + 'names': [ + 'M3 Buntkowsky', + 'M3 Thiele', + 'M3 Gutfleisch', + ], + }, +} + +# Add this CALCULATION_CONFIG at the top of views.py + +CALCULATION_CONFIG = { + 'top_left': { + # Row mappings: Django row_index (0-based) to Excel row + # Excel B4 -> Django row_index 1 (UI row 2) + # Excel B5 -> Django row_index 2 (UI row 3) + # Excel B6 -> Django row_index 3 (UI row 4) + + # B6 (row_index 3) = B5 (row_index 2) / 0.75 + 3: "2 / 0.75", + + # B11 (row_index 10) = B9 (row_index 8) + 10: "8", + + # B14 (row_index 13) = B13 (row_index 12) - B11 (row_index 10) + B12 (row_index 11) + 13: "12 - 10 + 11", + + # Note: B5, B17, B19, B20 require IF logic, so they'll be handled separately + }, + + # other tables (top_right, bottom_1, ...) stay as they are + + + ' top_right': { + # UI Row 1 (Excel Row 4): Stand der Gaszähler (Vormonat) (Nm³) + 0: { + 'L': "9 / (9 + 9) if (9 + 9) > 0 else 0", # L4 = L13/(L13+M13) + 'M': "9 / (9 + 9) if (9 + 9) > 0 else 0", # M4 = M13/(L13+M13) + 'N': "9 / (9 + 9) if (9 + 9) > 0 else 0", # N4 = N13/(N13+O13) + 'O': "9 / (9 + 9) if (9 + 9) > 0 else 0", # O4 = O13/(N13+O13) + 'P': None, # Editable + 'Q': None, # Editable + 'R': None, # Editable + }, + + # UI Row 2 (Excel Row 5): Gasrückführung (Nm³) + 1: { + 'L': "4", # L5 = L8 + 'M': "4", # M5 = L8 (merged) + 'N': "4", # N5 = N8 + 'O': "4", # O5 = N8 (merged) + 'P': "4 * 0", # P5 = P8 * P4 + 'Q': "4 * 0", # Q5 = P8 * Q4 + 'R': "4 * 0", # R5 = P8 * R4 + }, + + # UI Row 3 (Excel Row 6): Rückführung flüssig (Lit. L-He) + 2: { + 'L': "4", # L6 = L8 (Sammelrückführungen) + 'M': "4", + 'N': "4", + 'O': "4", + 'P': "4", + 'Q': "4", + 'R': "4", +}, + + # UI Row 4 (Excel Row 7): Sonderrückführungen (Lit. L-He) - EDITABLE + 3: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 5 (Excel Row 8): Sammelrückführungen (Lit. L-He) + 4: { + 'L': None, # Will be populated from ExcelEntry + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 6 (Excel Row 9): Bestand in Kannen-1 (Lit. L-He) - EDITABLE + 5: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 7 (Excel Row 10): Summe Bestand (Lit. L-He) + 6: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 11 (Excel Row 14): Rückführ. Soll (Lit. L-He) + # handled in calculate_top_right_dependents (merged pairs + M3) + 10: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 12 (Excel Row 15): Verluste (Soll-Rückf.) (Lit. L-He) + # handled in calculate_top_right_dependents + 11: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 14 (Excel Row 17): Kaltgas Rückgabe (Lit. L-He) – Faktor + # handled in calculate_top_right_dependents (different formulas for pair 1 vs pair 2 + M3) + 13: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 16 (Excel Row 19): Verbraucherverluste (Liter L-He) + # handled in calculate_top_right_dependents + 15: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 17 (Excel Row 20): % + # handled in calculate_top_right_dependents + 16: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + } +}, + 'bottom_1': { + 5: "4 + 3 + 2", + 8: "7 - 6", + }, + 'bottom_2': { + 3: "1 + 2", + 6: "5 - 4", + }, + 'bottom_3': { + 2: "0 + 1", + 5: "3 + 4", + }, + # Special configuration for summation column (last column) + 'summation_column': { + # For each row that should be summed across columns + 'rows_to_sum': [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23], # All rows + # OR specify specific rows: + # 'rows_to_sum': [0, 5, 10, 15, 20], # Only specific rows + # The last column index (0-based) + 'sum_column_index': 5, # 6th column (0-5) since you have 6 clients + } +} +def build_halfyear_window(interval_year: int, start_month: int): + """ + Build a list of (year, month) for the 6-month interval, possibly crossing into the next year. + Example: (2025, 10) -> [(2025,10), (2025,11), (2025,12), (2026,1), (2026,2), (2026,3)] + """ + window = [] + for offset in range(6): + total_index = (start_month - 1) + offset # 0-based + y = interval_year + (total_index // 12) + m = (total_index % 12) + 1 + window.append((y, m)) + return window +# --------------------------------------------------------------------------- +# Halbjahres-Bilanz helpers +# --------------------------------------------------------------------------- + +# You can adjust these indices if needed. +# Assuming: +# - bottom_1.table has row "Gasbestand" at some fixed row index, +# and columns: ... Nm³, Lit. LHe +GASBESTAND_ROW_INDEX = 9 # <-- adjust if your bottom_1 has a different row index +GASBESTAND_COL_NM3 = 3 # <-- adjust to the column index for Nm³ in bottom_1 + +# In top_left / top_right, "Bestand in Kannen-1 (Lit. L-He)" is row_index 5 +BESTAND_KANNEN_ROW_INDEX = 5 + +def pick_sheet_by_gasbestand(window, sheets_by_ym, prev_sheet): + """ + Returns the last sheet in the window whose Gasbestand (J36, Nm³ column) != 0. + If none found, returns prev_sheet (Übertrag_Dez__Vorjahr equivalent). + """ + for (y, m) in reversed(window): + sheet = sheets_by_ym.get((y, m)) + if not sheet: + continue + gasbestand_nm3 = get_bottom1_value(sheet, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) + if gasbestand_nm3 != 0: + return sheet + return prev_sheet +def get_bottom1_value(sheet, row_index: int, col_index: int) -> Decimal: + """Get a numeric value from bottom_1, or 0 if missing.""" + if sheet is None: + return Decimal('0') + + cell = Cell.objects.filter( + sheet=sheet, + table_type='bottom_1', + row_index=row_index, + column_index=col_index, + ).first() + + if cell is None or cell.value in (None, ''): + return Decimal('0') + + try: + return Decimal(str(cell.value)) + except Exception: + return Decimal('0') + +# MUST match the column order in your monthly_sheets top-right table + + + +def get_top_right_value(sheet, client_name: str, row_index: int) -> Decimal: + """ + Read a numeric value from the top_right table of a MonthlySheet for + a given client (by column) and row_index. + + top_right cells are keyed by (sheet, table_type='top_right', + row_index, column_index), where column_index is the position of the + client in HALFYEAR_RIGHT_CLIENTS. + """ + if sheet is None: + return Decimal('0') + + col_index = RIGHT_CLIENT_INDEX.get(client_name) + if col_index is None: + return Decimal('0') + + cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + row_index=row_index, + column_index=col_index, + ).first() + + if cell is None or cell.value in (None, ''): + return Decimal('0') + + try: + return Decimal(str(cell.value)) + except Exception: + return Decimal('0') + +TR_RUECKF_FLUESSIG_ROW = 2 # confirmed by your March value 172.840560 +TR_BESTAND_KANNEN_ROW = 5 # confirmed by your earlier query +def get_bestand_kannen_for_month(sheet, client_name: str) -> Decimal: + """ + 'B9' in your description: Bestand in Kannen-1 (Lit. L-He) + For this implementation we take it from top_left row_index = 5 for that client. + """ + return get_top_left_value(sheet, client_name, row_index=BESTAND_KANNEN_ROW_INDEX) + +from decimal import Decimal +from django.db.models import Sum +from django.db.models.functions import Coalesce +from django.db.models import DecimalField, Value + +from .models import MonthlySheet, SecondTableEntry, Client, Cell +from django.shortcuts import redirect, render + +# You already have HALFYEAR_CLIENTS for the left table (AG Vogel, AG Halfm, IKP) +HALFYEAR_CLIENTS = ["AG Vogel", "AG Halfm", "IKP"] + +# NEW: clients for the top-right half-year table +HALFYEAR_RIGHT_CLIENTS = [ + "Dr. Fohrer", + "AG Buntk.", + "AG Alff", + "AG Gutfl.", + "M3 Thiele", + "M3 Buntkowsky", + "M3 Gutfleisch", +] +BOTTOM1_COL_VOLUME = 0 +BOTTOM1_COL_BAR = 1 +BOTTOM1_COL_KORR = 2 +BOTTOM1_COL_NM3 = 3 +BOTTOM1_COL_LHE = 4 +BOTTOM2_ROW_ANLAGE = 0 +BOTTOM2_COL_G39 = 0 # "Gefäss 2,5" (cell id shows column_index=0) +BOTTOM2_COL_I39 = 1 # "Gefäss 1,0" (cell id shows column_index=1) +BOTTOM2_ROW_INPUTS = { + "g39": (0, 0), # row_index=0, column_index=0 (your G39) + "i39": (0, 1), # row_index=0, column_index=1 (your I39) +} +FACTOR_NM3_TO_LHE = Decimal("0.75") +RIGHT_CLIENT_INDEX = {name: idx for idx, name in enumerate(HALFYEAR_RIGHT_CLIENTS)} +def halfyear_balance_view(request): + """ + Read-only Halbjahres-Bilanz view. + + LEFT table: AG Vogel / AG Halfm / IKP (exactly as in your last working version) + RIGHT table: Dr. Fohrer / AG Buntk. / AG Alff / AG Gutfl. / + M3 Thiele / M3 Buntkowsky / M3 Gutfleisch + using the Excel formulas you described. + + Uses the global 6-month interval from the main page (clients_list). + """ + # 1) Read half-year interval from the session + interval_year = request.session.get('halfyear_year') + interval_start = request.session.get('halfyear_start_month') + + if not interval_year or not interval_start: + # No interval chosen yet -> redirect to main page + return redirect('clients_list') + + interval_year = int(interval_year) + interval_start = int(interval_start) + + # You already have this helper in your code + window = build_halfyear_window(interval_year, interval_start) + # window = [(y1, m1), (y2, m2), ..., (y6, m6)] + + # (Year, month) of the first month + start_year, start_month = window[0] + + # Previous month (for "Stand ... (Vorjahr)" and "Best. in Kannen Vormonat") + prev_total_index = (start_month - 1) - 1 # one month back, 0-based + if prev_total_index >= 0: + prev_year = start_year + (prev_total_index // 12) + prev_month = (prev_total_index % 12) + 1 + else: + prev_year = start_year - 1 + prev_month = 12 + + # Load MonthlySheet objects for the window and for the previous month + sheets_by_ym = {} + for (y, m) in window: + sheet = MonthlySheet.objects.filter(year=y, month=m).first() + sheets_by_ym[(y, m)] = sheet + + prev_sheet = MonthlySheet.objects.filter(year=prev_year, month=prev_month).first() + def pick_bottom2_from_window(window, sheets_by_ym, prev_sheet): + # choose sheet (same logic you already use) + chosen = None + for (y, m) in reversed(window): + s = sheets_by_ym.get((y, m)) + # use your existing condition for choosing month + if s: + chosen = s + break + if chosen is None: + chosen = prev_sheet + + # Now read the two inputs safely + bottom2_inputs = {} + for key, (row_idx, col_idx) in BOTTOM2_ROW_INPUTS.items(): + bottom2_inputs[key] = get_bottom2_value(chosen, row_idx, col_idx) + + return chosen, bottom2_inputs + + + chosen_sheet_bottom2, bottom2_inputs = pick_bottom2_from_window(window, sheets_by_ym, prev_sheet) + bottom2_g39 = bottom2_inputs["g39"] + bottom2_i39 = bottom2_inputs["i39"] + # ---------------------------- + # HALF-YEAR BOTTOM TABLE 1 (Bilanz) - Read only + # ---------------------------- + chosen_sheet_bottom1 = pick_sheet_by_gasbestand(window, sheets_by_ym, prev_sheet) + + # IMPORTANT: define which bottom_1 row_index corresponds to Excel rows 27..35 + # If your bottom_1 starts at Excel row 27 => row_index 0 == Excel 27 + # then row_index = excel_row - 27 + BOTTOM1_EXCEL_START_ROW = 27 + + bottom1_excel_rows = list(range(27, 37)) # 27..36 + BOTTOM1_LABELS = [ + "Batterie 1", + "2", + "3", + "4", + "5", + "Batterie Links", + "2 Bündel", + "2 Ballone", + "Reingasspeicher", + "Gasbestand", + ] + + BOTTOM1_VOLUMES = [ + Decimal("2.4"), + Decimal("5.1"), + Decimal("4.0"), + Decimal("1.0"), + Decimal("4.0"), + Decimal("0.6"), + Decimal("1.2"), + Decimal("20.0"), + Decimal("5.0"), + None, # Gasbestand row has no volume + ] + nm3_sum_27_35 = Decimal("0") + lhe_sum_27_35 = Decimal("0") + bottom1_rows = [] + + for excel_row in bottom1_excel_rows: + row_index = excel_row - BOTTOM1_EXCEL_START_ROW + + chosen_sheet_bottom1 = None + for (y, m) in reversed(window): + s = sheets_by_ym.get((y, m)) + gasbestand = get_bottom1_value(s, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) # J36 (Nm3) + if gasbestand != 0: + chosen_sheet_bottom1 = s + break + + if chosen_sheet_bottom1 is None: + chosen_sheet_bottom1 = prev_sheet + + # Normal rows (27..35): read from chosen sheet and accumulate sums + if excel_row != 36: + nm3_val = get_bottom1_value(chosen_sheet_bottom1, row_index, BOTTOM1_COL_NM3) + lhe_val = get_bottom1_value(chosen_sheet_bottom1, row_index, BOTTOM1_COL_LHE) + + nm3_sum_27_35 += nm3_val + lhe_sum_27_35 += lhe_val + + bottom1_rows.append({ + "label": BOTTOM1_LABELS[row_index], + "volume": BOTTOM1_VOLUMES[row_index], + "bar": get_bottom1_value(chosen_sheet_bottom1, row_index, BOTTOM1_COL_BAR), + "korr": get_bottom1_value(chosen_sheet_bottom1, row_index, BOTTOM1_COL_KORR), + "nm3": nm3_val, + "lhe": lhe_val, + }) + + # Gasbestand row (36): show sums (J36 = SUM(J27:J35), K36 = SUM(K27:K35)) + else: + bottom1_rows.append({ + "label": "Gasbestand", + "volume": "", + "bar": "", + "korr": "", + "nm3": nm3_sum_27_35, + "lhe": lhe_sum_27_35, + }) + start_sheet = sheets_by_ym.get((start_year, start_month)) + # ------------------------------------------------------------ + # Bottom Table 2 (Halbjahres Bilanz) – server-side recalcBottom2() + # ------------------------------------------------------------ + + FACTOR_BT2 = Decimal("0.75") + + # 1) Helper: pick last-nonzero value of bottom_2 row0 col0/col1 from the window (fallback: prev_sheet) + def pick_last_nonzero_bottom2(row_index: int, col_index: int) -> Decimal: + # Scan from last month in window backwards + for (y, m) in reversed(window): + s = sheets_by_ym.get((y, m)) + if not s: + continue + v = get_bottom2_value(s, row_index, col_index) + if v is not None and v != 0: + return v + # fallback to month before window + v_prev = get_bottom2_value(prev_sheet, row_index, col_index) + return v_prev if v_prev is not None else Decimal("0") + + # 2) K38 comes from Overall Summary: "Summe Bestand (Lit. L-He)" + # Find it from your already built overall summary rows list. + + k38 = Decimal("0") + j38 = Decimal("0") + # 3) Inputs G39 / I39 (picked from last non-zero month in window) + g39 = pick_last_nonzero_bottom2(row_index=0, col_index=0) # G39 + i39 = pick_last_nonzero_bottom2(row_index=0, col_index=1) # I39 + + k39 = (g39 or Decimal("0")) + (i39 or Decimal("0")) + j39 = k39 * FACTOR_BT2 + + # 4) +Kaltgas (row 40) + # JS: + # g40 = (2500 - g39)/100*10 + # i40 = (1000 - i39)/100*10 + g40 = None + i40 = None + if g39 is not None: + g40 = (Decimal("2500") - g39) / Decimal("100") * Decimal("10") + if i39 is not None: + i40 = (Decimal("1000") - i39) / Decimal("100") * Decimal("10") + + k40 = (g40 or Decimal("0")) + (i40 or Decimal("0")) + j40 = k40 * FACTOR_BT2 + + # 5) Bestand flüssig He (row 43) + k43 = ( + (k38 or Decimal("0")) + + (k39 or Decimal("0")) + + (k40 or Decimal("0")) + ) + j43 = k43 * FACTOR_BT2 + + # 6) Gesamtbestand neu (row 44) = Gasbestand(Lit) from Bottom Table 1 + k43 + gasbestand_lit = Decimal("0") + for r in bottom1_rows: + if (r.get("label") or "").strip().startswith("Gasbestand"): + gasbestand_lit = r.get("lhe") or Decimal("0") + break + + k44 = (gasbestand_lit or Decimal("0")) + (k43 or Decimal("0")) + j44 = k44 * FACTOR_BT2 + + bottom2 = { + "j38": j38, "k38": k38, + "g39": g39, "i39": i39, "j39": j39, "k39": k39, + "g40": g40, "i40": i40, "j40": j40, "k40": k40, + "j43": j43, "k43": k43, + "j44": j44, "k44": k44, + } + + # ------------------------------------------------------------------ + # 2) LEFT TABLE (your existing, working logic) + # ------------------------------------------------------------------ + HALFYEAR_CLIENTS_LEFT = ["AG Vogel", "AG Halfm", "IKP"] + + # We'll collect client-wise values first for clarity. + client_data_left = {name: {} for name in HALFYEAR_CLIENTS_LEFT} + + # --- Row B3: Stand der Gaszähler (Nm³) + # = MAX(B3 from previous month, and B3 from each of the 6 months in the window) + # row_index 0 in top_left = "Stand der Gaszähler (Nm³)" + months_for_max = [(prev_year, prev_month)] + window + + for cname in HALFYEAR_CLIENTS_LEFT: + max_val = Decimal('0') + for (y, m) in months_for_max: + sheet = sheets_by_ym.get((y, m)) + if sheet is None and (y, m) == (prev_year, prev_month): + sheet = prev_sheet + val_b3 = get_top_left_value(sheet, cname, row_index=0) + if val_b3 > max_val: + max_val = val_b3 + client_data_left[cname]['stand_gas'] = max_val + + # --- Row B4: Stand der Gaszähler (Vorjahr) (Nm³) -> previous month same row --- + for cname in HALFYEAR_CLIENTS_LEFT: + val_b4 = get_top_left_value(prev_sheet, cname, row_index=0) + client_data_left[cname]['stand_gas_prev'] = val_b4 + + # --- Row B5: Gasrückführung (Nm³) = B3 - B4 --- + for cname in HALFYEAR_CLIENTS_LEFT: + b3 = client_data_left[cname]['stand_gas'] + b4 = client_data_left[cname]['stand_gas_prev'] + client_data_left[cname]['gasrueckf'] = b3 - b4 + + # --- Row B6: Rückführung flüssig (Lit. L-He) = B5 / 0.75 --- + for cname in HALFYEAR_CLIENTS_LEFT: + b5 = client_data_left[cname]['gasrueckf'] + client_data_left[cname]['rueckf_fluessig'] = (b5 / Decimal('0.75')) if b5 != 0 else Decimal('0') + + # --- Row B7: Sonderrückführungen (Lit. L-He) = sum over 6 months of that row --- + # That row index is 4 in your top_left table. + for cname in HALFYEAR_CLIENTS_LEFT: + sonder_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + sonder_total += get_top_left_value(sheet, cname, row_index=4) + client_data_left[cname]['sonder'] = sonder_total + + # --- Row B8: Bestand in Kannen-1 (Lit. L-He) --- + # Excel-style logic with Gasbestand (J36) and fallback to previous month. + for cname in HALFYEAR_CLIENTS_LEFT: + chosen_value = None + + # Go from last month (window[5]) backwards to first (window[0]) + for (y, m) in reversed(window): + sheet = sheets_by_ym.get((y, m)) + gasbestand = get_bottom1_value(sheet, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) + if gasbestand != 0: + chosen_value = get_bestand_kannen_for_month(sheet, cname) + break + + # If still None -> use previous month (Übertrag_Dez__Vorjahr equivalent) + if chosen_value is None: + sheet_prev = prev_sheet + chosen_value = get_bestand_kannen_for_month(sheet_prev, cname) + + client_data_left[cname]['bestand_kannen'] = chosen_value if chosen_value is not None else Decimal('0') + + # --- Row B9: Summe Bestand (Lit. L-He) = equal to previous row --- + for cname in HALFYEAR_CLIENTS_LEFT: + client_data_left[cname]['summe_bestand'] = client_data_left[cname]['bestand_kannen'] + + # --- Row B10: Best. in Kannen Vormonat (Lit. L-He) + # = Bestand in Kannen-1 from the month BEFORE the window (prev_year, prev_month) + for cname in HALFYEAR_CLIENTS_LEFT: + client_data_left[cname]['best_kannen_vormonat'] = get_bestand_kannen_for_month(prev_sheet, cname) + + # --- Row B13: Bezug (Liter L-He) --- + for cname in HALFYEAR_CLIENTS_LEFT: + total_bezug = Decimal('0') + for (y, m) in window: + qs = SecondTableEntry.objects.filter( + client__name=cname, + date__year=y, + date__month=m, + ).aggregate( + total=Coalesce(Sum('lhe_output'), Value(0, output_field=DecimalField())) + ) + total_bezug += Decimal(str(qs['total'])) + client_data_left[cname]['bezug'] = total_bezug + + # --- Row B14: Rückführ. Soll (Lit. L-He) = Bezug - Summe Bestand + Best. in Kannen Vormonat --- + for cname in HALFYEAR_CLIENTS_LEFT: + b13 = client_data_left[cname]['bezug'] + b11 = client_data_left[cname]['summe_bestand'] + b12 = client_data_left[cname]['best_kannen_vormonat'] + client_data_left[cname]['rueckf_soll'] = b13 - b11 + b12 + + # --- Row B15: Verluste (Soll-Rückf.) (Lit. L-He) = B14 - B6 --- + for cname in HALFYEAR_CLIENTS_LEFT: + b14 = client_data_left[cname]['rueckf_soll'] + b6 = client_data_left[cname]['rueckf_fluessig'] + client_data_left[cname]['verluste'] = b14 - b6 + + # --- Row B16: Füllungen warm (Lit. L-He) = sum over 6 months (row_index=11) --- + for cname in HALFYEAR_CLIENTS_LEFT: + total_warm = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + total_warm += get_top_left_value(sheet, cname, row_index=11) + client_data_left[cname]['fuellungen_warm'] = total_warm + + # --- Row B17: Kaltgas Rückgabe (Lit. L-He) = Bezug * 0.06 --- + factor = Decimal('0.06') + for cname in HALFYEAR_CLIENTS_LEFT: + b13 = client_data_left[cname]['bezug'] + client_data_left[cname]['kaltgas_rueckgabe'] = b13 * factor + + # --- Row B18: Verbraucherverluste (Liter L-He) = Verluste - Kaltgas Rückgabe --- + for cname in HALFYEAR_CLIENTS_LEFT: + b15 = client_data_left[cname]['verluste'] + b17 = client_data_left[cname]['kaltgas_rueckgabe'] + client_data_left[cname]['verbraucherverluste'] = b15 - b17 + + # --- Row B19: % = Verbraucherverluste / Bezug --- + for cname in HALFYEAR_CLIENTS_LEFT: + bezug = client_data_left[cname]['bezug'] + verb = client_data_left[cname]['verbraucherverluste'] + if bezug != 0: + client_data_left[cname]['percent'] = verb / bezug + else: + client_data_left[cname]['percent'] = None + + # Build LEFT rows structure + left_row_defs = [ + ('Stand der Gaszähler (Nm³)', 'stand_gas'), + ('Stand der Gaszähler (Vorjahr) (Nm³)', 'stand_gas_prev'), + ('Gasrückführung (Nm³)', 'gasrueckf'), + ('Rückführung flüssig (Lit. L-He)', 'rueckf_fluessig'), + ('Sonderrückführungen (Lit. L-He)', 'sonder'), + ('Bestand in Kannen-1 (Lit. L-He)', 'bestand_kannen'), + ('Summe Bestand (Lit. L-He)', 'summe_bestand'), + ('Best. in Kannen Vormonat (Lit. L-He)', 'best_kannen_vormonat'), + ('Bezug (Liter L-He)', 'bezug'), + ('Rückführ. Soll (Lit. L-He)', 'rueckf_soll'), + ('Verluste (Soll-Rückf.) (Lit. L-He)', 'verluste'), + ('Füllungen warm (Lit. L-He)', 'fuellungen_warm'), + ('Kaltgas Rückgabe (Lit. L-He) – Faktor', 'kaltgas_rueckgabe'), + ('Verbraucherverluste (Liter L-He)', 'verbraucherverluste'), + ('%', 'percent'), + ] + + rows_left = [] + for label, key in left_row_defs: + values = [client_data_left[cname][key] for cname in HALFYEAR_CLIENTS_LEFT] + if key == 'percent': + total_bezug = sum((client_data_left[c]['bezug'] for c in HALFYEAR_CLIENTS_LEFT), Decimal('0')) + total_verb = sum((client_data_left[c]['verbraucherverluste'] for c in HALFYEAR_CLIENTS_LEFT), Decimal('0')) + total = (total_verb / total_bezug) if total_bezug != 0 else None + else: + total = sum((v for v in values if v is not None), Decimal('0')) + rows_left.append({ + 'label': label, + 'values': values, + 'total': total, + 'is_percent': key == 'percent', + }) + + # ------------------------------------------------------------------ + # 3) RIGHT TABLE (top-right half-year aggregation) + # ------------------------------------------------------------------ + RIGHT_CLIENTS = HALFYEAR_RIGHT_CLIENTS # for brevity + + right_data = {name: {} for name in RIGHT_CLIENTS} + + # --- Bezug (Liter L-He) for each right client (same as for left) --- + for cname in RIGHT_CLIENTS: + total_bezug = Decimal('0') + for (y, m) in window: + qs = SecondTableEntry.objects.filter( + client__name=cname, + date__year=y, + date__month=m, + ).aggregate( + total=Coalesce(Sum('lhe_output'), Value(0, output_field=DecimalField())) + ) + total_bezug += Decimal(str(qs['total'])) + right_data[cname]['bezug'] = total_bezug + def find_bestand_from_window(reference_client: str) -> Decimal: + """ + Implements: + WENN(last_month!J36=0; WENN(prev_month!J36=0; ...; prev_sheet!9); last_month!9) + reference_client decides which column (L/N/P/Q/R) we read from monthly top_right row_index=5. + """ + # scan backward through window + for (y, m) in reversed(window): + sh = sheets_by_ym.get((y, m)) + if not sh: + continue + gasbestand = get_bottom1_value(sh, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) + if gasbestand != 0: + return get_top_right_value(sh, reference_client, TR_BESTAND_KANNEN_ROW) + + # fallback to previous month (Übertrag_Dez__Vorjahr equivalent) + return get_top_right_value(prev_sheet, reference_client, TR_BESTAND_KANNEN_ROW) + + # Fohrer+Buntk merged: BOTH use Fohrer column (L9) + val_L = find_bestand_from_window("Dr. Fohrer") + right_data["Dr. Fohrer"]["bestand_kannen"] = val_L + right_data["AG Buntk."]["bestand_kannen"] = val_L + + # Alff+Gutfl merged: BOTH use Alff column (N9) + val_N = find_bestand_from_window("AG Alff") + right_data["AG Alff"]["bestand_kannen"] = val_N + right_data["AG Gutfl."]["bestand_kannen"] = val_N + + # M3 each uses its own column (P9/Q9/R9) + right_data["M3 Thiele"]["bestand_kannen"] = find_bestand_from_window("M3 Thiele") + right_data["M3 Buntkowsky"]["bestand_kannen"] = find_bestand_from_window("M3 Buntkowsky") + right_data["M3 Gutfleisch"]["bestand_kannen"] = find_bestand_from_window("M3 Gutfleisch") + # Helper for pair shares (L13/($L13+$M13), etc.) + def pair_share(c1, c2): + total = right_data[c1]['bezug'] + right_data[c2]['bezug'] + if total == 0: + return (Decimal('0'), Decimal('0')) + return ( + right_data[c1]['bezug'] / total, + right_data[c2]['bezug'] / total, + ) + + # --- "Stand der Gaszähler (Vorjahr) (Nm³)" row: share based on Bezug --- + # Dr. Fohrer / AG Buntk. + s_fohrer, s_buntk = pair_share("Dr. Fohrer", "AG Buntk.") + right_data["Dr. Fohrer"]['stand_prev_share'] = s_fohrer + right_data["AG Buntk."]['stand_prev_share'] = s_buntk + + # AG Alff / AG Gutfl. + s_alff, s_gutfl = pair_share("AG Alff", "AG Gutfl.") + right_data["AG Alff"]['stand_prev_share'] = s_alff + right_data["AG Gutfl."]['stand_prev_share'] = s_gutfl + + # M3 Thiele / M3 Buntkowsky / M3 Gutfleisch → empty in Excel → None + for cname in ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"]: + right_data[cname]['stand_prev_share'] = None + + # --- Rückführung flüssig per month (raw sums) --- + # top_right row_index=2 is "Rückführung flüssig (Lit. L-He)" + + # --- Sonderrückführungen (row_index=3 in top_right) --- + for cname in RIGHT_CLIENTS: + sonder_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + sonder_total += get_top_right_value(sheet, cname, row_index=3) + right_data[cname]['sonder'] = sonder_total + + # --- Sammelrückführung (row_index=4 in top_right), grouped & merged --- + # Group 1: Dr. Fohrer + AG Buntk. + group1_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + group1_total += get_top_right_value(sheet, "Dr. Fohrer", row_index=4) + right_data["Dr. Fohrer"]['sammel'] = group1_total + right_data["AG Buntk."]['sammel'] = group1_total + + # Group 2: AG Alff + AG Gutfl. + group2_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + group2_total += get_top_right_value(sheet, "AG Alff", row_index=4) + right_data["AG Alff"]['sammel'] = group2_total + right_data["AG Gutfl."]['sammel'] = group2_total + + # Group 3: M3 Thiele + M3 Buntkowsky + M3 Gutfleisch + group3_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + group3_total += get_top_right_value(sheet, "M3 Thiele", row_index=4) + right_data["M3 Thiele"]['sammel'] = group3_total + right_data["M3 Buntkowsky"]['sammel'] = group3_total + right_data["M3 Gutfleisch"]['sammel'] = group3_total + def safe_div(a: Decimal, b: Decimal) -> Decimal: + return (a / b) if b != 0 else Decimal("0") + + # --- Rückführung flüssig (Lit. L-He) for Halbjahres-Bilanz top-right --- + # Uses your exact formulas. + + # 1) Fohrer / Buntk split by BEZUG share times group SAMMEL (L8) + L13 = right_data["Dr. Fohrer"]["bezug"] + M13 = right_data["AG Buntk."]["bezug"] + L8 = right_data["Dr. Fohrer"]["sammel"] # merged group total + + den = (L13 + M13) + right_data["Dr. Fohrer"]["rueckf_fluessig"] = (safe_div(L13, den) * L8) if den != 0 else Decimal("0") + right_data["AG Buntk."]["rueckf_fluessig"] = (safe_div(M13, den) * L8) if den != 0 else Decimal("0") + + # 2) Alff / Gutfl split by BEZUG share times group SAMMEL (N8) + N13 = right_data["AG Alff"]["bezug"] + O13 = right_data["AG Gutfl."]["bezug"] + N8 = right_data["AG Alff"]["sammel"] # merged group total + + den = (N13 + O13) + right_data["AG Alff"]["rueckf_fluessig"] = (safe_div(N13, den) * N8) if den != 0 else Decimal("0") + right_data["AG Gutfl."]["rueckf_fluessig"] = (safe_div(O13, den) * N8) if den != 0 else Decimal("0") + + # 3) M3 Thiele = sum of monthly Rückführung flüssig (monthly top_right row_index=2) over window + P6_sum = Decimal("0") + for (y, m) in window: + sh = sheets_by_ym.get((y, m)) + P6_sum += get_top_right_value(sh, "M3 Thiele", TR_RUECKF_FLUESSIG_ROW) + right_data["M3 Thiele"]["rueckf_fluessig"] = P6_sum + + # 4) M3 Buntkowsky / M3 Gutfleisch split by BEZUG share times M3-group SAMMEL (P8) + P13 = right_data["M3 Thiele"]["bezug"] + Q13 = right_data["M3 Buntkowsky"]["bezug"] + R13 = right_data["M3 Gutfleisch"]["bezug"] + P8 = right_data["M3 Thiele"]["sammel"] # merged group total + + den = (P13 + Q13 + R13) + right_data["M3 Buntkowsky"]["rueckf_fluessig"] = (safe_div(Q13, den) * P8) if den != 0 else Decimal("0") + right_data["M3 Gutfleisch"]["rueckf_fluessig"] = (safe_div(R13, den) * P8) if den != 0 else Decimal("0") + # --- Bestand in Kannen-1 (Lit. L-He) for right table (grouped) --- + # Use Gasbestand (J36) and fallback logic, but now reading top_right B9 for each group. + TOP_RIGHT_ROW_BESTAND_KANNEN = 6 # <-- most likely correct in your setup + + def pick_bestand_top_right(base_client: str) -> Decimal: + # Go from last month in window backwards: if Gasbestand != 0, use that month's Bestand in Kannen + for (y, m) in reversed(window): + sh = sheets_by_ym.get((y, m)) + if not sh: + continue + + gasbestand = get_bottom1_value(sh, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) + if gasbestand != 0: + return get_top_right_value(sh, base_client, TOP_RIGHT_ROW_BESTAND_KANNEN) + + # Fallback to previous month (Übertrag_Dez__Vorjahr equivalent) + return get_top_right_value(prev_sheet, base_client, TOP_RIGHT_ROW_BESTAND_KANNEN) + + # Group 1 merged (Fohrer + Buntk.) + g1_best = pick_bestand_top_right("Dr. Fohrer") + right_data["Dr. Fohrer"]["bestand_kannen"] = g1_best + right_data["AG Buntk."]["bestand_kannen"] = g1_best + + # Group 2 merged (Alff + Gutfl.) + g2_best = pick_bestand_top_right("AG Alff") + right_data["AG Alff"]["bestand_kannen"] = g2_best + right_data["AG Gutfl."]["bestand_kannen"] = g2_best + + # Group 3 merged (M3 Thiele + M3 Buntkowsky + M3 Gutfleisch) + g3_best = pick_bestand_top_right("M3 Thiele") + right_data["M3 Thiele"]["bestand_kannen"] = g3_best + right_data["M3 Buntkowsky"]["bestand_kannen"] = g3_best + right_data["M3 Gutfleisch"]["bestand_kannen"] = g3_best + + # Summe Bestand = same as previous row + for cname in RIGHT_CLIENTS: + right_data[cname]['summe_bestand'] = right_data[cname]['bestand_kannen'] + + # Best. in Kannen Vormonat (Lit. L-He) from previous month top_right row_index=7 + g1_prev = get_top_right_value(prev_sheet, "Dr. Fohrer", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["Dr. Fohrer"]['best_kannen_vormonat'] = g1_prev + right_data["AG Buntk."]['best_kannen_vormonat'] = g1_prev + + g2_prev = get_top_right_value(prev_sheet, "AG Alff", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["AG Alff"]['best_kannen_vormonat'] = g2_prev + right_data["AG Gutfl."]['best_kannen_vormonat'] = g2_prev + + + # Group 1 merged (Fohrer + Buntk.) + g1_prev = get_top_right_value(prev_sheet, "Dr. Fohrer", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["Dr. Fohrer"]["best_kannen_vormonat"] = g1_prev + right_data["AG Buntk."]["best_kannen_vormonat"] = g1_prev + + # Group 2 merged (Alff + Gutfl.) + g2_prev = get_top_right_value(prev_sheet, "AG Alff", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["AG Alff"]["best_kannen_vormonat"] = g2_prev + right_data["AG Gutfl."]["best_kannen_vormonat"] = g2_prev + + # Group 3 UNMERGED (each one reads its own cell) + right_data["M3 Thiele"]["best_kannen_vormonat"] = get_top_right_value(prev_sheet, "M3 Thiele", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["M3 Buntkowsky"]["best_kannen_vormonat"] = get_top_right_value(prev_sheet, "M3 Buntkowsky", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["M3 Gutfleisch"]["best_kannen_vormonat"] = get_top_right_value(prev_sheet, "M3 Gutfleisch", TOP_RIGHT_ROW_BESTAND_KANNEN) + + # --- Rückführ. Soll (Lit. L-He) according to your formulas --- + + # Group 1: Dr. Fohrer / AG Buntk. + total_bestand_1 = right_data["Dr. Fohrer"]['summe_bestand'] + best_vormonat_1 = right_data["Dr. Fohrer"]['best_kannen_vormonat'] + diff1 = total_bestand_1 - best_vormonat_1 + share_fohrer = right_data["Dr. Fohrer"]['stand_prev_share'] or Decimal('0') + + right_data["Dr. Fohrer"]['rueckf_soll'] = ( + right_data["Dr. Fohrer"]['bezug'] - diff1 * share_fohrer + ) + right_data["AG Buntk."]['rueckf_soll'] = ( + right_data["AG Buntk."]['bezug'] - total_bestand_1 + best_vormonat_1 + ) + + # Group 2: AG Alff / AG Gutfl. + total_bestand_2 = right_data["AG Alff"]['summe_bestand'] + best_vormonat_2 = right_data["AG Alff"]['best_kannen_vormonat'] + diff2 = total_bestand_2 - best_vormonat_2 + share_alff = right_data["AG Alff"]['stand_prev_share'] or Decimal('0') + share_gutfl = right_data["AG Gutfl."]['stand_prev_share'] or Decimal('0') + + right_data["AG Alff"]['rueckf_soll'] = ( + right_data["AG Alff"]['bezug'] - diff2 * share_alff + ) + right_data["AG Gutfl."]['rueckf_soll'] = ( + right_data["AG Gutfl."]['bezug'] - diff2 * share_gutfl + ) + + # Group 3: M3 Thiele / M3 Buntkowsky / M3 Gutfleisch + for cname in ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"]: + b13 = right_data[cname]['bezug'] + b12 = right_data[cname]['best_kannen_vormonat'] + b11 = right_data[cname]['summe_bestand'] + # Excel: P13+P12-P11 etc. + right_data[cname]['rueckf_soll'] = b13 + b12 - b11 + + # --- Verluste (Soll-Rückf.) (Lit. L-He) = B14 - B6 - B7 --- + for cname in RIGHT_CLIENTS: + b14 = right_data[cname]['rueckf_soll'] + b6 = right_data[cname]['rueckf_fluessig'] + b7 = right_data[cname]['sonder'] + right_data[cname]['verluste'] = b14 - b6 - b7 + + # --- Füllungen warm (Lit. L-He) = sum of monthly 'Füllungen warm' (row_index=11 top_right) --- + for cname in RIGHT_CLIENTS: + total_warm = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + total_warm += get_top_right_value(sheet, cname, row_index=11) + right_data[cname]['fuellungen_warm'] = total_warm + + # --- Kaltgas Rückgabe (Lit. L-He) – Faktor = Bezug * 0.06 --- + for cname in RIGHT_CLIENTS: + b13 = right_data[cname]['bezug'] + right_data[cname]['kaltgas_rueckgabe'] = b13 * factor + + # --- Verbraucherverluste (Liter L-He) = Verluste - Kaltgas Rückgabe --- + for cname in RIGHT_CLIENTS: + b15 = right_data[cname]['verluste'] + b17 = right_data[cname]['kaltgas_rueckgabe'] + right_data[cname]['verbraucherverluste'] = b15 - b17 + + # --- % = Verbraucherverluste / Bezug --- + for cname in RIGHT_CLIENTS: + bezug = right_data[cname]['bezug'] + verb = right_data[cname]['verbraucherverluste'] + if bezug != 0: + right_data[cname]['percent'] = verb / bezug + else: + right_data[cname]['percent'] = None + + # Build RIGHT rows structure + right_row_defs = [ + ('Stand der Gaszähler (Vorjahr) (Nm³)', 'stand_prev_share'), + # We skip the pure-text "Gasrückführung (Nm³)" line here, + # because it’s only text (Aufteilung nach Verbrauch / Gaszähler) + # and easier to render directly in the template if needed. + ('Rückführung flüssig (Lit. L-He)', 'rueckf_fluessig'), + ('Sonderrückführungen (Lit. L-He)', 'sonder'), + ('Sammelrückführung (Lit. L-He)', 'sammel'), + ('Bestand in Kannen-1 (Lit. L-He)', 'bestand_kannen'), + ('Summe Bestand (Lit. L-He)', 'summe_bestand'), + ('Best. in Kannen Vormonat (Lit. L-He)', 'best_kannen_vormonat'), + ('Bezug (Liter L-He)', 'bezug'), + ('Rückführ. Soll (Lit. L-He)', 'rueckf_soll'), + ('Verluste (Soll-Rückf.) (Lit. L-He)', 'verluste'), + ('Füllungen warm (Lit. L-He)', 'fuellungen_warm'), + ('Kaltgas Rückgabe (Lit. L-He) – Faktor', 'kaltgas_rueckgabe'), + ('Verbraucherverluste (Liter L-He)', 'verbraucherverluste'), + ('%', 'percent'), + ] + + rows_right = [] + for label, key in right_row_defs: + values = [right_data[cname].get(key) for cname in RIGHT_CLIENTS] + + if key == 'percent': + total_bezug = sum((right_data[c]['bezug'] for c in RIGHT_CLIENTS), Decimal('0')) + total_verb = sum((right_data[c]['verbraucherverluste'] for c in RIGHT_CLIENTS), Decimal('0')) + total = (total_verb / total_bezug) if total_bezug != 0 else None + else: + total = sum((v for v in values if isinstance(v, Decimal)), Decimal('0')) + + rows_right.append({ + 'label': label, + 'values': values, + 'total': total, + 'is_percent': key == 'percent', + }) + SUM_TABLE_ROWS = [ + ("Rückführung flüssig (Lit. L-He)", "rueckf_fluessig"), + ("Sonderrückführungen (Lit. L-He)", "sonder"), + ("Sammelrückführungen (Lit. L-He)", "sammel"), + ("Bestand in Kannen-1 (Lit. L-He)", "bestand_kannen"), + ("Summe Bestand (Lit. L-He)", "summe_bestand"), + ("Best. in Kannen Vormonat (Lit. L-He)", "best_kannen_vormonat"), + ("Bezug (Liter L-He)", "bezug"), + ("Rückführ. Soll (Lit. L-He)", "rueckf_soll"), + ("Verluste (Soll-Rückf.) (Lit. L-He)", "verluste"), + ("Füllungen warm (Lit. L-He)", "fuellungen_warm"), + ("Kaltgas Rückgabe (Lit. L-He) – Faktor", "kaltgas_rueckgabe"), + ("Faktor 0.06", "factor_row"), + ("Verbraucherverluste (Liter L-He)", "verbraucherverluste"), + ("%", "percent"), + ] + + RIGHT_GROUPS = { + "chemie": ["Dr. Fohrer", "AG Buntk."], + "mawi": ["AG Alff", "AG Gutfl."], + "m3": ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"], + } + + RIGHT_ALL = ["Dr. Fohrer", "AG Buntk.", "AG Alff", "AG Gutfl.", "M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"] + LEFT_ALL = HALFYEAR_CLIENTS_LEFT + + def safe_pct(verb, bez): + return (verb / bez) if bez != 0 else None + rows_sum = [] + def d(x): + return x if isinstance(x, Decimal) else Decimal("0") + for label, key in SUM_TABLE_ROWS: + + if key == "factor_row": + lichtwiese = chemie = mawi = m3 = total = Decimal("0.06") + + elif key == "percent": + # Right totals + rw_bez = sum(d(right_data[c].get("bezug")) for c in RIGHT_ALL) + rw_verb = sum(d(right_data[c].get("verbraucherverluste")) for c in RIGHT_ALL) + lichtwiese = safe_pct(rw_verb, rw_bez) + + # Chemie + ch_bez = sum(d(right_data[c].get("bezug")) for c in RIGHT_GROUPS["chemie"]) + ch_verb = sum(d(right_data[c].get("verbraucherverluste")) for c in RIGHT_GROUPS["chemie"]) + chemie = safe_pct(ch_verb, ch_bez) + + # MaWi + mw_bez = sum(d(right_data[c].get("bezug")) for c in RIGHT_GROUPS["mawi"]) + mw_verb = sum(d(right_data[c].get("verbraucherverluste")) for c in RIGHT_GROUPS["mawi"]) + mawi = safe_pct(mw_verb, mw_bez) + + # M3 + m3_bez = sum(d(right_data[c].get("bezug")) for c in RIGHT_GROUPS["m3"]) + m3_verb = sum(d(right_data[c].get("verbraucherverluste")) for c in RIGHT_GROUPS["m3"]) + m3 = safe_pct(m3_verb, m3_bez) + + # Σ column = (left verb + right verb) / (left bez + right bez) + left_bez = sum(d(client_data_left[c].get("bezug")) for c in LEFT_ALL) + left_verb = sum(d(client_data_left[c].get("verbraucherverluste")) for c in LEFT_ALL) + total = safe_pct(left_verb + rw_verb, left_bez + rw_bez) + + else: + # normal rows = sums + lichtwiese = sum(d(right_data[c].get(key)) for c in RIGHT_ALL) + chemie = sum(d(right_data[c].get(key)) for c in RIGHT_GROUPS["chemie"]) + mawi = sum(d(right_data[c].get(key)) for c in RIGHT_GROUPS["mawi"]) + m3 = sum(d(right_data[c].get(key)) for c in RIGHT_GROUPS["m3"]) + + left_total = sum(d(client_data_left[c].get(key)) for c in LEFT_ALL) + total = left_total + lichtwiese + + rows_sum.append({ + "row_index": row_index, + "label": label, + "total": total, + "lichtwiese": lichtwiese, + "chemie": chemie, + "mawi": mawi, + "m3": m3, + "is_percent": (key == "percent"), + }) + def find_sum_row(rows, label_startswith: str): + for r in rows: + if str(r.get("label", "")).strip().startswith(label_startswith): + return r + return None + + summe_bestand_row = find_sum_row(rows_sum, "Summe Bestand") + k38 = (summe_bestand_row.get("total") if summe_bestand_row else Decimal("0")) or Decimal("0") + j38 = k38 * Decimal("0.75") + # --- FIX: now that k38 is known, update bottom2 + recompute dependent rows --- + bottom2["k38"] = k38 + bottom2["j38"] = j38 + + k39 = bottom2.get("k39") or Decimal("0") + k40 = bottom2.get("k40") or Decimal("0") + + # Row 43: Bestand flüssig He = SUMME(K38:K40) + k43 = (k38 or Decimal("0")) + k39 + k40 + j43 = k43 * Decimal("0.75") + + bottom2["k43"] = k43 + bottom2["j43"] = j43 + + # Row 44: Gesamtbestand neu = Gasbestand(Lit) from bottom table 1 + k43 + gasbestand_lit = Decimal("0") + for r in bottom1_rows: + if (r.get("label") or "").strip().startswith("Gasbestand"): + gasbestand_lit = r.get("lhe") or Decimal("0") + break + + k44 = gasbestand_lit + k43 + j44 = k44 * Decimal("0.75") + + bottom2["k44"] = k44 + bottom2["j44"] = j44 + def d(x): + return x if isinstance(x, Decimal) else Decimal("0") + + # ---- Bottom2: J38/K38 depend on rows_sum (overall summary), so do it HERE ---- + k38 = Decimal("0") + for r in rows_sum: + if r.get("label") == "Summe Bestand (Lit. L-He)": + k38 = r.get("total") or Decimal("0") + break + + j38 = k38 * FACTOR_NM3_TO_LHE # 0.75 + bottom2["k38"] = k38 + bottom2["j38"] = j38 + # ------------------------------------------------------------------ + # 4) Context – keep old keys AND new ones + # ------------------------------------------------------------------ + context = { + 'interval_year': interval_year, + 'interval_start_month': interval_start, + 'window': window, + + # Left table – old names (for your first template) + 'clients': HALFYEAR_CLIENTS_LEFT, + 'rows': rows_left, + + # Left table – explicit + 'clients_left': HALFYEAR_CLIENTS_LEFT, + 'rows_left': rows_left, + + # Right table + 'clients_right': RIGHT_CLIENTS, + 'rows_right': rows_right, + 'rows_sum': rows_sum, + 'bottom1_rows': bottom1_rows, + + } + context["bottom2"] = bottom2 + context["context_bottom2_g39"] = bottom2_inputs["g39"] + context["context_bottom2_i39"] = bottom2_inputs["i39"] + return render(request, 'halfyear_balance.html', context) + +def get_bottom2_value(sheet, row_index: int, col_index: int) -> Decimal: + """Get numeric value from bottom_2 or 0 if missing.""" + if sheet is None: + return Decimal("0") + cell = Cell.objects.filter( + sheet=sheet, + table_type="bottom_2", + row_index=row_index, + column_index=col_index, + ).first() + if cell is None or cell.value in (None, ""): + return Decimal("0") + try: + return Decimal(str(cell.value)) + except Exception: + return Decimal("0") + +def get_top_left_value(sheet, client_name: str, row_index: int) -> Decimal: + """ + Read a numeric value from the top_left table for a given month, client and row. + Does NOT use column_index, because top_left is keyed only by client + row_index. + """ + if sheet is None: + return Decimal('0') + + client_obj = Client.objects.filter(name=client_name).first() + if not client_obj: + return Decimal('0') + + cell = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client_obj, + row_index=row_index, + ).first() + + if cell is None or cell.value in (None, ''): + return Decimal('0') + + try: + return Decimal(str(cell.value)) + except Exception: + return Decimal('0') +def get_group_clients(group_key): + """Return queryset of clients that belong to a logical group.""" + from .models import Client # local import to avoid circulars + + group = CLIENT_GROUPS.get(group_key) + if not group: + return Client.objects.none() + return Client.objects.filter(name__in=group['names']) + +def calculate_summation(sheet, table_type, row_index, sum_column_index): + """Calculate summation for a row, with special handling for % row""" + from decimal import Decimal + from .models import Cell + + try: + # Special case: top_left, % row (Excel B20 -> row_index 19) + if table_type == 'top_left' and row_index == 19: + # K13 = sum of row 13 (Excel B13 -> row_index 12) across all clients + cells_row13 = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + row_index=12, # Excel B13 = row_index 12 + column_index__lt=sum_column_index # Exclude sum column itself + ) + total_13 = Decimal('0') + for cell in cells_row13: + if cell.value is not None: + total_13 += Decimal(str(cell.value)) + + # K19 = sum of row 19 (Excel B19 -> row_index 18) across all clients + cells_row19 = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + row_index=18, # Excel B19 = row_index 18 + column_index__lt=sum_column_index + ) + total_19 = Decimal('0') + for cell in cells_row19: + if cell.value is not None: + total_19 += Decimal(str(cell.value)) + + # Calculate: IF(K13=0; 0; K19/K13) + if total_13 == 0: + return Decimal('0') + return total_19 / total_13 + + # Normal summation for other rows + cells_in_row = Cell.objects.filter( + sheet=sheet, + table_type=table_type, + row_index=row_index, + column_index__lt=sum_column_index + ) + + total = Decimal('0') + for cell in cells_in_row: + if cell.value is not None: + total += Decimal(str(cell.value)) + + return total + + except Exception as e: + print(f"Error calculating summation for {table_type}[{row_index}]: {e}") + return None + +# Helper function for calculations +def evaluate_formula(formula, values_dict): + """ + Safely evaluate a formula like "10 + 9" where numbers are row indices + values_dict: {row_index: decimal_value} + """ + from decimal import Decimal + import re + + try: + # Create a copy of the formula to work with + expr = formula + + # Find all row numbers in the formula + row_refs = re.findall(r'\b\d+\b', expr) + + for row_ref in row_refs: + row_num = int(row_ref) + if row_num in values_dict and values_dict[row_num] is not None: + # Replace row reference with actual value + expr = expr.replace(row_ref, str(values_dict[row_num])) + else: + # Missing value - can't calculate + return None + + # Evaluate the expression + # Note: In production, use a safer evaluator like `asteval` + result = eval(expr, {"__builtins__": {}}, {}) + + # Convert to Decimal with proper rounding + return Decimal(str(round(result, 6))) + + except Exception: + return None + +# Monthly Sheet View +class MonthlySheetView(TemplateView): + template_name = 'monthly_sheet.html' + + def populate_helium_input_to_top_right(self, sheet): + """Populate bezug data from SecondTableEntry to top-right table (row 8 = Excel row 12)""" + from .models import SecondTableEntry, Cell, Client + from django.db.models.functions import Coalesce + from decimal import Decimal + + year = sheet.year + month = sheet.month + + TOP_RIGHT_CLIENTS = [ + "Dr. Fohrer", # Column index 0 (L) + "AG Buntk.", # Column index 1 (M) + "AG Alff", # Column index 2 (N) + "AG Gutfl.", # Column index 3 (O) + "M3 Thiele", # Column index 4 (P) + "M3 Buntkowsky", # Column index 5 (Q) + "M3 Gutfleisch", # Column index 6 (R) + ] + + # For each client in top-right table + for client_name in TOP_RIGHT_CLIENTS: + try: + client = Client.objects.get(name=client_name) + column_index = TOP_RIGHT_CLIENTS.index(client_name) + + # Calculate total LHe_output for this client in this month from SecondTableEntry + total_lhe_output = SecondTableEntry.objects.filter( + client=client, + date__year=year, + date__month=month + ).aggregate( + total=Coalesce(Sum('lhe_output'), Decimal('0')) + )['total'] + + # Get or create the cell for row_index 8 (Excel row 12) - Bezug + cell, created = Cell.objects.get_or_create( + sheet=sheet, + table_type='top_right', + client=client, + row_index=8, # Bezug row (Excel row 12) + column_index=column_index, + defaults={'value': total_lhe_output} + ) + + if not created and cell.value != total_lhe_output: + cell.value = total_lhe_output + cell.save() + + except Client.DoesNotExist: + continue + + # After populating bezug, trigger calculation for all dependent cells + # Get any cell to start the calculation + first_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right' + ).first() + + if first_cell: + save_view = SaveCellsView() + save_view.calculate_top_right_dependents(sheet, first_cell) + + return True + def calculate_bezug_from_entries(self, sheet, year, month): + """Calculate B11 (Bezug) from SecondTableEntry for all clients - ONLY for non-start sheets""" + from .models import SecondTableEntry, Cell, Client + from django.db.models import Sum + from django.db.models.functions import Coalesce + from decimal import Decimal + + # Check if this is the start sheet + if year == 2025 and month == 1: + return # Don't auto-calculate for start sheet + + for client in Client.objects.all(): + # Calculate total LHe output for this client in this month + lhe_output_sum = SecondTableEntry.objects.filter( + client=client, + date__year=year, + date__month=month + ).aggregate( + total=Coalesce(Sum('lhe_output'), Decimal('0')) + )['total'] + + # Update B11 cell (row_index 8 = UI Row 9) + b11_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client, + row_index=8 # Excel B11 + ).first() + + if b11_cell and (b11_cell.value != lhe_output_sum or b11_cell.value is None): + b11_cell.value = lhe_output_sum + b11_cell.save() + + # Also trigger dependent calculations + from .views import SaveCellsView + save_view = SaveCellsView() + save_view.calculate_top_left_dependents(sheet, b11_cell) + # In MonthlySheetView.get_context_data() method, update the TOP_RIGHT_CLIENTS and row count: + + + + return True + def get_context_data(self, **kwargs): + from decimal import Decimal + context = super().get_context_data(**kwargs) + year = self.kwargs.get('year', datetime.now().year) + month = self.kwargs.get('month', datetime.now().month) + is_start_sheet = (year == FIRST_SHEET_YEAR and month == FIRST_SHEET_MONTH) + + # Get or create the monthly sheet + sheet, created = MonthlySheet.objects.get_or_create( + year=year, month=month + ) + + # All clients (used for bottom tables etc.) + clients = Client.objects.all().order_by('name') + + # Pre-fill cells if creating new sheet + if created: + self.initialize_sheet_cells(sheet, clients) + + # Apply previous month links (for B4 and B12) + self.apply_previous_month_links(sheet, year, month) + self.calculate_bezug_from_entries(sheet, year, month) + self.populate_helium_input_to_top_right(sheet) + self.apply_previous_month_links_top_right(sheet, year, month) + + # Define client groups + TOP_LEFT_CLIENTS = [ + "AG Vogel", + "AG Halfm", + "IKP", + ] + + TOP_RIGHT_CLIENTS = [ + "Dr. Fohrer", + "AG Buntk.", + "AG Alff", + "AG Gutfl.", + "M3 Thiele", + "M3 Buntkowsky", + "M3 Gutfleisch", + ] + current_summary = MonthlySummary.objects.filter(sheet=sheet).first() + + # Get previous month summary (for Bottom Table 3: K46 = prev K44) + prev_month_info = self.get_prev_month(year, month) + prev_summary = None + if not is_start_sheet: + prev_sheet = MonthlySheet.objects.filter( + year=prev_month_info['year'], + month=prev_month_info['month'] + ).first() + if prev_sheet: + prev_summary = MonthlySummary.objects.filter(sheet=prev_sheet).first() + + context.update({ + # ... your existing context ... + 'current_summary': current_summary, + 'prev_summary': prev_summary, + }) + # Update row counts in build_group_rows function + # Update row counts in build_group_rows function + def build_group_rows(sheet, table_type, client_names): + """Build rows for display in monthly sheet.""" + from decimal import Decimal + from .models import Cell + MERGED_ROWS = {2, 3, 5, 6, 7, 9, 10, 12, 14, 15} + MERGED_PAIRS = [ + ("Dr. Fohrer", "AG Buntk."), + ("AG Alff", "AG Gutfl."), + ] + rows = [] + + # Determine row count + row_counts = { + "top_left": 16, + "top_right": 16, # rows 0–15 + "bottom_1": 10, + "bottom_2": 10, + "bottom_3": 10, + } + row_count = row_counts.get(table_type, 0) + + # Get all cells for this sheet and table + all_cells = ( + Cell.objects.filter( + sheet=sheet, + table_type=table_type, + ).select_related("client") + ) + + # Group cells by row index + cells_by_row = {} + for cell in all_cells: + if cell.row_index not in cells_by_row: + cells_by_row[cell.row_index] = {} + cells_by_row[cell.row_index][cell.client.name] = cell + + # We will store row sums for Bezug (row 8) and Verbraucherverluste (row 14) + # for top_left / top_right so we can compute the overall % in row 15. + sum_bezug = None + sum_verbrauch = None + + # Build each row + for row_idx in range(row_count): + display_cells = [] + row_cells_dict = cells_by_row.get(row_idx, {}) + + # Build cells in the requested client order + for name in client_names: + cell = row_cells_dict.get(name) + display_cells.append(cell) + + # Calculate sum for this row (includes editable + calculated cells) + sum_value = None + total = Decimal("0") + has_value = False + + merged_second_indices = set() + if table_type == 'top_right' and row_idx in MERGED_ROWS: + for left_name, right_name in MERGED_PAIRS: + try: + right_idx = client_names.index(right_name) + merged_second_indices.add(right_idx) + except ValueError: + # client not in this table; just ignore + pass + + for col_idx, cell in enumerate(display_cells): + # Skip the duplicate (second) column of each merged pair + if col_idx in merged_second_indices: + continue + + if cell and cell.value is not None: + try: + total += Decimal(str(cell.value)) + has_value = True + except Exception: + pass + + if has_value: + sum_value = total + + # Remember special rows for top tables + if table_type in ("top_left", "top_right"): + if row_idx == 8: # Bezug + sum_bezug = total + elif row_idx == 14: # Verbraucherverluste + sum_verbrauch = total + + rows.append( + { + "cells": display_cells, + "sum": sum_value, + "row_index": row_idx, + } + ) + + # Adjust the % row sum for top_left / top_right: + # Sum(%) = Sum(Verbraucherverluste) / Sum(Bezug) + if table_type in ("top_left", "top_right"): + perc_row_idx = 15 # % row + if 0 <= perc_row_idx < len(rows): + if sum_bezug is not None and sum_bezug != 0 and sum_verbrauch is not None: + rows[perc_row_idx]["sum"] = sum_verbrauch / sum_bezug + else: + rows[perc_row_idx]["sum"] = None + + return rows + + + # Now call the local function + top_left_rows = build_group_rows(sheet, 'top_left', TOP_LEFT_CLIENTS) + top_right_rows = build_group_rows(sheet, 'top_right', TOP_RIGHT_CLIENTS) + + # --- Build combined summary of top-left + top-right Sum columns --- + + # Helper to safely get the Sum for a given row index + def get_row_sum(rows, row_index): + if 0 <= row_index < len(rows): + return rows[row_index].get('sum') + return None + + # Row definitions we want in the combined Σ table + # (row_index in top tables, label shown in the small table) + summary_row_defs = [ + (2, "Rückführung flüssig (Lit. L-He)"), + (3, "Sonderrückführungen (Lit. L-He)"), + (4, "Sammelrückführungen (Lit. L-He)"), + (5, "Bestand in Kannen-1 (Lit. L-He)"), + (6, "Summe Bestand (Lit. L-He)"), + (7, "Best. in Kannen Vormonat (Lit. L-He)"), + (8, "Bezug (Liter L-He)"), + (9, "Rückführ. Soll (Lit. L-He)"), + (10, "Verluste (Soll-Rückf.) (Lit. L-He)"), + (11, "Füllungen warm (Lit. L-He)"), + (12, "Kaltgas Rückgabe (Lit. L-He) – Faktor"), + (13, "Faktor 0.06"), + (14, "Verbraucherverluste (Liter L-He)"), + (15, "%"), + ] + + # Precompute totals for Bezug and Verbraucherverluste across both tables + bezug_left = get_row_sum(top_left_rows, 8) or Decimal('0') + bezug_right = get_row_sum(top_right_rows, 8) or Decimal('0') + total_bezug = bezug_left + bezug_right + + verb_left = get_row_sum(top_left_rows, 14) or Decimal('0') + verb_right = get_row_sum(top_right_rows, 14) or Decimal('0') + total_verbrauch = verb_left + verb_right + + summary_rows = [] + + for row_index, label in summary_row_defs: + # Faktor row: always fixed 0.06 + if row_index == 13: + summary_value = Decimal('0.06') + + # % row: total Verbraucherverluste / total Bezug + elif row_index == 15: + if total_bezug != 0: + summary_value = total_verbrauch / total_bezug + else: + summary_value = None + + else: + left_sum = get_row_sum(top_left_rows, row_index) + right_sum = get_row_sum(top_right_rows, row_index) + + # Sammelrückführungen: only from top-right table + if row_index == 4: + left_sum = None + + total = Decimal('0') + + has_any = False + if left_sum is not None: + total += Decimal(str(left_sum)) + has_any = True + if right_sum is not None: + total += Decimal(str(right_sum)) + has_any = True + + summary_value = total if has_any else None + + summary_rows.append({ + 'row_index': row_index, + 'label': label, + 'sum': summary_value, + }) + + # Get cells for bottom tables + cells_by_table = self.get_cells_by_table(sheet) + + context.update({ + 'sheet': sheet, + 'clients': clients, + 'year': year, + 'month': month, + 'month_name': calendar.month_name[month], + 'prev_month': self.get_prev_month(year, month), + 'next_month': self.get_next_month(year, month), + 'cells_by_table': cells_by_table, + 'top_left_headers': TOP_LEFT_CLIENTS + ['Sum'], + 'top_right_headers': TOP_RIGHT_CLIENTS + ['Sum'], + 'top_left_rows': top_left_rows, + 'top_right_rows': top_right_rows, + 'summary_rows': summary_rows, # 👈 NEW + 'is_start_sheet': is_start_sheet, + }) + return context + + def get_cells_by_table(self, sheet): + """Organize cells by table type for easy template rendering""" + cells = sheet.cells.select_related('client').all() + organized = { + 'top_left': [[] for _ in range(16)], + 'top_right': [[] for _ in range(16)], # now 16 rows + 'bottom_1': [[] for _ in range(10)], + 'bottom_2': [[] for _ in range(10)], + 'bottom_3': [[] for _ in range(10)], + } + + for cell in cells: + if cell.table_type not in organized: + continue + + max_rows = len(organized[cell.table_type]) + if cell.row_index < 0 or cell.row_index >= max_rows: + # This is an "extra" cell from an older layout (e.g. top_left rows 18–23) + continue + + row_list = organized[cell.table_type][cell.row_index] + while len(row_list) <= cell.column_index: + row_list.append(None) + + row_list[cell.column_index] = cell + + return organized + + + def initialize_sheet_cells(self, sheet, clients): + """Create all empty cells for a new monthly sheet""" + cells_to_create = [] + summation_config = CALCULATION_CONFIG.get('summation_column', {}) + sum_column_index = summation_config.get('sum_column_index', len(clients) - 1) # Last column + + # For each table type and row + table_configs = [ + ('top_left', 16), + ('top_right', 16), + ('bottom_1', 10), + ('bottom_2', 10), + ('bottom_3', 10), + ] + + for table_type, row_count in table_configs: + for row_idx in range(row_count): + for col_idx, client in enumerate(clients): + is_summation = (col_idx == sum_column_index) + cells_to_create.append(Cell( + sheet=sheet, + client=client, + table_type=table_type, + row_index=row_idx, + column_index=col_idx, + value=None, + is_formula=is_summation, # Mark summation cells as formulas + )) + + # Bulk create all cells at once + Cell.objects.bulk_create(cells_to_create) + def get_prev_month(self, year, month): + """Get previous month year and month""" + if month == 1: + return {'year': year - 1, 'month': 12} + return {'year': year, 'month': month - 1} + + def get_next_month(self, year, month): + """Get next month year and month""" + if month == 12: + return {'year': year + 1, 'month': 1} + return {'year': year, 'month': month + 1} + def apply_previous_month_links(self, sheet, year, month): + """ + For non-start sheets: + B4 (row 2) = previous sheet B3 (row 1) + B10 (row 8) = previous sheet B9 (row 7) + """ + # Do nothing on the first sheet + if year == FIRST_SHEET_YEAR and month == FIRST_SHEET_MONTH: + return + + # Figure out previous month + if month == 1: + prev_year = year - 1 + prev_month = 12 + else: + prev_year = year + prev_month = month - 1 + + from .models import MonthlySheet, Cell, Client + + prev_sheet = MonthlySheet.objects.filter( + year=prev_year, + month=prev_month + ).first() + + if not prev_sheet: + # No previous sheet created yet → nothing to copy + return + + # For each client, copy values + for client in Client.objects.all(): + # B3(prev) → B4(curr): UI row 1 → row 2 → row_index 0 → 1 + prev_b3 = Cell.objects.filter( + sheet=prev_sheet, + table_type='top_left', + client=client, + row_index=0, # UI row 1 + ).first() + + curr_b4 = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client, + row_index=1, # UI row 2 + ).first() + + if prev_b3 and curr_b4: + curr_b4.value = prev_b3.value + curr_b4.save() + + # B9(prev) → B10(curr): UI row 7 → row 8 → row_index 6 → 7 + prev_b9 = Cell.objects.filter( + sheet=prev_sheet, + table_type='top_left', + client=client, + row_index=6, # UI row 7 (Summe Bestand) + ).first() + + curr_b10 = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client, + row_index=7, # UI row 8 (Best. in Kannen Vormonat) + ).first() + + if prev_b9 and curr_b10: + curr_b10.value = prev_b9.value + curr_b10.save() + + def apply_previous_month_links_top_right(self, sheet, year, month): + """ + top_right row 7: Best. in Kannen Vormonat (Lit. L-He) + = previous sheet's Summe Bestand (row 6). + + For merged pairs: + - Dr. Fohrer + AG Buntk. share the SAME value (from previous month's AG Buntk. or Dr. Fohrer) + - AG Alff + AG Gutfl. share the SAME value (from previous month's AG Alff or AG Gutfl.) + M3 clients just copy their own value. + """ + from .models import MonthlySheet, Cell, Client + from decimal import Decimal + + # Do nothing on first sheet + if year == FIRST_SHEET_YEAR and month == FIRST_SHEET_MONTH: + return + + # find previous month + if month == 1: + prev_year = year - 1 + prev_month = 12 + else: + prev_year = year + prev_month = month - 1 + + prev_sheet = MonthlySheet.objects.filter( + year=prev_year, + month=prev_month + ).first() + + if not prev_sheet: + return # nothing to copy from + + TOP_RIGHT_CLIENTS = [ + "Dr. Fohrer", + "AG Buntk.", + "AG Alff", + "AG Gutfl.", + "M3 Thiele", + "M3 Buntkowsky", + "M3 Gutfleisch", + ] + + # Helper function to get a cell value + def get_cell_value(sheet_obj, client_name, row_index): + client_obj = Client.objects.filter(name=client_name).first() + if not client_obj: + return None + + try: + col_idx = TOP_RIGHT_CLIENTS.index(client_name) + except ValueError: + return None + + cell = Cell.objects.filter( + sheet=sheet_obj, + table_type='top_right', + client=client_obj, + row_index=row_index, + column_index=col_idx, + ).first() + + return cell.value if cell else None + + # Helper function to set a cell value + def set_cell_value(sheet_obj, client_name, row_index, value): + client_obj = Client.objects.filter(name=client_name).first() + if not client_obj: + return False + + try: + col_idx = TOP_RIGHT_CLIENTS.index(client_name) + except ValueError: + return False + + cell, created = Cell.objects.get_or_create( + sheet=sheet_obj, + table_type='top_right', + client=client_obj, + row_index=row_index, + column_index=col_idx, + defaults={'value': value} + ) + + if not created and cell.value != value: + cell.value = value + cell.save() + elif created: + cell.save() + + return True + + # ----- Pair 1: Dr. Fohrer + AG Buntk. ----- + # Get previous month's Summe Bestand (row 6) for either client in the pair + pair1_prev_val = None + + # Try AG Buntk. first + prev_buntk_val = get_cell_value(prev_sheet, "AG Buntk.", 6) + if prev_buntk_val is not None: + pair1_prev_val = prev_buntk_val + else: + # Try Dr. Fohrer if AG Buntk. is empty + prev_fohrer_val = get_cell_value(prev_sheet, "Dr. Fohrer", 6) + if prev_fohrer_val is not None: + pair1_prev_val = prev_fohrer_val + + # Apply the value to both clients in the pair + if pair1_prev_val is not None: + set_cell_value(sheet, "Dr. Fohrer", 7, pair1_prev_val) + set_cell_value(sheet, "AG Buntk.", 7, pair1_prev_val) + + # ----- Pair 2: AG Alff + AG Gutfl. ----- + pair2_prev_val = None + + # Try AG Alff first + prev_alff_val = get_cell_value(prev_sheet, "AG Alff", 6) + if prev_alff_val is not None: + pair2_prev_val = prev_alff_val + else: + # Try AG Gutfl. if AG Alff is empty + prev_gutfl_val = get_cell_value(prev_sheet, "AG Gutfl.", 6) + if prev_gutfl_val is not None: + pair2_prev_val = prev_gutfl_val + + # Apply the value to both clients in the pair + if pair2_prev_val is not None: + set_cell_value(sheet, "AG Alff", 7, pair2_prev_val) + set_cell_value(sheet, "AG Gutfl.", 7, pair2_prev_val) + + # ----- M3 clients: copy their own Summe Bestand ----- + for name in ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"]: + prev_val = get_cell_value(prev_sheet, name, 6) + if prev_val is not None: + set_cell_value(sheet, name, 7, prev_val) +# Add this helper function to views.py +def get_factor_value(table_type, row_index): + """Get factor value (like 0.06 for top_left row 17)""" + factors = { + ('top_left', 17): Decimal('0.06'), # A18 in Excel (UI row 17, 0-based index 16) + } + return factors.get((table_type, row_index), Decimal('0')) +# Save Cells View +# views.py - Updated SaveCellsView +# views.py - Update SaveCellsView class +def debug_cell_values(self, sheet, client_id): + """Debug method to check cell values""" + from .models import Cell + cells = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client_id=client_id + ).order_by('row_index') + + debug_info = {} + for cell in cells: + debug_info[f"row_{cell.row_index}"] = { + 'value': str(cell.value) if cell.value else 'None', + 'ui_row': cell.row_index + 1, + 'excel_ref': f"B{cell.row_index + 3}" + } + + return debug_info +class DebugCalculationView(View): + """Debug view to test calculations directly""" + def get(self, request): + sheet_id = request.GET.get('sheet_id', 1) + client_name = request.GET.get('client', 'AG Vogel') + + try: + sheet = MonthlySheet.objects.get(id=sheet_id) + client = Client.objects.get(name=client_name) + + # Get SaveCellsView instance + save_view = SaveCellsView() + + # Create a dummy cell to trigger calculations + dummy_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client, + row_index=0 # B3 + ).first() + + if not dummy_cell: + return JsonResponse({'error': 'No cells found for this client'}) + + # Trigger calculation + updated = save_view.calculate_top_left_dependents(sheet, dummy_cell) + + # Get updated cell values + cells = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client + ).order_by('row_index') + + cell_data = [] + for cell in cells: + cell_data.append({ + 'row_index': cell.row_index, + 'ui_row': cell.row_index + 1, + 'excel_ref': f"B{cell.row_index + 3}", + 'value': str(cell.value) if cell.value else 'None', + 'description': self.get_row_description(cell.row_index) + }) + + return JsonResponse({ + 'sheet': f"{sheet.year}-{sheet.month:02d}", + 'client': client.name, + 'cells': cell_data, + 'updated_count': len(updated), + 'calculation': 'B5 = IF(B3>0; B3-B4; 0)' + }) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=400) + + def get_row_description(self, row_index): + """Get description for row index""" + descriptions = { + 0: "B3: Stand der Gaszähler (Nm³)", + 1: "B4: Stand der Gaszähler (Vormonat) (Nm³)", + 2: "B5: Gasrückführung (Nm³)", + 3: "B6: Rückführung flüssig (Lit. L-He)", + 4: "B7: Sonderrückführungen (Lit. L-He)", + 5: "B8: Bestand in Kannen-1 (Lit. L-He)", + 6: "B9: Summe Bestand (Lit. L-He)", + 7: "B10: Best. in Kannen Vormonat (Lit. L-He)", + 8: "B11: Bezug (Liter L-He)", + 9: "B12: Rückführ. Soll (Lit. L-He)", + 10: "B13: Verluste (Soll-Rückf.) (Lit. L-He)", + 11: "B14: Füllungen warm (Lit. L-He)", + 12: "B15: Kaltgas Rückgabe (Lit. L-He) – Faktor", + 13: "B16: Faktor", + 14: "B17: Verbraucherverluste (Liter L-He)", + 15: "B18: %" + } + return descriptions.get(row_index, f"Row {row_index}") +def recalculate_stand_der_gaszahler(self, sheet): + """Recalculate Stand der Gaszähler for all client pairs""" + from decimal import Decimal + + # For Dr. Fohrer and AG Buntk. (L & M columns) + try: + # Get Dr. Fohrer's bezug + dr_fohrer_client = Client.objects.get(name="Dr. Fohrer") + dr_fohrer_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=dr_fohrer_client, + row_index=9 # Row 9 = Bezug + ).first() + L13 = Decimal(str(dr_fohrer_cell.value)) if dr_fohrer_cell and dr_fohrer_cell.value else Decimal('0') + + # Get AG Buntk.'s bezug + ag_buntk_client = Client.objects.get(name="AG Buntk.") + ag_buntk_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_buntk_client, + row_index=9 + ).first() + M13 = Decimal(str(ag_buntk_cell.value)) if ag_buntk_cell and ag_buntk_cell.value else Decimal('0') + + total = L13 + M13 + if total > 0: + # Update Dr. Fohrer's row 0 + dr_fohrer_row0 = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=dr_fohrer_client, + row_index=0 + ).first() + if dr_fohrer_row0: + dr_fohrer_row0.value = L13 / total + dr_fohrer_row0.save() + + # Update AG Buntk.'s row 0 + ag_buntk_row0 = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_buntk_client, + row_index=0 + ).first() + if ag_buntk_row0: + ag_buntk_row0.value = M13 / total + ag_buntk_row0.save() + except Exception as e: + print(f"Error recalculating Stand der Gaszähler for Dr. Fohrer/AG Buntk.: {e}") + + # For AG Alff and AG Gutfl. (N & O columns) + try: + # Get AG Alff's bezug + ag_alff_client = Client.objects.get(name="AG Alff") + ag_alff_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_alff_client, + row_index=9 + ).first() + N13 = Decimal(str(ag_alff_cell.value)) if ag_alff_cell and ag_alff_cell.value else Decimal('0') + + # Get AG Gutfl.'s bezug + ag_gutfl_client = Client.objects.get(name="AG Gutfl.") + ag_gutfl_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_gutfl_client, + row_index=9 + ).first() + O13 = Decimal(str(ag_gutfl_cell.value)) if ag_gutfl_cell and ag_gutfl_cell.value else Decimal('0') + + total = N13 + O13 + if total > 0: + # Update AG Alff's row 0 + ag_alff_row0 = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_alff_client, + row_index=0 + ).first() + if ag_alff_row0: + ag_alff_row0.value = N13 / total + ag_alff_row0.save() + + # Update AG Gutfl.'s row 0 + ag_gutfl_row0 = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_gutfl_client, + row_index=0 + ).first() + if ag_gutfl_row0: + ag_gutfl_row0.value = O13 / total + ag_gutfl_row0.save() + except Exception as e: + print(f"Error recalculating Stand der Gaszähler for AG Alff/AG Gutfl.: {e}") +# In your SaveCellsView class in views.py +class DebugTopRightView(View): + """Debug view to check top_right calculations""" + def get(self, request): + sheet_id = request.GET.get('sheet_id', 1) + client_name = request.GET.get('client', 'Dr. Fohrer') + + try: + sheet = MonthlySheet.objects.get(id=sheet_id) + client = Client.objects.get(name=client_name) + + # Get all cells for this client in top_right + cells = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=client + ).order_by('row_index') + + cell_data = [] + descriptions = { + 0: "Stand der Gaszähler (Vormonat)", + 1: "Gasrückführung (Nm³)", + 2: "Rückführung flüssig", + 3: "Sonderrückführungen", + 4: "Sammelrückführungen", + 5: "Bestand in Kannen-1", + 6: "Summe Bestand", + 7: "Best. in Kannen Vormonat", + 8: "Same as row 9 from prev sheet", + 9: "Bezug", + 10: "Rückführ. Soll", + 11: "Verluste", + 12: "Füllungen warm", + 13: "Kaltgas Rückgabe", + 14: "Faktor 0.06", + 15: "Verbraucherverluste", + 16: "%" + } + + for cell in cells: + cell_data.append({ + 'row_index': cell.row_index, + 'ui_row': cell.row_index + 1, + 'description': descriptions.get(cell.row_index, f"Row {cell.row_index}"), + 'value': str(cell.value) if cell.value else 'Empty', + 'cell_id': cell.id + }) + + # Test calculation + row3_cell = cells.filter(row_index=3).first() + row5_cell = cells.filter(row_index=5).first() + row6_cell = cells.filter(row_index=6).first() + + calculation_info = { + 'row3_value': str(row3_cell.value) if row3_cell and row3_cell.value else '0', + 'row5_value': str(row5_cell.value) if row5_cell and row5_cell.value else '0', + 'row6_value': str(row6_cell.value) if row6_cell and row6_cell.value else '0', + 'expected_sum': '0' + } + + if row3_cell and row5_cell and row6_cell: + row3_val = Decimal(str(row3_cell.value)) if row3_cell.value else Decimal('0') + row5_val = Decimal(str(row5_cell.value)) if row5_cell.value else Decimal('0') + expected = row3_val + row5_val + calculation_info['expected_sum'] = str(expected) + calculation_info['is_correct'] = row6_cell.value == expected + + return JsonResponse({ + 'sheet': f"{sheet.year}-{sheet.month}", + 'client': client.name, + 'cells': cell_data, + 'calculation': calculation_info + }) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=400) + + +class SaveCellsView(View): + + def calculate_bottom_3_dependents(self, sheet): + updated_cells = [] + + def get_cell(row_idx, col_idx): + return Cell.objects.filter( + sheet=sheet, + table_type='bottom_3', + row_index=row_idx, + column_index=col_idx + ).first() + + def set_cell(row_idx, col_idx, value): + cell, created = Cell.objects.get_or_create( + sheet=sheet, + table_type='bottom_3', + row_index=row_idx, + column_index=col_idx, + defaults={'value': value} + ) + if not created and cell.value != value: + cell.value = value + cell.save() + elif created: + cell.save() + + updated_cells.append({ + 'id': cell.id, + 'value': str(cell.value) if cell.value is not None else '', + 'is_calculated': True, + }) + + def dec(x): + if x in (None, ''): + return Decimal('0') + try: + return Decimal(str(x)) + except Exception: + return Decimal('0') + + # ---- current summary ---- + cur_sum = MonthlySummary.objects.filter(sheet=sheet).first() + curr_k44 = dec(cur_sum.gesamtbestand_neu_lhe) if cur_sum else Decimal('0') + total_verb = dec(cur_sum.verbraucherverlust_lhe) if cur_sum else Decimal('0') + + # ---- previous month summary ---- + year, month = sheet.year, sheet.month + if month == 1: + prev_year, prev_month = year - 1, 12 + else: + prev_year, prev_month = year, month - 1 + + prev_sheet = MonthlySheet.objects.filter(year=prev_year, month=prev_month).first() + if prev_sheet: + prev_sum = MonthlySummary.objects.filter(sheet=prev_sheet).first() + prev_k44 = dec(prev_sum.gesamtbestand_neu_lhe) if prev_sum else Decimal('0') + else: + prev_k44 = Decimal('0') + + # ---- read editable inputs from bottom_3: F47,G47,I47,I50 ---- + def get_val(r, c): + cell = get_cell(r, c) + return dec(cell.value if cell else None) + + f47 = get_val(1, 0) + g47 = get_val(1, 1) + i47 = get_val(1, 2) + i50 = get_val(4, 2) + + # Now apply your formulas using prev_k44, curr_k44, total_verb, f47,g47,i47,i50 + # Row indices: 0..7 correspond to 46..53 + # col 3 = J, col 4 = K + + # Row 46 + k46 = prev_k44 + j46 = k46 * Decimal('0.75') + set_cell(0, 3, j46) + set_cell(0, 4, k46) + + # Row 47 + g47 = self._dec((get_cell(1, 1) or {}).value if get_cell(1, 1) else None) + i47 = self._dec((get_cell(1, 2) or {}).value if get_cell(1, 2) else None) + + j47 = g47 + i47 + k47 = (j47 / Decimal('0.75')) + g47 if j47 != 0 else g47 + + set_cell(1, 3, j47) + set_cell(1, 4, k47) + + # Row 48 + k48 = k46 + k47 + j48 = k48 * Decimal('0.75') + set_cell(2, 3, j48) + set_cell(2, 4, k48) + + # Row 49 + k49 = curr_k44 + j49 = k49 * Decimal('0.75') + set_cell(3, 3, j49) + set_cell(3, 4, k49) + + # Row 50 + j50 = i50 + k50 = j50 / Decimal('0.75') if j50 != 0 else Decimal('0') + set_cell(4, 3, j50) + set_cell(4, 4, k50) + + # Row 51 + k51 = k48 - k49 - k50 + j51 = k51 * Decimal('0.75') + set_cell(5, 3, j51) + set_cell(5, 4, k51) + + # Row 52 + k52 = total_verb + j52 = k52 * Decimal('0.75') + set_cell(6, 3, j52) + set_cell(6, 4, k52) + + # Row 53 + j53 = j51 - j52 + k53 = k51 - k52 + set_cell(7, 3, j53) + set_cell(7, 4, k53) + + return updated_cells + + + def _dec(self, value): + """Convert value to Decimal or return 0.""" + if value is None or value == '': + return Decimal('0') + try: + return Decimal(str(value)) + except Exception: + return Decimal('0') + + def post(self, request, *args, **kwargs): + """ + Handle AJAX saves from monthly_sheet.html + - Single-cell save: when cell_id is present (blur on one cell) + - Bulk save: when the 'Save All Cells' button is used (no cell_id) + """ + try: + sheet_id = request.POST.get('sheet_id') + if not sheet_id: + return JsonResponse({ + 'status': 'error', + 'message': 'Missing sheet_id' + }) + + sheet = MonthlySheet.objects.get(id=sheet_id) + + # -------- Single-cell update (blur) -------- + cell_id = request.POST.get('cell_id') + if cell_id: + value_raw = (request.POST.get('value') or '').strip() + + try: + cell = Cell.objects.get(id=cell_id, sheet=sheet) + except Cell.DoesNotExist: + return JsonResponse({ + 'status': 'error', + 'message': 'Cell not found' + }) + + # Convert value to Decimal or None + if value_raw == '': + new_value = None + else: + try: + # Allow comma or dot + value_clean = value_raw.replace(',', '.') + new_value = Decimal(value_clean) + except (InvalidOperation, ValueError): + # If conversion fails, treat as empty + new_value = None + + old_value = cell.value + cell.value = new_value + cell.save() + + updated_cells = [{ + 'id': cell.id, + 'value': '' if cell.value is None else str(cell.value), + 'is_calculated': cell.is_formula, # model field + }] + + # Recalculate dependents depending on table_type + if cell.table_type == 'top_left': + updated_cells.extend( + self.calculate_top_left_dependents(sheet, cell) + ) + elif cell.table_type == 'top_right': + updated_cells.extend( + self.calculate_top_right_dependents(sheet, cell) + ) + elif cell.table_type == 'bottom_1': + updated_cells.extend(self.calculate_bottom_1_dependents(sheet, cell)) + elif cell.table_type == 'bottom_3': + updated_cells.extend( + self.calculate_bottom_3_dependents(sheet) + ) + # bottom_1 / bottom_2 / bottom_3 currently have no formulas: + # they just save the new value. + updated_cells += self.calculate_bottom_3_dependents(sheet) + return JsonResponse({ + 'status': 'success', + 'updated_cells': updated_cells + }) + + + # -------- Bulk save (Save All button) -------- + return self.save_bulk_cells(request, sheet) + + except MonthlySheet.DoesNotExist: + return JsonResponse({ + 'status': 'error', + 'message': 'Sheet not found' + }) + except Exception as e: + # Generic safety net so the frontend sees an error message + return JsonResponse({ + 'status': 'error', + 'message': str(e) + }) + + # ... your other methods above ... + + # Update the calculate_top_right_dependents method in SaveCellsView class + + def calculate_top_right_dependents(self, sheet, changed_cell): + """ + Recalculate all dependent cells in the top-right table according to Excel formulas. + + Excel rows (4-20) -> 0-based indices (0-15) + Rows: + 0: Stand der Gaszähler (Vormonat) (Nm³) - shares for M3 clients + 1: Gasrückführung (Nm³) + 2: Rückführung flüssig (Lit. L-He) + 3: Sonderrückführungen (Lit. L-He) - editable + 4: Sammelrückführungen (Lit. L-He) - from helium_input groups + 5: Bestand in Kannen-1 (Lit. L-He) - editable, merged in pairs + 6: Summe Bestand (Lit. L-He) = row 5 + 7: Best. in Kannen Vormonat (Lit. L-He) - from previous month + 8: Bezug (Liter L-He) - from SecondTableEntry + 9: Rückführ. Soll (Lit. L-He) - calculated + 10: Verluste (Soll-Rückf.) (Lit. L-He) - calculated + 11: Füllungen warm (Lit. L-He) - from SecondTableEntry warm outputs + 12: Kaltgas Rückgabe (Lit. L-He) – Faktor - calculated + 13: Faktor 0.06 - fixed + 14: Verbraucherverluste (Liter L-He) - calculated + 15: % - calculated + """ + from decimal import Decimal + from django.db.models import Sum, Count + from django.db.models.functions import Coalesce + from .models import Client, Cell, ExcelEntry, SecondTableEntry + + TOP_RIGHT_CLIENTS = [ + "Dr. Fohrer", # L + "AG Buntk.", # M (merged with L) + "AG Alff", # N + "AG Gutfl.", # O (merged with N) + "M3 Thiele", # P + "M3 Buntkowsky", # Q + "M3 Gutfleisch", # R + ] + + # Define merged pairs + MERGED_PAIRS = [ + ("Dr. Fohrer", "AG Buntk."), # L and M are merged + ("AG Alff", "AG Gutfl."), # N and O are merged + ] + + # M3 clients (not merged, calculated individually) + M3_CLIENTS = ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"] + + # Groups for Sammelrückführungen (helium_input) + HELIUM_INPUT_GROUPS = { + "fohrer_buntk": ["Dr. Fohrer", "AG Buntk."], + "alff_gutfl": ["AG Alff", "AG Gutfl."], + "m3": ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"], + } + + year = sheet.year + month = sheet.month + factor = Decimal('0.06') # Fixed factor from Excel + updated_cells = [] + + # Helper functions + def get_val(client_name, row_idx): + """Get cell value for a client and row""" + try: + client = Client.objects.get(name=client_name) + col_idx = TOP_RIGHT_CLIENTS.index(client_name) + cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=client, + row_index=row_idx, + column_index=col_idx + ).first() + if cell and cell.value is not None: + return Decimal(str(cell.value)) + except (Client.DoesNotExist, ValueError, KeyError): + pass + return Decimal('0') + + def set_val(client_name, row_idx, value, is_calculated=True): + """Set cell value for a client and row""" + try: + client = Client.objects.get(name=client_name) + col_idx = TOP_RIGHT_CLIENTS.index(client_name) + + cell, created = Cell.objects.get_or_create( + sheet=sheet, + table_type='top_right', + client=client, + row_index=row_idx, + column_index=col_idx, + defaults={'value': value} + ) + + # Only update if value changed + if not created and cell.value != value: + cell.value = value + cell.save() + elif created: + cell.save() + + updated_cells.append({ + 'id': cell.id, + 'value': str(cell.value) if cell.value else '', + 'is_calculated': is_calculated + }) + + return True + except (Client.DoesNotExist, ValueError): + return False + + # 1. Update Summe Bestand (row 6) from Bestand in Kannen-1 (row 5) + # For merged pairs: copy value from changed cell to its pair + if changed_cell and changed_cell.table_type == 'top_right': + if changed_cell.row_index == 5: # Bestand in Kannen-1 + client_name = changed_cell.client.name + new_value = changed_cell.value + + # Check if this client is in a merged pair + for pair in MERGED_PAIRS: + if client_name in pair: + # Update both clients in the pair + for client_in_pair in pair: + if client_in_pair != client_name: + set_val(client_in_pair, 5, new_value, is_calculated=False) + break + + # 2. For all clients: Set Summe Bestand (row 6) = Bestand in Kannen-1 (row 5) + for client_name in TOP_RIGHT_CLIENTS: + bestand_value = get_val(client_name, 5) + set_val(client_name, 6, bestand_value, is_calculated=True) + + # 3. Update Sammelrückführungen (row 4) from helium_input groups + for group_name, client_names in HELIUM_INPUT_GROUPS.items(): + # Get total helium_input for this group + clients_in_group = Client.objects.filter(name__in=client_names) + total_helium = ExcelEntry.objects.filter( + client__in=clients_in_group, + date__year=year, + date__month=month + ).aggregate(total=Coalesce(Sum('lhe_ges'), Decimal('0')))['total'] + + # Set same value for all clients in group + for client_name in client_names: + set_val(client_name, 4, total_helium, is_calculated=True) + + # 4. Calculate Rückführung flüssig (row 2) + # For merged pairs: =L8 for Dr. Fohrer/AG Buntk., =N8 for AG Alff/AG Gutfl. + # For M3 clients: =$P$8 * P4, $P$8 * Q4, $P$8 * R4 + + # Get Sammelrückführungen values for groups + sammel_fohrer_buntk = get_val("Dr. Fohrer", 4) # L8 + sammel_alff_gutfl = get_val("AG Alff", 4) # N8 + sammel_m3_group = get_val("M3 Thiele", 4) # P8 (same for all M3) + + # For merged pairs + set_val("Dr. Fohrer", 2, sammel_fohrer_buntk, is_calculated=True) + set_val("AG Buntk.", 2, sammel_fohrer_buntk, is_calculated=True) + set_val("AG Alff", 2, sammel_alff_gutfl, is_calculated=True) + set_val("AG Gutfl.", 2, sammel_alff_gutfl, is_calculated=True) + + # For M3 clients: =$P$8 * column4 (Stand der Gaszähler) + for m3_client in M3_CLIENTS: + stand_value = get_val(m3_client, 0) # Stand der Gaszähler (row 0) + rueck_value = sammel_m3_group * stand_value + set_val(m3_client, 2, rueck_value, is_calculated=True) + + # 5. Calculate Füllungen warm (row 11) from SecondTableEntry warm outputs + # 5. Calculate Füllungen warm (row 11) as NUMBER of warm fillings + # (sum of 1s where each warm SecondTableEntry is one filling) + for client_name in TOP_RIGHT_CLIENTS: + client = Client.objects.get(name=client_name) + warm_count = SecondTableEntry.objects.filter( + client=client, + date__year=year, + date__month=month, + is_warm=True + ).aggregate( + total=Coalesce(Count('id'), 0) + )['total'] + + # store as Decimal so later formulas (warm * 15) still work nicely + warm_value = Decimal(warm_count) + set_val(client_name, 11, warm_value, is_calculated=True) + # 6. Set Faktor row (13) to 0.06 + for client_name in TOP_RIGHT_CLIENTS: + set_val(client_name, 13, factor, is_calculated=True) + + # 6a. Recalculate Stand der Gaszähler (row 0) for the merged pairs + # according to Excel: + # L4 = L13 / (L13 + M13), M4 = M13 / (L13 + M13) + # N4 = N13 / (N13 + O13), O4 = O13 / (N13 + O13) + + # Pair 1: Dr. Fohrer / AG Buntk. + bezug_dr = get_val("Dr. Fohrer", 8) # L13 + bezug_buntk = get_val("AG Buntk.", 8) # M13 + total_pair1 = bezug_dr + bezug_buntk + + if total_pair1 != 0: + set_val("Dr. Fohrer", 0, bezug_dr / total_pair1, is_calculated=True) + set_val("AG Buntk.", 0, bezug_buntk / total_pair1, is_calculated=True) + else: + # if no Bezug, both shares are 0 + set_val("Dr. Fohrer", 0, Decimal('0'), is_calculated=True) + set_val("AG Buntk.", 0, Decimal('0'), is_calculated=True) + + # Pair 2: AG Alff / AG Gutfl. + bezug_alff = get_val("AG Alff", 8) # N13 + bezug_gutfl = get_val("AG Gutfl.", 8) # O13 + total_pair2 = bezug_alff + bezug_gutfl + + if total_pair2 != 0: + set_val("AG Alff", 0, bezug_alff / total_pair2, is_calculated=True) + set_val("AG Gutfl.", 0, bezug_gutfl / total_pair2, is_calculated=True) + else: + set_val("AG Alff", 0, Decimal('0'), is_calculated=True) + set_val("AG Gutfl.", 0, Decimal('0'), is_calculated=True) + + + # 7. Calculate all other dependent rows for merged pairs + for pair in MERGED_PAIRS: + client1, client2 = pair + + # Get values for the pair + bezug1 = get_val(client1, 8) # Bezug client1 + bezug2 = get_val(client2, 8) # Bezug client2 + total_bezug = bezug1 + bezug2 # L13+M13 or N13+O13 + + summe_bestand = get_val(client1, 6) # L11 or N11 (merged, same value) + best_vormonat = get_val(client1, 7) # L12 or N12 (merged, same value) + rueck_fl = get_val(client1, 2) # L6 or N6 (merged, same value) + warm1 = get_val(client1, 11) # L16 or N16 + warm2 = get_val(client2, 11) # M16 or O16 + total_warm = warm1 + warm2 # L16+M16 or N16+O16 + + # Calculate Rückführ. Soll (row 9) + # = L13+M13 - L11 + L12 for first pair + # = N13+O13 - N11 + N12 for second pair + rueck_soll = total_bezug - summe_bestand + best_vormonat + set_val(client1, 9, rueck_soll, is_calculated=True) + set_val(client2, 9, rueck_soll, is_calculated=True) + + # Calculate Verluste (row 10) = Rückführ. Soll - Rückführung flüssig + verluste = rueck_soll - rueck_fl + set_val(client1, 10, verluste, is_calculated=True) + set_val(client2, 10, verluste, is_calculated=True) + + # Calculate Kaltgas Rückgabe (row 12) + # = (L13+M13)*$A18 + (L16+M16)*15 + kaltgas = (total_bezug * factor) + (total_warm * Decimal('15')) + set_val(client1, 12, kaltgas, is_calculated=True) + set_val(client2, 12, kaltgas, is_calculated=True) + + # Calculate Verbraucherverluste (row 14) = Verluste - Kaltgas + verbrauch = verluste - kaltgas + set_val(client1, 14, verbrauch, is_calculated=True) + set_val(client2, 14, verbrauch, is_calculated=True) + + # Calculate % (row 15) = Verbraucherverluste / (L13+M13) + if total_bezug != 0: + prozent = verbrauch / total_bezug + else: + prozent = Decimal('0') + set_val(client1, 15, prozent, is_calculated=True) + set_val(client2, 15, prozent, is_calculated=True) + + # 8. Calculate all dependent rows for M3 clients (individual calculations) + for m3_client in M3_CLIENTS: + # Get individual values + bezug = get_val(m3_client, 8) # Bezug for this M3 client + summe_bestand = get_val(m3_client, 6) # Summe Bestand + best_vormonat = get_val(m3_client, 7) # Best. in Kannen Vormonat + rueck_fl = get_val(m3_client, 2) # Rückführung flüssig + warm = get_val(m3_client, 11) # Füllungen warm + + # Calculate Rückführ. Soll (row 9) = Bezug - Summe Bestand + Best. Vormonat + rueck_soll = bezug - summe_bestand + best_vormonat + set_val(m3_client, 9, rueck_soll, is_calculated=True) + + # Calculate Verluste (row 10) = Rückführ. Soll - Rückführung flüssig + verluste = rueck_soll - rueck_fl + set_val(m3_client, 10, verluste, is_calculated=True) + + # Calculate Kaltgas Rückgabe (row 12) = Bezug * factor + warm * 15 + kaltgas = (bezug * factor) + (warm * Decimal('15')) + set_val(m3_client, 12, kaltgas, is_calculated=True) + + # Calculate Verbraucherverluste (row 14) = Verluste - Kaltgas + verbrauch = verluste - kaltgas + set_val(m3_client, 14, verbrauch, is_calculated=True) + + # Calculate % (row 15) = Verbraucherverluste / Bezug + if bezug != 0: + prozent = verbrauch / bezug + else: + prozent = Decimal('0') + set_val(m3_client, 15, prozent, is_calculated=True) + + return updated_cells + + + + def calculate_bottom_1_dependents(self, sheet, changed_cell): + """ + Recalculate Bottom Table 1 (table_type='bottom_1'). + + Layout (row_index 0–9, col_index 0–4): + + Rows 0–8: + 0: Batterie 1 + 1: 2 + 2: 3 + 3: 4 + 4: 5 + 5: 6 + 6: 2 Bündel + 7: 2 Ballone + 8: Reingasspeicher + + Row 9: + 9: Gasbestand (totals row) + + Columns: + 0: Volumen (fixed values, not editable) + 1: bar (editable for rows 0–8) + 2: korrigiert = bar / (bar/2000 + 1) + 3: Nm³ = Volumen * korrigiert + 4: Lit. LHe = Nm³ / 0.75 + + Row 9: + - Volumen (col 0): empty + - bar (col 1): empty + - korrigiert (col 2): empty + - Nm³ (col 3): SUM Nm³ rows 0–8 + - Lit. LHe (col 4): SUM Lit. LHe rows 0–8 + """ + from decimal import Decimal, InvalidOperation + from .models import Cell + + updated_cells = [] + + DATA_ROWS = list(range(0, 9)) # 0–8 + TOTAL_ROW = 9 + + COL_VOL = 0 + COL_BAR = 1 + COL_KORR = 2 + COL_NM3 = 3 + COL_LHE = 4 + + # Fixed Volumen values for rows 0–8 + VOLUMES = [ + Decimal("2.4"), # row 0 + Decimal("5.1"), # row 1 + Decimal("4.0"), # row 2 + Decimal("1.0"), # row 3 + Decimal("4.0"), # row 4 + Decimal("0.4"), # row 5 + Decimal("1.2"), # row 6 + Decimal("20.0"), # row 7 + Decimal("5.0"), # row 8 + ] + + def get_cell(row_idx, col_idx): + return Cell.objects.filter( + sheet=sheet, + table_type="bottom_1", + row_index=row_idx, + column_index=col_idx, + ).first() + + def set_calc(row_idx, col_idx, value): + """ + Set a calculated cell and add it to updated_cells. + If value is None, we clear the cell. + """ + cell = get_cell(row_idx, col_idx) + if not cell: + cell = Cell( + sheet=sheet, + table_type="bottom_1", + row_index=row_idx, + column_index=col_idx, + value=value, + ) + else: + cell.value = value + cell.save() + + updated_cells.append( + { + "id": cell.id, + "value": "" if cell.value is None else str(cell.value), + "is_calculated": True, + } + ) + + # ---------- Rows 0–8: per-gasspeicher calculations ---------- + for row_idx in DATA_ROWS: + bar_cell = get_cell(row_idx, COL_BAR) + + # Volumen: fixed constant or, if present, value from DB + vol = VOLUMES[row_idx] + vol_cell = get_cell(row_idx, COL_VOL) + if vol_cell and vol_cell.value is not None: + try: + vol = Decimal(str(vol_cell.value)) + except (InvalidOperation, ValueError): + # fall back to fixed constant + vol = VOLUMES[row_idx] + + bar = None + try: + if bar_cell and bar_cell.value is not None: + bar = Decimal(str(bar_cell.value)) + except (InvalidOperation, ValueError): + bar = None + + # korrigiert = bar / (bar/2000 + 1) + if bar is not None and bar != 0: + korr = bar / (bar / Decimal("2000") + Decimal("1")) + else: + korr = None + + # Nm³ = Volumen * korrigiert + if korr is not None: + nm3 = vol * korr + else: + nm3 = None + + # Lit. LHe = Nm³ / 0.75 + if nm3 is not None: + lit_lhe = nm3 / Decimal("0.75") + else: + lit_lhe = None + + # Write calculated cells back (NOT Volumen or bar) + set_calc(row_idx, COL_KORR, korr) + set_calc(row_idx, COL_NM3, nm3) + set_calc(row_idx, COL_LHE, lit_lhe) + + # ---------- Row 9: totals (Gasbestand) ---------- + total_nm3 = Decimal("0") + total_lhe = Decimal("0") + has_nm3 = False + has_lhe = False + + for row_idx in DATA_ROWS: + nm3_cell = get_cell(row_idx, COL_NM3) + if nm3_cell and nm3_cell.value is not None: + try: + total_nm3 += Decimal(str(nm3_cell.value)) + has_nm3 = True + except (InvalidOperation, ValueError): + pass + + lhe_cell = get_cell(row_idx, COL_LHE) + if lhe_cell and lhe_cell.value is not None: + try: + total_lhe += Decimal(str(lhe_cell.value)) + has_lhe = True + except (InvalidOperation, ValueError): + pass + + # Volumen (0), bar (1), korrigiert (2) on total row stay empty + set_calc(TOTAL_ROW, COL_KORR, None) # explicitly clear korrigiert + set_calc(TOTAL_ROW, COL_NM3, total_nm3 if has_nm3 else None) + set_calc(TOTAL_ROW, COL_LHE, total_lhe if has_lhe else None) + + return updated_cells + + + + + + def calculate_top_left_dependents(self, sheet, changed_cell): + """Calculate dependent cells in top_left table""" + from decimal import Decimal + from django.db.models import Sum + from django.db.models.functions import Coalesce + + client_id = changed_cell.client_id + updated_cells = [] + + # Get all cells for this client in top_left table + client_cells = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client_id=client_id + ) + + # Create a dict for easy access + cell_dict = {} + for cell in client_cells: + cell_dict[cell.row_index] = cell + + # Get values with safe defaults + def get_cell_value(row_idx): + cell = cell_dict.get(row_idx) + if cell and cell.value is not None: + try: + return Decimal(str(cell.value)) + except: + return Decimal('0') + return Decimal('0') + + # 1. B5 = IF(B3>0; B3-B4; 0) - Gasrückführung + b3 = get_cell_value(0) # row_index 0 = Excel B3 (UI row 1) + b4 = get_cell_value(1) # row_index 1 = Excel B4 (UI row 2) + + # Calculate B5 + if b3 > 0: + b5_value = b3 - b4 + if b5_value < 0: + b5_value = Decimal('0') + else: + b5_value = Decimal('0') + + # Update B5 - FORCE update even if value appears the same + b5_cell = cell_dict.get(2) # row_index 2 = Excel B5 (UI row 3) + if b5_cell: + # Always update B5 when B3 or B4 changes + b5_cell.value = b5_value + b5_cell.save() + updated_cells.append({ + 'id': b5_cell.id, + 'value': str(b5_cell.value), + 'is_calculated': True + }) + + # 2. B6 = B5 / 0.75 - Rückführung flüssig + b6_value = b5_value / Decimal('0.75') + b6_cell = cell_dict.get(3) # row_index 3 = Excel B6 (UI row 4) + if b6_cell: + b6_cell.value = b6_value + b6_cell.save() + updated_cells.append({ + 'id': b6_cell.id, + 'value': str(b6_cell.value), + 'is_calculated': True + }) + + # 3. B9 = B7 + B8 - Summe Bestand + b7 = get_cell_value(4) # row_index 4 = Excel B7 (UI row 5) + b8 = get_cell_value(5) # row_index 5 = Excel B8 (UI row 6) + b9_value = b7 + b8 + b9_cell = cell_dict.get(6) # row_index 6 = Excel B9 (UI row 7) + if b9_cell: + b9_cell.value = b9_value + b9_cell.save() + updated_cells.append({ + 'id': b9_cell.id, + 'value': str(b9_cell.value), + 'is_calculated': True + }) + + # 4. B11 = Sum of LHe Output from SecondTableEntry for this client/month - Bezug + from .models import SecondTableEntry + client = changed_cell.client + + # Calculate total LHe output for this client in this month + # 4. B11 = Bezug (Liter L-He) + # For start sheet: manual entry, for other sheets: auto-calculated from SecondTableEntry + from .models import SecondTableEntry + client = changed_cell.client + + b11_cell = cell_dict.get(8) # row_index 8 = Excel B11 (UI row 9) + + # Check if this is the start sheet (2025-01) + is_start_sheet = (sheet.year == 2025 and sheet.month == 1) + + # Get the B11 value for calculations + b11_value = Decimal('0') + + if is_start_sheet: + # For start sheet, keep whatever value is already there (manual entry) + if b11_cell and b11_cell.value is not None: + b11_value = Decimal(str(b11_cell.value)) + else: + # For non-start sheets: auto-calculate from SecondTableEntry + lhe_output_sum = SecondTableEntry.objects.filter( + client=client, + date__year=sheet.year, + date__month=sheet.month + ).aggregate( + total=Coalesce(Sum('lhe_output'), Decimal('0')) + )['total'] + + b11_value = lhe_output_sum + + if b11_cell and (b11_cell.value != b11_value or b11_cell.value is None): + b11_cell.value = b11_value + b11_cell.save() + updated_cells.append({ + 'id': b11_cell.id, + 'value': str(b11_cell.value), + 'is_calculated': True # Calculated from SecondTableEntry + }) + + # 5. B12 = B11 + B10 - B9 - Rückführ. Soll + b10 = get_cell_value(7) # row_index 7 = Excel B10 (UI row 8) + b12_value = b11_value + b10 - b9_value # Use b11_value instead of lhe_output_sum + b12_cell = cell_dict.get(9) # row_index 9 = Excel B12 (UI row 10) + if b12_cell: + b12_cell.value = b12_value + b12_cell.save() + updated_cells.append({ + 'id': b12_cell.id, + 'value': str(b12_cell.value), + 'is_calculated': True + }) + + # 6. B13 = B12 - B6 - Verluste (Soll-Rückf.) + b13_value = b12_value - b6_value + b13_cell = cell_dict.get(10) # row_index 10 = Excel B13 (UI row 11) + if b13_cell: + b13_cell.value = b13_value + b13_cell.save() + updated_cells.append({ + 'id': b13_cell.id, + 'value': str(b13_cell.value), + 'is_calculated': True + }) + + # 7. B14 = Count of warm outputs + warm_count = SecondTableEntry.objects.filter( + client=client, + date__year=sheet.year, + date__month=sheet.month, + is_warm=True + ).count() + + b14_cell = cell_dict.get(11) # row_index 11 = Excel B14 (UI row 12) + if b14_cell: + b14_cell.value = Decimal(str(warm_count)) + b14_cell.save() + updated_cells.append({ + 'id': b14_cell.id, + 'value': str(b14_cell.value), + 'is_calculated': True + }) + + # 8. B15 = IF(B11>0; B11 * factor + B14 * 15; 0) - Kaltgas Rückgabe + factor = get_cell_value(13) # row_index 13 = Excel B16 (Faktor) (UI row 14) + if factor == 0: + factor = Decimal('0.06') # default factor + + if b11_value > 0: # Use b11_value + b15_value = b11_value * factor + Decimal(str(warm_count)) * Decimal('15') + else: + b15_value = Decimal('0') + + b15_cell = cell_dict.get(12) # row_index 12 = Excel B15 (UI row 13) + if b15_cell: + b15_cell.value = b15_value + b15_cell.save() + updated_cells.append({ + 'id': b15_cell.id, + 'value': str(b15_cell.value), + 'is_calculated': True + }) + + # 9. B17 = B13 - B15 - Verbraucherverluste + b17_value = b13_value - b15_value + b17_cell = cell_dict.get(14) # row_index 14 = Excel B17 (UI row 15) + if b17_cell: + b17_cell.value = b17_value + b17_cell.save() + updated_cells.append({ + 'id': b17_cell.id, + 'value': str(b17_cell.value), + 'is_calculated': True + }) + + # 10. B18 = IF(B11=0; 0; B17/B11) - % + if b11_value == 0: # Use b11_value + b18_value = Decimal('0') + else: + b18_value = b17_value / b11_value # Use b11_value + + b18_cell = cell_dict.get(15) # row_index 15 = Excel B18 (UI row 16) + if b18_cell: + b18_cell.value = b18_value + b18_cell.save() + updated_cells.append({ + 'id': b18_cell.id, + 'value': str(b18_cell.value), + 'is_calculated': True + }) + + return updated_cells + def save_bulk_cells(self, request, sheet): + """Original bulk save logic (for backward compatibility)""" + # Get all cell updates + cell_updates = {} + for key, value in request.POST.items(): + if key.startswith('cell_'): + cell_id = key.replace('cell_', '') + cell_updates[cell_id] = value + + # Update cells and track which ones changed + updated_cells = [] + changed_clients = set() + + for cell_id, new_value in cell_updates.items(): + try: + cell = Cell.objects.get(id=cell_id, sheet=sheet) + old_value = cell.value + + # Convert new value + try: + if new_value.strip(): + cell.value = Decimal(new_value) + else: + cell.value = None + except Exception: + cell.value = None + + # Only save if value changed + if cell.value != old_value: + cell.save() + updated_cells.append({ + 'id': cell.id, + 'value': str(cell.value) if cell.value else '' + }) + # bottom_3 has no client, so this will just add None for those cells, + # which is harmless. Top-left cells still add their real client_id. + changed_clients.add(cell.client_id) + + except Cell.DoesNotExist: + continue + + # Recalculate for each changed client (top-left tables) + for client_id in changed_clients: + if client_id is not None: + self.recalculate_top_left_table(sheet, client_id) + + # --- NEW: recalc bottom_3 for the whole sheet, independent of clients --- + bottom3_updates = self.calculate_bottom_3_dependents(sheet) + + # Get all updated cells for response (top-left) + all_updated_cells = [] + for client_id in changed_clients: + if client_id is None: + continue # skip bottom_3 / non-client cells + client_cells = Cell.objects.filter( + sheet=sheet, + client_id=client_id + ) + for cell in client_cells: + all_updated_cells.append({ + 'id': cell.id, + 'value': str(cell.value) if cell.value else '', + 'is_calculated': cell.is_formula + }) + + # Add bottom_3 recalculated cells (J46..K53, etc.) + all_updated_cells.extend(bottom3_updates) + + return JsonResponse({ + 'status': 'success', + 'message': f'Saved {len(updated_cells)} cells', + 'updated_cells': all_updated_cells + }) + + + def recalculate_top_left_table(self, sheet, client_id): + """Recalculate the top-left table for a specific client""" + from decimal import Decimal + + # Get all cells for this client in top_left table + cells = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client_id=client_id + ).order_by('row_index') + + # Create a dictionary of cell values + cell_dict = {} + for cell in cells: + cell_dict[cell.row_index] = cell + + # Excel logic implementation for top-left table + # B3 (row_index 0) = Stand der Gaszähler (Nm³) - manual + # B4 (row_index 1) = Stand der Gaszähler (Vormonat) (Nm³) - from previous sheet + + # Get B3 and B4 + b3_cell = cell_dict.get(0) # UI Row 3 + b4_cell = cell_dict.get(1) # UI Row 4 + + if b3_cell and b3_cell.value and b4_cell and b4_cell.value: + # B5 = IF(B3>0; B3-B4; 0) + b3 = Decimal(str(b3_cell.value)) + b4 = Decimal(str(b4_cell.value)) + + if b3 > 0: + b5 = b3 - b4 + if b5 < 0: + b5 = Decimal('0') + else: + b5 = Decimal('0') + + # Update B5 (row_index 2) + b5_cell = cell_dict.get(2) + if b5_cell and (b5_cell.value != b5 or b5_cell.value is None): + b5_cell.value = b5 + b5_cell.save() + + # B6 = B5 / 0.75 (row_index 3) + b6 = b5 / Decimal('0.75') + b6_cell = cell_dict.get(3) + if b6_cell and (b6_cell.value != b6 or b6_cell.value is None): + b6_cell.value = b6 + b6_cell.save() + + # Get previous month's sheet for B10 + if sheet.month == 1: + prev_year = sheet.year - 1 + prev_month = 12 + else: + prev_year = sheet.year + prev_month = sheet.month - 1 + + prev_sheet = MonthlySheet.objects.filter( + year=prev_year, + month=prev_month + ).first() + + if prev_sheet: + # Get B9 from previous sheet (row_index 7 in previous) + prev_b9 = Cell.objects.filter( + sheet=prev_sheet, + table_type='top_left', + client_id=client_id, + row_index=7 # UI Row 9 + ).first() + + if prev_b9 and prev_b9.value: + # Update B10 in current sheet (row_index 8) + b10_cell = cell_dict.get(8) + if b10_cell and (b10_cell.value != prev_b9.value or b10_cell.value is None): + b10_cell.value = prev_b9.value + b10_cell.save() +@method_decorator(csrf_exempt, name='dispatch') +class SaveMonthSummaryView(View): + """ + Saves per-month summary values such as K44 (Gesamtbestand neu). + Called from JS after 'Save All' finishes. + """ + + def post(self, request, *args, **kwargs): + try: + data = json.loads(request.body.decode('utf-8')) + except json.JSONDecodeError: + return JsonResponse( + {'success': False, 'message': 'Invalid JSON'}, + status=400 + ) + + sheet_id = data.get('sheet_id') + if not sheet_id: + return JsonResponse( + {'success': False, 'message': 'Missing sheet_id'}, + status=400 + ) + + try: + sheet = MonthlySheet.objects.get(id=sheet_id) + except MonthlySheet.DoesNotExist: + return JsonResponse( + {'success': False, 'message': 'Sheet not found'}, + status=404 + ) + + # More tolerant decimal conversion: accepts "123.45" and "123,45" + def to_decimal(value): + if value is None: + return None + s = str(value).strip() + if s == '': + return None + s = s.replace(',', '.') + try: + return Decimal(s) + except (InvalidOperation, ValueError): + # Debug: show what failed in the dev server console + print("SaveMonthSummaryView to_decimal failed for:", repr(value)) + return None + + raw_k44 = data.get('gesamtbestand_neu_lhe') + raw_gas = data.get('gasbestand_lhe') + raw_verb = data.get('verbraucherverlust_lhe') + + gesamtbestand_neu_lhe = to_decimal(raw_k44) + gasbestand_lhe = to_decimal(raw_gas) + verbraucherverlust_lhe = to_decimal(raw_verb) + + summary, created = MonthlySummary.objects.get_or_create(sheet=sheet) + + if gesamtbestand_neu_lhe is not None: + summary.gesamtbestand_neu_lhe = gesamtbestand_neu_lhe + if gasbestand_lhe is not None: + summary.gasbestand_lhe = gasbestand_lhe + if verbraucherverlust_lhe is not None: + summary.verbraucherverlust_lhe = verbraucherverlust_lhe + + summary.save() + + # Small debug output so you can see in the server console what was saved + print( + f"Saved MonthlySummary for {sheet.year}-{sheet.month:02d}: " + f"K44={summary.gesamtbestand_neu_lhe}, " + f"Gasbestand={summary.gasbestand_lhe}, " + f"Verbraucherverlust={summary.verbraucherverlust_lhe}" + ) + + return JsonResponse({'success': True}) + + + +# Calculate View (placeholder for calculations) +class CalculateView(View): + def post(self, request): + # This will be implemented when you provide calculation rules + return JsonResponse({ + 'status': 'success', + 'message': 'Calculation endpoint ready' + }) + +# Summary Sheet View +class SummarySheetView(TemplateView): + template_name = 'summary_sheet.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + start_month = int(self.kwargs.get('start_month', 1)) + year = int(self.kwargs.get('year', datetime.now().year)) + + # Get 6 monthly sheets + months = [(year, m) for m in range(start_month, start_month + 6)] + sheets = MonthlySheet.objects.filter( + year=year, + month__in=list(range(start_month, start_month + 6)) + ).order_by('month') + + # Aggregate data across months + summary_data = self.calculate_summary(sheets) + + context.update({ + 'year': year, + 'start_month': start_month, + 'end_month': start_month + 5, + 'sheets': sheets, + 'clients': Client.objects.all(), + 'summary_data': summary_data, + }) + return context + + def calculate_summary(self, sheets): + """Calculate totals across 6 months""" + summary = {} + + for client in Client.objects.all(): + client_total = 0 + for sheet in sheets: + # Get specific cells and sum + cells = sheet.cells.filter( + client=client, + table_type='top_left', + row_index=0 # Example: first row + ) + for cell in cells: + if cell.value: + try: + client_total += float(cell.value) + except (ValueError, TypeError): + continue + + summary[client.id] = client_total + + return summary + +# Existing views below (keep all your existing functions) +def clients_list(request): + # --- Clients for the yearly summary table --- + clients = Client.objects.all() + + # --- Available years for output data (same as before) --- + available_years_qs = SecondTableEntry.objects.dates('date', 'year', order='DESC') + available_years = [y.year for y in available_years_qs] + + # === 1) Year used for the "Helium Output Yearly Summary" table === + # Uses ?year=... in the dropdown + year_param = request.GET.get('year') + if year_param: + selected_year = int(year_param) + else: + selected_year = available_years[0] if available_years else datetime.now().year + + # === 2) GLOBAL half-year interval (shared with other pages) ======= + # Try GET params first + interval_year_param = request.GET.get('interval_year') + start_month_param = request.GET.get('interval_start_month') + + # Fallbacks from session + session_year = request.session.get('halfyear_year') + session_start_month = request.session.get('halfyear_start_month') + + # Determine final interval_year + if interval_year_param: + interval_year = int(interval_year_param) + elif session_year: + interval_year = int(session_year) + else: + # default: same as selected_year for summary + interval_year = selected_year + + # Determine final interval_start_month + if start_month_param: + interval_start_month = int(start_month_param) + elif session_start_month: + interval_start_month = int(session_start_month) + else: + interval_start_month = 1 # default Jan + + # Store back into the session so other views can read them + request.session['halfyear_year'] = interval_year + request.session['halfyear_start_month'] = interval_start_month + + # === 3) Build a 6-month window, allowing wrap into the next year === + # Example: interval_year=2025, interval_start_month=12 + # window = [(2025,12), (2026,1), (2026,2), (2026,3), (2026,4), (2026,5)] + window = [] + for offset in range(6): + total_index = (interval_start_month - 1) + offset # 0-based index + y = interval_year + (total_index // 12) + m = (total_index % 12) + 1 + window.append((y, m)) + + # === 4) Totals per client in that 6-month window ================== + monthly_data = [] + for client in clients: + monthly_totals = [] + for (y, m) in window: + total = SecondTableEntry.objects.filter( + client=client, + date__year=y, + date__month=m + ).aggregate( + total=Coalesce( + Sum('lhe_output'), + Value(0, output_field=DecimalField()) + ) + )['total'] + monthly_totals.append(total) + + monthly_data.append({ + 'client': client, + 'monthly_totals': monthly_totals, + 'year_total': sum(monthly_totals), + }) + + # === 5) Month labels for the header (only the 6-month window) ===== + month_labels = [calendar.month_abbr[m] for (y, m) in window] + + # === 6) FINALLY: return the response ============================== + return render(request, 'clients_table.html', { + 'available_years': available_years, + 'current_year': selected_year, # used by year dropdown + 'interval_year': interval_year, # used by "Global 6-Month Interval" form + 'interval_start_month': interval_start_month, + 'months': month_labels, + 'monthly_data': monthly_data, + }) + + # === 5) Month labels for the header (only the 6-month window) ===== + month_labels = [calendar.month_abbr[m] for (y, m) in window] + + +def set_halfyear_interval(request): + if request.method == 'POST': + year = int(request.POST.get('year')) + start_month = int(request.POST.get('start_month')) + + request.session['halfyear_year'] = year + request.session['halfyear_start_month'] = start_month + + return redirect(request.META.get('HTTP_REFERER', 'clients_list')) + + return redirect('clients_list') +# Table One View (ExcelEntry) +def table_one_view(request): + from .models import ExcelEntry, Client, Institute + + # --- Base queryset for the main Helium Input table --- + base_entries = ExcelEntry.objects.all().select_related('client', 'client__institute') + + # Read the global 6-month interval from the session + interval_year = request.session.get('halfyear_year') + interval_start = request.session.get('halfyear_start_month') + + if interval_year and interval_start: + interval_year = int(interval_year) + interval_start = int(interval_start) + + # Build the same 6-month window as on the main page (can cross year) + window = [] + for offset in range(6): + total_index = (interval_start - 1) + offset # 0-based + y = interval_year + (total_index // 12) + m = (total_index % 12) + 1 + window.append((y, m)) + + # Build Q filter: (year=m_year AND month=m_month) for any of those 6 + q = Q() + for (y, m) in window: + q |= Q(date__year=y, date__month=m) + + entries_table1 = base_entries.filter(q).order_by('-date') + else: + # Fallback: if no global interval yet, show everything + entries_table1 = base_entries.order_by('-date') + clients = Client.objects.all().select_related('institute') + institutes = Institute.objects.all() + + # ---- Overview filters ---- + # years present in ExcelEntry.date + year_qs = ExcelEntry.objects.dates('date', 'year', order='DESC') + available_years = [d.year for d in year_qs] + + # default year/start month + # default year/start month (if no global interval yet) + if available_years: + default_year = available_years[0] # newest year in ExcelEntry + else: + default_year = timezone.now().year + + # 🔸 Read global half-year interval from session (set on main page) + session_year = request.session.get('halfyear_year') + session_start = request.session.get('halfyear_start_month') + + # If the user has set a global interval, use it. + # Otherwise fall back to default year / January. + year = int(session_year) if session_year else int(default_year) + start_month = int(session_start) if session_start else 1 + + + # six-month window + # --- Build a 6-month window, allowing wrap into the next year --- + # Example: year=2025, start_month=10 + # window = [(2025,10), (2025,11), (2025,12), (2026,1), (2026,2), (2026,3)] + window = [] + for offset in range(6): + total_index = (start_month - 1) + offset # 0-based + y_for_month = year + (total_index // 12) + m_for_month = (total_index % 12) + 1 + window.append((y_for_month, m_for_month)) + + overview = None + + if window: + # Build per-group data + groups_entries = [] # for internal calculations + + for key, group in CLIENT_GROUPS.items(): + clients_qs = get_group_clients(key) + + values = [] + group_total = Decimal('0') + + for (y_m, m_m) in window: + total = ExcelEntry.objects.filter( + client__in=clients_qs, + date__year=y_m, + date__month=m_m + ).aggregate( + total=Coalesce(Sum('lhe_ges'), Decimal('0')) + )['total'] + + values.append(total) + group_total += total + + groups_entries.append({ + 'key': key, + 'label': group['label'], + 'values': values, + 'total': group_total, + }) + + # month totals across all groups + month_totals = [] + for idx in range(len(window)): + s = sum((g['values'][idx] for g in groups_entries), Decimal('0')) + month_totals.append(s) + + grand_total = sum(month_totals, Decimal('0')) + + # Build rows for the template + rows = [] + for idx, (y_m, m_m) in enumerate(window): + row_values = [g['values'][idx] for g in groups_entries] + rows.append({ + 'month_number': m_m, + 'month_label': calendar.month_name[m_m], + 'values': row_values, + 'total': month_totals[idx], + }) + + groups_meta = [{'key': g['key'], 'label': g['label']} for g in groups_entries] + group_totals = [g['total'] for g in groups_entries] + + # Start/end for display – include years so wrap is clear + start_year = window[0][0] + start_month_disp = window[0][1] + end_year = window[-1][0] + end_month_disp = window[-1][1] + + overview = { + 'year': year, # keep for backwards compatibility if needed + 'start_month': start_month_disp, + 'start_year': start_year, + 'end_month': end_month_disp, + 'end_year': end_year, + 'rows': rows, + 'groups': groups_meta, + 'group_totals': group_totals, + 'grand_total': grand_total, + } + + + # Month dropdown labels + MONTH_CHOICES = [ + (1, 'Jan'), (2, 'Feb'), (3, 'Mar'), (4, 'Apr'), + (5, 'May'), (6, 'Jun'), (7, 'Jul'), (8, 'Aug'), + (9, 'Sep'), (10, 'Oct'), (11, 'Nov'), (12, 'Dec'), + ] + + return render(request, 'table_one.html', { + 'entries_table1': entries_table1, + 'clients': clients, + 'institutes': institutes, + 'available_years': available_years, + 'month_choices': MONTH_CHOICES, + 'overview': overview, + }) + +# Table Two View (SecondTableEntry) +def table_two_view(request): + try: + clients = Client.objects.all().select_related('institute') + institutes = Institute.objects.all() + + # 🔸 Read global half-year interval from session + interval_year = request.session.get('halfyear_year') + interval_start = request.session.get('halfyear_start_month') + + if interval_year and interval_start: + interval_year = int(interval_year) + interval_start = int(interval_start) + + # Build the same 6-month window as in clients_list (can cross years) + window = [] + for offset in range(6): + total_index = (interval_start - 1) + offset # 0-based + y = interval_year + (total_index // 12) + m = (total_index % 12) + 1 + window.append((y, m)) + + # Build a Q object matching any of those (year, month) pairs + q = Q() + for (y, m) in window: + q |= Q(date__year=y, date__month=m) + + entries = SecondTableEntry.objects.filter(q).order_by('-date') + else: + # Fallback if no global interval yet: show all + entries = SecondTableEntry.objects.all().order_by('-date') + + return render(request, 'table_two.html', { + 'entries_table2': entries, + 'clients': clients, + 'institutes': institutes, + 'interval_year': interval_year, + 'interval_start_month': interval_start, + }) + + except Exception as e: + return render(request, 'table_two.html', { + 'error_message': f"Failed to load data: {str(e)}", + 'entries_table2': [], + 'clients': clients, + 'institutes': institutes, + }) + + +def monthly_sheet_root(request): + """ + Redirect /sheet/ to the sheet matching the globally selected + half-year start (year + month). If not set, fall back to latest. + """ + year = request.session.get('halfyear_year') + start_month = request.session.get('halfyear_start_month') + + if year and start_month: + try: + year = int(year) + start_month = int(start_month) + return redirect('monthly_sheet', year=year, month=start_month) + except ValueError: + pass # fall through + + # Fallback: latest MonthlySheet if exists + latest_sheet = MonthlySheet.objects.order_by('-year', '-month').first() + if latest_sheet: + return redirect('monthly_sheet', year=latest_sheet.year, month=latest_sheet.month) + else: + now = timezone.now() + return redirect('monthly_sheet', year=now.year, month=now.month) +# Add Entry (Generic) +def add_entry(request, model_name): + if request.method == 'POST': + try: + if model_name == 'SecondTableEntry': + model = SecondTableEntry + + # Parse date + date_str = request.POST.get('date') + try: + date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else None + except (ValueError, TypeError): + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid date format. Use YYYY-MM-DD' + }, status=400) + + # NEW: robust parse of warm flag (handles 0/1, true/false, on/off) + raw_warm = request.POST.get('is_warm') + is_warm_bool = str(raw_warm).lower() in ('1', 'true', 'on', 'yes') + + # Handle Helium Output (Table Two) + lhe_output = request.POST.get('lhe_output') + + entry = model.objects.create( + client=Client.objects.get(id=request.POST.get('client_id')), + date=date_obj, + is_warm=is_warm_bool, # <-- use the boolean here + lhe_delivery=request.POST.get('lhe_delivery', ''), + lhe_output=Decimal(lhe_output) if lhe_output else None, + notes=request.POST.get('notes', '') + ) + + return JsonResponse({ + 'status': 'success', + 'id': entry.id, + 'client_name': entry.client.name, + 'institute_name': entry.client.institute.name, + 'date': entry.date.strftime('%Y-%m-%d') if entry.date else '', + 'is_warm': entry.is_warm, + 'lhe_delivery': entry.lhe_delivery, + 'lhe_output': str(entry.lhe_output) if entry.lhe_output else '', + 'notes': entry.notes + }) + + elif model_name == 'ExcelEntry': + model = ExcelEntry + + # Parse the date string into a date object + date_str = request.POST.get('date') + try: + date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else None + except (ValueError, TypeError): + date_obj = None + + try: + pressure = Decimal(request.POST.get('pressure', 0)) + purity = Decimal(request.POST.get('purity', 0)) + druckkorrektur = Decimal(request.POST.get('druckkorrektur', 1)) + lhe_zus = Decimal(request.POST.get('lhe_zus', 0)) + constant_300 = Decimal(request.POST.get('constant_300', 300)) + korrig_druck = Decimal(request.POST.get('korrig_druck', 0)) + nm3 = Decimal(request.POST.get('nm3', 0)) + lhe = Decimal(request.POST.get('lhe', 0)) + lhe_ges = Decimal(request.POST.get('lhe_ges', 0)) + except InvalidOperation: + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid numeric value in Helium Input' + }, status=400) + + # Create the entry with ALL fields + entry = model.objects.create( + client=Client.objects.get(id=request.POST.get('client_id')), + date=date_obj, + pressure=pressure, + purity=purity, + druckkorrektur=druckkorrektur, + lhe_zus=lhe_zus, + constant_300=constant_300, + korrig_druck=korrig_druck, + nm3=nm3, + lhe=lhe, + lhe_ges=lhe_ges, + notes=request.POST.get('notes', '') + ) + + # Prepare the response + response_data = { + 'status': 'success', + 'id': entry.id, + 'client_name': entry.client.name, + 'institute_name': entry.client.institute.name, + 'pressure': str(entry.pressure), + 'purity': str(entry.purity), + 'druckkorrektur': str(entry.druckkorrektur), + 'constant_300': str(entry.constant_300), + 'korrig_druck': str(entry.korrig_druck), + 'nm3': str(entry.nm3), + 'lhe': str(entry.lhe), + 'lhe_zus': str(entry.lhe_zus), + 'lhe_ges': str(entry.lhe_ges), + 'notes': entry.notes, + } + + if entry.date: + # JS uses this for the Date column and for the Month column + response_data['date'] = entry.date.strftime('%Y-%m-%d') + response_data['month'] = f"{entry.date.month:02d}" + else: + response_data['date'] = '' + response_data['month'] = '' + + return JsonResponse(response_data) + + + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}, status=400) + + return JsonResponse({'status': 'error', 'message': 'Invalid request'}, status=400) + +# Update Entry (Generic) +def update_entry(request, model_name): + if request.method == 'POST': + try: + if model_name == 'SecondTableEntry': + model = SecondTableEntry + elif model_name == 'ExcelEntry': + model = ExcelEntry + else: + return JsonResponse({'status': 'error', 'message': 'Invalid model'}, status=400) + + entry_id = int(request.POST.get('id')) + entry = model.objects.get(id=entry_id) + + # Common updates for both models + entry.client = Client.objects.get(id=request.POST.get('client_id')) + entry.notes = request.POST.get('notes', '') + + # Handle date properly for both models + date_str = request.POST.get('date') + if date_str: + try: + entry.date = datetime.strptime(date_str, '%Y-%m-%d').date() + except ValueError: + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid date format. Use YYYY-MM-DD' + }, status=400) + + if model_name == 'SecondTableEntry': + # Handle Helium Output specific fields + + raw_warm = request.POST.get('is_warm') + entry.is_warm = str(raw_warm).lower() in ('1', 'true', 'on', 'yes') + + entry.lhe_delivery = request.POST.get('lhe_delivery', '') + + lhe_output = request.POST.get('lhe_output') + try: + entry.lhe_output = Decimal(lhe_output) if lhe_output else None + except InvalidOperation: + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid LHe Output value' + }, status=400) + + entry.save() + + return JsonResponse({ + 'status': 'success', + 'id': entry.id, + 'client_name': entry.client.name, + 'institute_name': entry.client.institute.name, + 'date': entry.date.strftime('%Y-%m-%d') if entry.date else '', + 'is_warm': entry.is_warm, + 'lhe_delivery': entry.lhe_delivery, + 'lhe_output': str(entry.lhe_output) if entry.lhe_output else '', + 'notes': entry.notes + }) + + + elif model_name == 'ExcelEntry': + # Handle Helium Input specific fields + try: + entry.pressure = Decimal(request.POST.get('pressure', 0)) + entry.purity = Decimal(request.POST.get('purity', 0)) + entry.druckkorrektur = Decimal(request.POST.get('druckkorrektur', 1)) + entry.lhe_zus = Decimal(request.POST.get('lhe_zus', 0)) + entry.constant_300 = Decimal(request.POST.get('constant_300', 300)) + entry.korrig_druck = Decimal(request.POST.get('korrig_druck', 0)) + entry.nm3 = Decimal(request.POST.get('nm3', 0)) + entry.lhe = Decimal(request.POST.get('lhe', 0)) + entry.lhe_ges = Decimal(request.POST.get('lhe_ges', 0)) + except InvalidOperation: + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid numeric value in Helium Input' + }, status=400) + + entry.save() + + return JsonResponse({ + 'status': 'success', + 'id': entry.id, + 'client_name': entry.client.name, + 'institute_name': entry.client.institute.name, + 'date': entry.date.strftime('%Y-%m-%d') if entry.date else '', + 'month': f"{entry.date.month:02d}" if entry.date else '', + 'pressure': str(entry.pressure), + 'purity': str(entry.purity), + 'druckkorrektur': str(entry.druckkorrektur), + 'constant_300': str(entry.constant_300), + 'korrig_druck': str(entry.korrig_druck), + 'nm3': str(entry.nm3), + 'lhe': str(entry.lhe), + 'lhe_zus': str(entry.lhe_zus), + 'lhe_ges': str(entry.lhe_ges), + 'notes': entry.notes + }) + + + except model.DoesNotExist: + return JsonResponse({'status': 'error', 'message': 'Entry not found'}, status=404) + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}, status=400) + + return JsonResponse({'status': 'error', 'message': 'Invalid request method'}, status=400) + +# Delete Entry (Generic) +def delete_entry(request, model_name): + if request.method == 'POST': + try: + if model_name == 'SecondTableEntry': + model = SecondTableEntry + elif model_name == 'ExcelEntry': + model = ExcelEntry + else: + return JsonResponse({'status': 'error', 'message': 'Invalid model'}, status=400) + + entry_id = request.POST.get('id') + entry = model.objects.get(id=entry_id) + entry.delete() + return JsonResponse({'status': 'success', 'message': 'Entry deleted'}) + + except model.DoesNotExist: + return JsonResponse({'status': 'error', 'message': 'Entry not found'}, status=404) + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}, status=400) + + return JsonResponse({'status': 'error', 'message': 'Invalid request'}, status=400) + +def betriebskosten_list(request): + items = Betriebskosten.objects.all().order_by('-buchungsdatum') + return render(request, 'betriebskosten_list.html', {'items': items}) + +def betriebskosten_create(request): + if request.method == 'POST': + try: + entry_id = request.POST.get('id') + if entry_id: + # Update existing entry + entry = Betriebskosten.objects.get(id=entry_id) + else: + # Create new entry + entry = Betriebskosten() + + # Get form data + buchungsdatum_str = request.POST.get('buchungsdatum') + rechnungsnummer = request.POST.get('rechnungsnummer') + kostentyp = request.POST.get('kostentyp') + betrag = request.POST.get('betrag') + beschreibung = request.POST.get('beschreibung') + gas_volume = request.POST.get('gas_volume') + + # Validate required fields + if not all([buchungsdatum_str, rechnungsnummer, kostentyp, betrag]): + return JsonResponse({'status': 'error', 'message': 'All required fields must be filled'}) + + # Convert date string to date object + try: + buchungsdatum = parse_date(buchungsdatum_str) + if not buchungsdatum: + return JsonResponse({'status': 'error', 'message': 'Invalid date format'}) + except (ValueError, TypeError): + return JsonResponse({'status': 'error', 'message': 'Invalid date format'}) + + # Set entry values + entry.buchungsdatum = buchungsdatum + entry.rechnungsnummer = rechnungsnummer + entry.kostentyp = kostentyp + entry.betrag = betrag + entry.beschreibung = beschreibung + + # Only set gas_volume if kostentyp is helium and gas_volume is provided + if kostentyp == 'helium' and gas_volume: + entry.gas_volume = gas_volume + else: + entry.gas_volume = None + + entry.save() + + return JsonResponse({ + 'status': 'success', + 'id': entry.id, + 'buchungsdatum': entry.buchungsdatum.strftime('%Y-%m-%d'), + 'rechnungsnummer': entry.rechnungsnummer, + 'kostentyp_display': entry.get_kostentyp_display(), + 'gas_volume': str(entry.gas_volume) if entry.gas_volume else '-', + 'betrag': str(entry.betrag), + 'beschreibung': entry.beschreibung or '' + }) + + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}) + + return JsonResponse({'status': 'error', 'message': 'Invalid request method'}) + +def betriebskosten_delete(request): + if request.method == 'POST': + try: + entry_id = request.POST.get('id') + entry = Betriebskosten.objects.get(id=entry_id) + entry.delete() + return JsonResponse({'status': 'success'}) + except Betriebskosten.DoesNotExist: + return JsonResponse({'status': 'error', 'message': 'Entry not found'}) + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}) + + return JsonResponse({'status': 'error', 'message': 'Invalid request method'}) + +class CheckSheetView(View): + def get(self, request): + # Get current month/year + current_year = datetime.now().year + current_month = datetime.now().month + + # Get all sheets + sheets = MonthlySheet.objects.all() + + sheet_data = [] + for sheet in sheets: + cells_count = sheet.cells.count() + # Count non-empty cells + non_empty = sheet.cells.exclude(value__isnull=True).count() + + sheet_data.append({ + 'id': sheet.id, + 'year': sheet.year, + 'month': sheet.month, + 'month_name': calendar.month_name[sheet.month], + 'total_cells': cells_count, + 'non_empty_cells': non_empty, + 'has_data': non_empty > 0 + }) + + # Also check what the default view would show + default_sheet = MonthlySheet.objects.filter( + year=current_year, + month=current_month + ).first() + + return JsonResponse({ + 'current_year': current_year, + 'current_month': current_month, + 'current_month_name': calendar.month_name[current_month], + 'default_sheet_exists': default_sheet is not None, + 'default_sheet_id': default_sheet.id if default_sheet else None, + 'sheets': sheet_data, + 'total_sheets': len(sheet_data) + }) + + +class QuickDebugView(View): + def get(self, request): + # Get ALL sheets + sheets = MonthlySheet.objects.all().order_by('year', 'month') + + result = { + 'sheets': [] + } + + for sheet in sheets: + sheet_info = { + 'id': sheet.id, + 'display': f"{sheet.year}-{sheet.month:02d}", + 'url': f"/sheet/{sheet.year}/{sheet.month}/", # CHANGED THIS LINE + 'sheet_url_pattern': 'sheet/{year}/{month}/', # Add this for clarity + } + + # Count cells with data for first client in top_left table + first_client = Client.objects.first() + if first_client: + test_cells = sheet.cells.filter( + client=first_client, + table_type='top_left', + row_index__in=[8, 9, 10] # Rows 9, 10, 11 + ).order_by('row_index') + + cell_values = {} + for cell in test_cells: + cell_values[f"row_{cell.row_index}"] = str(cell.value) if cell.value else "Empty" + + sheet_info['test_cells'] = cell_values + else: + sheet_info['test_cells'] = "No clients" + + result['sheets'].append(sheet_info) + + return JsonResponse(result) +class TestFormulaView(View): + def get(self, request): + # Test the formula evaluation directly + test_values = { + 8: 2, # Row 9 value (0-based index 8) + 9: 2, # Row 10 value (0-based index 9) + } + + # Test formula "9 + 8" (using 0-based indices) + formula = "9 + 8" + result = evaluate_formula(formula, test_values) + + return JsonResponse({ + 'test_values': test_values, + 'formula': formula, + 'result': str(result), + 'note': 'Formula uses 0-based indices. 9=Row10, 8=Row9' + }) + + +class SimpleDebugView(View): + """Simplest debug view to check if things are working""" + def get(self, request): + sheet_id = request.GET.get('sheet_id', 1) + + try: + sheet = MonthlySheet.objects.get(id=sheet_id) + + # Get first client + client = Client.objects.first() + if not client: + return JsonResponse({'error': 'No clients found'}) + + # Check a few cells + cells = Cell.objects.filter( + sheet=sheet, + client=client, + table_type='top_left', + row_index__in=[8, 9, 10] + ).order_by('row_index') + + cell_data = [] + for cell in cells: + cell_data.append({ + 'row_index': cell.row_index, + 'ui_row': cell.row_index + 1, + 'value': str(cell.value) if cell.value is not None else 'Empty', + 'cell_id': cell.id + }) + + return JsonResponse({ + 'sheet': f"{sheet.year}-{sheet.month}", + 'sheet_id': sheet.id, + 'client': client.name, + 'cells': cell_data, + 'note': 'Row 8 = UI Row 9, Row 9 = UI Row 10, Row 10 = UI Row 11' + }) + + except MonthlySheet.DoesNotExist: + return JsonResponse({'error': f'Sheet with id {sheet_id} not found'}) + +def halfyear_settings(request): + """ + Global settings page: choose a year + first month for the 6-month interval. + These values are stored in the session and used by other views. + """ + # Determine available years from your data (use ExcelEntry or SecondTableEntry) + # Here I use SecondTableEntry; you can switch to ExcelEntry if you prefer. + available_years_qs = SecondTableEntry.objects.dates('date', 'year', order='DESC') + available_years = [d.year for d in available_years_qs] + + if not available_years: + available_years = [timezone.now().year] + + # Defaults (if nothing in session yet) + default_year = request.session.get('halfyear_year', available_years[0]) + default_start_month = request.session.get('halfyear_start_month', 1) + + if request.method == 'POST': + year = int(request.POST.get('year', default_year)) + start_month = int(request.POST.get('start_month', default_start_month)) + + request.session['halfyear_year'] = year + request.session['halfyear_start_month'] = start_month + + # Redirect back to where user came from, or to this page again + next_url = request.POST.get('next') or request.GET.get('next') or 'halfyear_settings' + return redirect(next_url) + + # Month choices for the dropdown + month_choices = [(i, calendar.month_name[i]) for i in range(1, 13)] + + context = { + 'available_years': available_years, + 'selected_year': int(default_year), + 'selected_start_month': int(default_start_month), + 'month_choices': month_choices, + } + return render(request, 'halfyear_settings.html', context) \ No newline at end of file diff --git a/sheets/old/views_v2.py b/sheets/old/views_v2.py new file mode 100644 index 0000000..086c96e --- /dev/null +++ b/sheets/old/views_v2.py @@ -0,0 +1,4520 @@ +from django.shortcuts import render, redirect +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.db.models import Sum, Value, DecimalField +from django.http import JsonResponse +from django.db.models import Q +from decimal import Decimal, InvalidOperation +from django.apps import apps +from datetime import date, datetime +import calendar +from django.utils import timezone +from django.views.generic import TemplateView, View +from .models import ( + Client, SecondTableEntry, Institute, ExcelEntry, + Betriebskosten, MonthlySheet, Cell, CellReference, MonthlySummary +) +from django.db.models import Sum +from django.urls import reverse +from django.db.models.functions import Coalesce +from .forms import BetriebskostenForm +from django.utils.dateparse import parse_date +from django.contrib.auth.mixins import LoginRequiredMixin +import json +FIRST_SHEET_YEAR = 2025 +FIRST_SHEET_MONTH = 1 + +CLIENT_GROUPS = { + 'ikp': { + 'label': 'IKP', + # exactly as in the Clients admin + 'names': ['IKP'], + }, + 'phys_chem_bunt_fohrer': { + 'label': 'Buntkowsky + Dr. Fohrer', + # include all variants you might have used for Buntkowsky + 'names': [ + 'AG Buntk.', # the one in your new entry + 'AG Buntkowsky.', # from your original list + 'AG Buntkowsky', + 'Dr. Fohrer', + ], + }, + 'mawi_alff_gutfleisch': { + 'label': 'Alff + AG Gutfleisch', + # include both short and full forms + 'names': [ + 'AG Alff', + 'AG Gutfl.', + 'AG Gutfleisch', + ], + }, + 'm3_group': { + 'label': 'M3 Buntkowsky + M3 Thiele + M3 Gutfleisch', + 'names': [ + 'M3 Buntkowsky', + 'M3 Thiele', + 'M3 Gutfleisch', + ], + }, +} + +# Add this CALCULATION_CONFIG at the top of views.py + +CALCULATION_CONFIG = { + 'top_left': { + # Row mappings: Django row_index (0-based) to Excel row + # Excel B4 -> Django row_index 1 (UI row 2) + # Excel B5 -> Django row_index 2 (UI row 3) + # Excel B6 -> Django row_index 3 (UI row 4) + + # B6 (row_index 3) = B5 (row_index 2) / 0.75 + 3: "2 / 0.75", + + # B11 (row_index 10) = B9 (row_index 8) + 10: "8", + + # B14 (row_index 13) = B13 (row_index 12) - B11 (row_index 10) + B12 (row_index 11) + 13: "12 - 10 + 11", + + # Note: B5, B17, B19, B20 require IF logic, so they'll be handled separately + }, + + # other tables (top_right, bottom_1, ...) stay as they are + + + ' top_right': { + # UI Row 1 (Excel Row 4): Stand der Gaszähler (Vormonat) (Nm³) + 0: { + 'L': "9 / (9 + 9) if (9 + 9) > 0 else 0", # L4 = L13/(L13+M13) + 'M': "9 / (9 + 9) if (9 + 9) > 0 else 0", # M4 = M13/(L13+M13) + 'N': "9 / (9 + 9) if (9 + 9) > 0 else 0", # N4 = N13/(N13+O13) + 'O': "9 / (9 + 9) if (9 + 9) > 0 else 0", # O4 = O13/(N13+O13) + 'P': None, # Editable + 'Q': None, # Editable + 'R': None, # Editable + }, + + # UI Row 2 (Excel Row 5): Gasrückführung (Nm³) + 1: { + 'L': "4", # L5 = L8 + 'M': "4", # M5 = L8 (merged) + 'N': "4", # N5 = N8 + 'O': "4", # O5 = N8 (merged) + 'P': "4 * 0", # P5 = P8 * P4 + 'Q': "4 * 0", # Q5 = P8 * Q4 + 'R': "4 * 0", # R5 = P8 * R4 + }, + + # UI Row 3 (Excel Row 6): Rückführung flüssig (Lit. L-He) + 2: { + 'L': "4", # L6 = L8 (Sammelrückführungen) + 'M': "4", + 'N': "4", + 'O': "4", + 'P': "4", + 'Q': "4", + 'R': "4", +}, + + # UI Row 4 (Excel Row 7): Sonderrückführungen (Lit. L-He) - EDITABLE + 3: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 5 (Excel Row 8): Sammelrückführungen (Lit. L-He) + 4: { + 'L': None, # Will be populated from ExcelEntry + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 6 (Excel Row 9): Bestand in Kannen-1 (Lit. L-He) - EDITABLE + 5: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 7 (Excel Row 10): Summe Bestand (Lit. L-He) + 6: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 11 (Excel Row 14): Rückführ. Soll (Lit. L-He) + # handled in calculate_top_right_dependents (merged pairs + M3) + 10: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 12 (Excel Row 15): Verluste (Soll-Rückf.) (Lit. L-He) + # handled in calculate_top_right_dependents + 11: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 14 (Excel Row 17): Kaltgas Rückgabe (Lit. L-He) – Faktor + # handled in calculate_top_right_dependents (different formulas for pair 1 vs pair 2 + M3) + 13: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 16 (Excel Row 19): Verbraucherverluste (Liter L-He) + # handled in calculate_top_right_dependents + 15: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + }, + + # UI Row 17 (Excel Row 20): % + # handled in calculate_top_right_dependents + 16: { + 'L': None, + 'M': None, + 'N': None, + 'O': None, + 'P': None, + 'Q': None, + 'R': None, + } +}, + 'bottom_1': { + 5: "4 + 3 + 2", + 8: "7 - 6", + }, + 'bottom_2': { + 3: "1 + 2", + 6: "5 - 4", + }, + 'bottom_3': { + 2: "0 + 1", + 5: "3 + 4", + }, + # Special configuration for summation column (last column) + 'summation_column': { + # For each row that should be summed across columns + 'rows_to_sum': [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23], # All rows + # OR specify specific rows: + # 'rows_to_sum': [0, 5, 10, 15, 20], # Only specific rows + # The last column index (0-based) + 'sum_column_index': 5, # 6th column (0-5) since you have 6 clients + } +} +def build_halfyear_window(interval_year: int, start_month: int): + """ + Build a list of (year, month) for the 6-month interval, possibly crossing into the next year. + Example: (2025, 10) -> [(2025,10), (2025,11), (2025,12), (2026,1), (2026,2), (2026,3)] + """ + window = [] + for offset in range(6): + total_index = (start_month - 1) + offset # 0-based + y = interval_year + (total_index // 12) + m = (total_index % 12) + 1 + window.append((y, m)) + return window +# --------------------------------------------------------------------------- +# Halbjahres-Bilanz helpers +# --------------------------------------------------------------------------- + +# You can adjust these indices if needed. +# Assuming: +# - bottom_1.table has row "Gasbestand" at some fixed row index, +# and columns: ... Nm³, Lit. LHe +GASBESTAND_ROW_INDEX = 9 # <-- adjust if your bottom_1 has a different row index +GASBESTAND_COL_NM3 = 3 # <-- adjust to the column index for Nm³ in bottom_1 + +# In top_left / top_right, "Bestand in Kannen-1 (Lit. L-He)" is row_index 5 +BESTAND_KANNEN_ROW_INDEX = 5 + +def pick_sheet_by_gasbestand(window, sheets_by_ym, prev_sheet): + """ + Returns the last sheet in the window whose Gasbestand (J36, Nm³ column) != 0. + If none found, returns prev_sheet (Übertrag_Dez__Vorjahr equivalent). + """ + for (y, m) in reversed(window): + sheet = sheets_by_ym.get((y, m)) + if not sheet: + continue + gasbestand_nm3 = get_bottom1_value(sheet, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) + if gasbestand_nm3 != 0: + return sheet + return prev_sheet +def get_bottom1_value(sheet, row_index: int, col_index: int) -> Decimal: + """Get a numeric value from bottom_1, or 0 if missing.""" + if sheet is None: + return Decimal('0') + + cell = Cell.objects.filter( + sheet=sheet, + table_type='bottom_1', + row_index=row_index, + column_index=col_index, + ).first() + + if cell is None or cell.value in (None, ''): + return Decimal('0') + + try: + return Decimal(str(cell.value)) + except Exception: + return Decimal('0') + +# MUST match the column order in your monthly_sheets top-right table + + + +def get_top_right_value(sheet, client_name: str, row_index: int) -> Decimal: + """ + Read a numeric value from the top_right table of a MonthlySheet for + a given client (by column) and row_index. + + top_right cells are keyed by (sheet, table_type='top_right', + row_index, column_index), where column_index is the position of the + client in HALFYEAR_RIGHT_CLIENTS. + """ + if sheet is None: + return Decimal('0') + + col_index = RIGHT_CLIENT_INDEX.get(client_name) + if col_index is None: + return Decimal('0') + + cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + row_index=row_index, + column_index=col_index, + ).first() + + if cell is None or cell.value in (None, ''): + return Decimal('0') + + try: + return Decimal(str(cell.value)) + except Exception: + return Decimal('0') + +TR_RUECKF_FLUESSIG_ROW = 2 # confirmed by your March value 172.840560 +TR_BESTAND_KANNEN_ROW = 5 # confirmed by your earlier query +def get_bestand_kannen_for_month(sheet, client_name: str) -> Decimal: + """ + 'B9' in your description: Bestand in Kannen-1 (Lit. L-He) + For this implementation we take it from top_left row_index = 5 for that client. + """ + return get_top_left_value(sheet, client_name, row_index=BESTAND_KANNEN_ROW_INDEX) + +from decimal import Decimal +from django.db.models import Sum +from django.db.models.functions import Coalesce +from django.db.models import DecimalField, Value + +from .models import MonthlySheet, SecondTableEntry, Client, Cell +from django.shortcuts import redirect, render + +# You already have HALFYEAR_CLIENTS for the left table (AG Vogel, AG Halfm, IKP) +HALFYEAR_CLIENTS = ["AG Vogel", "AG Halfm", "IKP"] + +# NEW: clients for the top-right half-year table +HALFYEAR_RIGHT_CLIENTS = [ + "Dr. Fohrer", + "AG Buntk.", + "AG Alff", + "AG Gutfl.", + "M3 Thiele", + "M3 Buntkowsky", + "M3 Gutfleisch", +] +BOTTOM1_COL_VOLUME = 0 +BOTTOM1_COL_BAR = 1 +BOTTOM1_COL_KORR = 2 +BOTTOM1_COL_NM3 = 3 +BOTTOM1_COL_LHE = 4 +BOTTOM2_ROW_ANLAGE = 0 +BOTTOM2_COL_G39 = 0 # "Gefäss 2,5" (cell id shows column_index=0) +BOTTOM2_COL_I39 = 1 # "Gefäss 1,0" (cell id shows column_index=1) +BOTTOM2_ROW_INPUTS = { + "g39": (0, 0), # row_index=0, column_index=0 (your G39) + "i39": (0, 1), # row_index=0, column_index=1 (your I39) +} +FACTOR_NM3_TO_LHE = Decimal("0.75") +RIGHT_CLIENT_INDEX = {name: idx for idx, name in enumerate(HALFYEAR_RIGHT_CLIENTS)} +def halfyear_balance_view(request): + """ + Read-only Halbjahres-Bilanz view. + + LEFT table: AG Vogel / AG Halfm / IKP (exactly as in your last working version) + RIGHT table: Dr. Fohrer / AG Buntk. / AG Alff / AG Gutfl. / + M3 Thiele / M3 Buntkowsky / M3 Gutfleisch + using the Excel formulas you described. + + Uses the global 6-month interval from the main page (clients_list). + """ + # 1) Read half-year interval from the session + interval_year = request.session.get('halfyear_year') + interval_start = request.session.get('halfyear_start_month') + + if not interval_year or not interval_start: + # No interval chosen yet -> redirect to main page + return redirect('clients_list') + + interval_year = int(interval_year) + interval_start = int(interval_start) + + # You already have this helper in your code + window = build_halfyear_window(interval_year, interval_start) + # window = [(y1, m1), (y2, m2), ..., (y6, m6)] + + # (Year, month) of the first month + start_year, start_month = window[0] + + # Previous month (for "Stand ... (Vorjahr)" and "Best. in Kannen Vormonat") + prev_total_index = (start_month - 1) - 1 # one month back, 0-based + if prev_total_index >= 0: + prev_year = start_year + (prev_total_index // 12) + prev_month = (prev_total_index % 12) + 1 + else: + prev_year = start_year - 1 + prev_month = 12 + + # Load MonthlySheet objects for the window and for the previous month + sheets_by_ym = {} + for (y, m) in window: + sheet = MonthlySheet.objects.filter(year=y, month=m).first() + sheets_by_ym[(y, m)] = sheet + + prev_sheet = MonthlySheet.objects.filter(year=prev_year, month=prev_month).first() + def pick_bottom2_from_window(window, sheets_by_ym, prev_sheet): + # choose sheet (same logic you already use) + chosen = None + for (y, m) in reversed(window): + s = sheets_by_ym.get((y, m)) + # use your existing condition for choosing month + if s: + chosen = s + break + if chosen is None: + chosen = prev_sheet + + # Now read the two inputs safely + bottom2_inputs = {} + for key, (row_idx, col_idx) in BOTTOM2_ROW_INPUTS.items(): + bottom2_inputs[key] = get_bottom2_value(chosen, row_idx, col_idx) + + return chosen, bottom2_inputs + + + chosen_sheet_bottom2, bottom2_inputs = pick_bottom2_from_window(window, sheets_by_ym, prev_sheet) + bottom2_g39 = bottom2_inputs["g39"] + bottom2_i39 = bottom2_inputs["i39"] + # ---------------------------- + # HALF-YEAR BOTTOM TABLE 1 (Bilanz) - Read only + # ---------------------------- + chosen_sheet_bottom1 = pick_sheet_by_gasbestand(window, sheets_by_ym, prev_sheet) + + # IMPORTANT: define which bottom_1 row_index corresponds to Excel rows 27..35 + # If your bottom_1 starts at Excel row 27 => row_index 0 == Excel 27 + # then row_index = excel_row - 27 + BOTTOM1_EXCEL_START_ROW = 27 + + bottom1_excel_rows = list(range(27, 37)) # 27..36 + BOTTOM1_LABELS = [ + "Batterie 1", + "2", + "3", + "4", + "5", + "Batterie Links", + "2 Bündel", + "2 Ballone", + "Reingasspeicher", + "Gasbestand", + ] + + BOTTOM1_VOLUMES = [ + Decimal("2.4"), + Decimal("5.1"), + Decimal("4.0"), + Decimal("1.0"), + Decimal("4.0"), + Decimal("0.6"), + Decimal("1.2"), + Decimal("20.0"), + Decimal("5.0"), + None, # Gasbestand row has no volume + ] + nm3_sum_27_35 = Decimal("0") + lhe_sum_27_35 = Decimal("0") + bottom1_rows = [] + + for excel_row in bottom1_excel_rows: + row_index = excel_row - BOTTOM1_EXCEL_START_ROW + + chosen_sheet_bottom1 = None + for (y, m) in reversed(window): + s = sheets_by_ym.get((y, m)) + gasbestand = get_bottom1_value(s, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) # J36 (Nm3) + if gasbestand != 0: + chosen_sheet_bottom1 = s + break + + if chosen_sheet_bottom1 is None: + chosen_sheet_bottom1 = prev_sheet + + # Normal rows (27..35): read from chosen sheet and accumulate sums + if excel_row != 36: + nm3_val = get_bottom1_value(chosen_sheet_bottom1, row_index, BOTTOM1_COL_NM3) + lhe_val = get_bottom1_value(chosen_sheet_bottom1, row_index, BOTTOM1_COL_LHE) + + nm3_sum_27_35 += nm3_val + lhe_sum_27_35 += lhe_val + + bottom1_rows.append({ + "label": BOTTOM1_LABELS[row_index], + "volume": BOTTOM1_VOLUMES[row_index], + "bar": get_bottom1_value(chosen_sheet_bottom1, row_index, BOTTOM1_COL_BAR), + "korr": get_bottom1_value(chosen_sheet_bottom1, row_index, BOTTOM1_COL_KORR), + "nm3": nm3_val, + "lhe": lhe_val, + }) + + # Gasbestand row (36): show sums (J36 = SUM(J27:J35), K36 = SUM(K27:K35)) + else: + bottom1_rows.append({ + "label": "Gasbestand", + "volume": "", + "bar": "", + "korr": "", + "nm3": nm3_sum_27_35, + "lhe": lhe_sum_27_35, + }) + start_sheet = sheets_by_ym.get((start_year, start_month)) + # ------------------------------------------------------------ + # Bottom Table 2 (Halbjahres Bilanz) – server-side recalcBottom2() + # ------------------------------------------------------------ + + FACTOR_BT2 = Decimal("0.75") + + # 1) Helper: pick last-nonzero value of bottom_2 row0 col0/col1 from the window (fallback: prev_sheet) + def pick_last_nonzero_bottom2(row_index: int, col_index: int) -> Decimal: + # Scan from last month in window backwards + for (y, m) in reversed(window): + s = sheets_by_ym.get((y, m)) + if not s: + continue + v = get_bottom2_value(s, row_index, col_index) + if v is not None and v != 0: + return v + # fallback to month before window + v_prev = get_bottom2_value(prev_sheet, row_index, col_index) + return v_prev if v_prev is not None else Decimal("0") + + # 2) K38 comes from Overall Summary: "Summe Bestand (Lit. L-He)" + # Find it from your already built overall summary rows list. + + k38 = Decimal("0") + j38 = Decimal("0") + # 3) Inputs G39 / I39 (picked from last non-zero month in window) + g39 = pick_last_nonzero_bottom2(row_index=0, col_index=0) # G39 + i39 = pick_last_nonzero_bottom2(row_index=0, col_index=1) # I39 + + k39 = (g39 or Decimal("0")) + (i39 or Decimal("0")) + j39 = k39 * FACTOR_BT2 + + # 4) +Kaltgas (row 40) + # JS: + # g40 = (2500 - g39)/100*10 + # i40 = (1000 - i39)/100*10 + g40 = None + i40 = None + if g39 is not None: + g40 = (Decimal("2500") - g39) / Decimal("100") * Decimal("10") + if i39 is not None: + i40 = (Decimal("1000") - i39) / Decimal("100") * Decimal("10") + + k40 = (g40 or Decimal("0")) + (i40 or Decimal("0")) + j40 = k40 * FACTOR_BT2 + + # 5) Bestand flüssig He (row 43) + k43 = ( + (k38 or Decimal("0")) + + (k39 or Decimal("0")) + + (k40 or Decimal("0")) + ) + j43 = k43 * FACTOR_BT2 + + # 6) Gesamtbestand neu (row 44) = Gasbestand(Lit) from Bottom Table 1 + k43 + gasbestand_lit = Decimal("0") + for r in bottom1_rows: + if (r.get("label") or "").strip().startswith("Gasbestand"): + gasbestand_lit = r.get("lhe") or Decimal("0") + break + + k44 = (gasbestand_lit or Decimal("0")) + (k43 or Decimal("0")) + j44 = k44 * FACTOR_BT2 + + bottom2 = { + "j38": j38, "k38": k38, + "g39": g39, "i39": i39, "j39": j39, "k39": k39, + "g40": g40, "i40": i40, "j40": j40, "k40": k40, + "j43": j43, "k43": k43, + "j44": j44, "k44": k44, + } + + # ------------------------------------------------------------------ + # 2) LEFT TABLE (your existing, working logic) + # ------------------------------------------------------------------ + HALFYEAR_CLIENTS_LEFT = ["AG Vogel", "AG Halfm", "IKP"] + + # We'll collect client-wise values first for clarity. + client_data_left = {name: {} for name in HALFYEAR_CLIENTS_LEFT} + + # --- Row B3: Stand der Gaszähler (Nm³) + # = MAX(B3 from previous month, and B3 from each of the 6 months in the window) + # row_index 0 in top_left = "Stand der Gaszähler (Nm³)" + months_for_max = [(prev_year, prev_month)] + window + + for cname in HALFYEAR_CLIENTS_LEFT: + max_val = Decimal('0') + for (y, m) in months_for_max: + sheet = sheets_by_ym.get((y, m)) + if sheet is None and (y, m) == (prev_year, prev_month): + sheet = prev_sheet + val_b3 = get_top_left_value(sheet, cname, row_index=0) + if val_b3 > max_val: + max_val = val_b3 + client_data_left[cname]['stand_gas'] = max_val + + # --- Row B4: Stand der Gaszähler (Vorjahr) (Nm³) -> previous month same row --- + for cname in HALFYEAR_CLIENTS_LEFT: + val_b4 = get_top_left_value(prev_sheet, cname, row_index=0) + client_data_left[cname]['stand_gas_prev'] = val_b4 + + # --- Row B5: Gasrückführung (Nm³) = B3 - B4 --- + for cname in HALFYEAR_CLIENTS_LEFT: + b3 = client_data_left[cname]['stand_gas'] + b4 = client_data_left[cname]['stand_gas_prev'] + client_data_left[cname]['gasrueckf'] = b3 - b4 + + # --- Row B6: Rückführung flüssig (Lit. L-He) = B5 / 0.75 --- + for cname in HALFYEAR_CLIENTS_LEFT: + b5 = client_data_left[cname]['gasrueckf'] + client_data_left[cname]['rueckf_fluessig'] = (b5 / Decimal('0.75')) if b5 != 0 else Decimal('0') + + # --- Row B7: Sonderrückführungen (Lit. L-He) = sum over 6 months of that row --- + # That row index is 4 in your top_left table. + for cname in HALFYEAR_CLIENTS_LEFT: + sonder_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + sonder_total += get_top_left_value(sheet, cname, row_index=4) + client_data_left[cname]['sonder'] = sonder_total + + # --- Row B8: Bestand in Kannen-1 (Lit. L-He) --- + # Excel-style logic with Gasbestand (J36) and fallback to previous month. + for cname in HALFYEAR_CLIENTS_LEFT: + chosen_value = None + + # Go from last month (window[5]) backwards to first (window[0]) + for (y, m) in reversed(window): + sheet = sheets_by_ym.get((y, m)) + gasbestand = get_bottom1_value(sheet, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) + if gasbestand != 0: + chosen_value = get_bestand_kannen_for_month(sheet, cname) + break + + # If still None -> use previous month (Übertrag_Dez__Vorjahr equivalent) + if chosen_value is None: + sheet_prev = prev_sheet + chosen_value = get_bestand_kannen_for_month(sheet_prev, cname) + + client_data_left[cname]['bestand_kannen'] = chosen_value if chosen_value is not None else Decimal('0') + + # --- Row B9: Summe Bestand (Lit. L-He) = equal to previous row --- + for cname in HALFYEAR_CLIENTS_LEFT: + client_data_left[cname]['summe_bestand'] = client_data_left[cname]['bestand_kannen'] + + # --- Row B10: Best. in Kannen Vormonat (Lit. L-He) + # = Bestand in Kannen-1 from the month BEFORE the window (prev_year, prev_month) + for cname in HALFYEAR_CLIENTS_LEFT: + client_data_left[cname]['best_kannen_vormonat'] = get_bestand_kannen_for_month(prev_sheet, cname) + + # --- Row B13: Bezug (Liter L-He) --- + for cname in HALFYEAR_CLIENTS_LEFT: + total_bezug = Decimal('0') + for (y, m) in window: + qs = SecondTableEntry.objects.filter( + client__name=cname, + date__year=y, + date__month=m, + ).aggregate( + total=Coalesce(Sum('lhe_output'), Value(0, output_field=DecimalField())) + ) + total_bezug += Decimal(str(qs['total'])) + client_data_left[cname]['bezug'] = total_bezug + + # --- Row B14: Rückführ. Soll (Lit. L-He) = Bezug - Summe Bestand + Best. in Kannen Vormonat --- + for cname in HALFYEAR_CLIENTS_LEFT: + b13 = client_data_left[cname]['bezug'] + b11 = client_data_left[cname]['summe_bestand'] + b12 = client_data_left[cname]['best_kannen_vormonat'] + client_data_left[cname]['rueckf_soll'] = b13 - b11 + b12 + + # --- Row B15: Verluste (Soll-Rückf.) (Lit. L-He) = B14 - B6 --- + for cname in HALFYEAR_CLIENTS_LEFT: + b14 = client_data_left[cname]['rueckf_soll'] + b6 = client_data_left[cname]['rueckf_fluessig'] + client_data_left[cname]['verluste'] = b14 - b6 + + # --- Row B16: Füllungen warm (Lit. L-He) = sum over 6 months (row_index=11) --- + for cname in HALFYEAR_CLIENTS_LEFT: + total_warm = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + total_warm += get_top_left_value(sheet, cname, row_index=11) + client_data_left[cname]['fuellungen_warm'] = total_warm + + # --- Row B17: Kaltgas Rückgabe (Lit. L-He) = Bezug * 0.06 --- + factor = Decimal('0.06') + for cname in HALFYEAR_CLIENTS_LEFT: + b13 = client_data_left[cname]['bezug'] + client_data_left[cname]['kaltgas_rueckgabe'] = b13 * factor + + # --- Row B18: Verbraucherverluste (Liter L-He) = Verluste - Kaltgas Rückgabe --- + for cname in HALFYEAR_CLIENTS_LEFT: + b15 = client_data_left[cname]['verluste'] + b17 = client_data_left[cname]['kaltgas_rueckgabe'] + client_data_left[cname]['verbraucherverluste'] = b15 - b17 + + # --- Row B19: % = Verbraucherverluste / Bezug --- + for cname in HALFYEAR_CLIENTS_LEFT: + bezug = client_data_left[cname]['bezug'] + verb = client_data_left[cname]['verbraucherverluste'] + if bezug != 0: + client_data_left[cname]['percent'] = verb / bezug + else: + client_data_left[cname]['percent'] = None + + # Build LEFT rows structure + left_row_defs = [ + ('Stand der Gaszähler (Nm³)', 'stand_gas'), + ('Stand der Gaszähler (Vorjahr) (Nm³)', 'stand_gas_prev'), + ('Gasrückführung (Nm³)', 'gasrueckf'), + ('Rückführung flüssig (Lit. L-He)', 'rueckf_fluessig'), + ('Sonderrückführungen (Lit. L-He)', 'sonder'), + ('Bestand in Kannen-1 (Lit. L-He)', 'bestand_kannen'), + ('Summe Bestand (Lit. L-He)', 'summe_bestand'), + ('Best. in Kannen Vormonat (Lit. L-He)', 'best_kannen_vormonat'), + ('Bezug (Liter L-He)', 'bezug'), + ('Rückführ. Soll (Lit. L-He)', 'rueckf_soll'), + ('Verluste (Soll-Rückf.) (Lit. L-He)', 'verluste'), + ('Füllungen warm (Lit. L-He)', 'fuellungen_warm'), + ('Kaltgas Rückgabe (Lit. L-He) – Faktor', 'kaltgas_rueckgabe'), + ('Verbraucherverluste (Liter L-He)', 'verbraucherverluste'), + ('%', 'percent'), + ] + + rows_left = [] + for label, key in left_row_defs: + values = [client_data_left[cname][key] for cname in HALFYEAR_CLIENTS_LEFT] + if key == 'percent': + total_bezug = sum((client_data_left[c]['bezug'] for c in HALFYEAR_CLIENTS_LEFT), Decimal('0')) + total_verb = sum((client_data_left[c]['verbraucherverluste'] for c in HALFYEAR_CLIENTS_LEFT), Decimal('0')) + total = (total_verb / total_bezug) if total_bezug != 0 else None + else: + total = sum((v for v in values if v is not None), Decimal('0')) + rows_left.append({ + 'label': label, + 'values': values, + 'total': total, + 'is_percent': key == 'percent', + }) + + # ------------------------------------------------------------------ + # 3) RIGHT TABLE (top-right half-year aggregation) + # ------------------------------------------------------------------ + RIGHT_CLIENTS = HALFYEAR_RIGHT_CLIENTS # for brevity + + right_data = {name: {} for name in RIGHT_CLIENTS} + + # --- Bezug (Liter L-He) for each right client (same as for left) --- + for cname in RIGHT_CLIENTS: + total_bezug = Decimal('0') + for (y, m) in window: + qs = SecondTableEntry.objects.filter( + client__name=cname, + date__year=y, + date__month=m, + ).aggregate( + total=Coalesce(Sum('lhe_output'), Value(0, output_field=DecimalField())) + ) + total_bezug += Decimal(str(qs['total'])) + right_data[cname]['bezug'] = total_bezug + def find_bestand_from_window(reference_client: str) -> Decimal: + """ + Implements: + WENN(last_month!J36=0; WENN(prev_month!J36=0; ...; prev_sheet!9); last_month!9) + reference_client decides which column (L/N/P/Q/R) we read from monthly top_right row_index=5. + """ + # scan backward through window + for (y, m) in reversed(window): + sh = sheets_by_ym.get((y, m)) + if not sh: + continue + gasbestand = get_bottom1_value(sh, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) + if gasbestand != 0: + return get_top_right_value(sh, reference_client, TR_BESTAND_KANNEN_ROW) + + # fallback to previous month (Übertrag_Dez__Vorjahr equivalent) + return get_top_right_value(prev_sheet, reference_client, TR_BESTAND_KANNEN_ROW) + + # Fohrer+Buntk merged: BOTH use Fohrer column (L9) + val_L = find_bestand_from_window("Dr. Fohrer") + right_data["Dr. Fohrer"]["bestand_kannen"] = val_L + right_data["AG Buntk."]["bestand_kannen"] = val_L + + # Alff+Gutfl merged: BOTH use Alff column (N9) + val_N = find_bestand_from_window("AG Alff") + right_data["AG Alff"]["bestand_kannen"] = val_N + right_data["AG Gutfl."]["bestand_kannen"] = val_N + + # M3 each uses its own column (P9/Q9/R9) + right_data["M3 Thiele"]["bestand_kannen"] = find_bestand_from_window("M3 Thiele") + right_data["M3 Buntkowsky"]["bestand_kannen"] = find_bestand_from_window("M3 Buntkowsky") + right_data["M3 Gutfleisch"]["bestand_kannen"] = find_bestand_from_window("M3 Gutfleisch") + # Helper for pair shares (L13/($L13+$M13), etc.) + def pair_share(c1, c2): + total = right_data[c1]['bezug'] + right_data[c2]['bezug'] + if total == 0: + return (Decimal('0'), Decimal('0')) + return ( + right_data[c1]['bezug'] / total, + right_data[c2]['bezug'] / total, + ) + + # --- "Stand der Gaszähler (Vorjahr) (Nm³)" row: share based on Bezug --- + # Dr. Fohrer / AG Buntk. + s_fohrer, s_buntk = pair_share("Dr. Fohrer", "AG Buntk.") + right_data["Dr. Fohrer"]['stand_prev_share'] = s_fohrer + right_data["AG Buntk."]['stand_prev_share'] = s_buntk + + # AG Alff / AG Gutfl. + s_alff, s_gutfl = pair_share("AG Alff", "AG Gutfl.") + right_data["AG Alff"]['stand_prev_share'] = s_alff + right_data["AG Gutfl."]['stand_prev_share'] = s_gutfl + + # M3 Thiele / M3 Buntkowsky / M3 Gutfleisch → empty in Excel → None + for cname in ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"]: + right_data[cname]['stand_prev_share'] = None + + # --- Rückführung flüssig per month (raw sums) --- + # top_right row_index=2 is "Rückführung flüssig (Lit. L-He)" + + # --- Sonderrückführungen (row_index=3 in top_right) --- + for cname in RIGHT_CLIENTS: + sonder_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + sonder_total += get_top_right_value(sheet, cname, row_index=3) + right_data[cname]['sonder'] = sonder_total + + # --- Sammelrückführung (row_index=4 in top_right), grouped & merged --- + # Group 1: Dr. Fohrer + AG Buntk. + group1_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + group1_total += get_top_right_value(sheet, "Dr. Fohrer", row_index=4) + right_data["Dr. Fohrer"]['sammel'] = group1_total + right_data["AG Buntk."]['sammel'] = group1_total + + # Group 2: AG Alff + AG Gutfl. + group2_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + group2_total += get_top_right_value(sheet, "AG Alff", row_index=4) + right_data["AG Alff"]['sammel'] = group2_total + right_data["AG Gutfl."]['sammel'] = group2_total + + # Group 3: M3 Thiele + M3 Buntkowsky + M3 Gutfleisch + group3_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + group3_total += get_top_right_value(sheet, "M3 Thiele", row_index=4) + right_data["M3 Thiele"]['sammel'] = group3_total + right_data["M3 Buntkowsky"]['sammel'] = group3_total + right_data["M3 Gutfleisch"]['sammel'] = group3_total + def safe_div(a: Decimal, b: Decimal) -> Decimal: + return (a / b) if b != 0 else Decimal("0") + + # --- Rückführung flüssig (Lit. L-He) for Halbjahres-Bilanz top-right --- + # Uses your exact formulas. + + # 1) Fohrer / Buntk split by BEZUG share times group SAMMEL (L8) + L13 = right_data["Dr. Fohrer"]["bezug"] + M13 = right_data["AG Buntk."]["bezug"] + L8 = right_data["Dr. Fohrer"]["sammel"] # merged group total + + den = (L13 + M13) + right_data["Dr. Fohrer"]["rueckf_fluessig"] = (safe_div(L13, den) * L8) if den != 0 else Decimal("0") + right_data["AG Buntk."]["rueckf_fluessig"] = (safe_div(M13, den) * L8) if den != 0 else Decimal("0") + + # 2) Alff / Gutfl split by BEZUG share times group SAMMEL (N8) + N13 = right_data["AG Alff"]["bezug"] + O13 = right_data["AG Gutfl."]["bezug"] + N8 = right_data["AG Alff"]["sammel"] # merged group total + + den = (N13 + O13) + right_data["AG Alff"]["rueckf_fluessig"] = (safe_div(N13, den) * N8) if den != 0 else Decimal("0") + right_data["AG Gutfl."]["rueckf_fluessig"] = (safe_div(O13, den) * N8) if den != 0 else Decimal("0") + + # 3) M3 Thiele = sum of monthly Rückführung flüssig (monthly top_right row_index=2) over window + P6_sum = Decimal("0") + for (y, m) in window: + sh = sheets_by_ym.get((y, m)) + P6_sum += get_top_right_value(sh, "M3 Thiele", TR_RUECKF_FLUESSIG_ROW) + right_data["M3 Thiele"]["rueckf_fluessig"] = P6_sum + + # 4) M3 Buntkowsky / M3 Gutfleisch split by BEZUG share times M3-group SAMMEL (P8) + P13 = right_data["M3 Thiele"]["bezug"] + Q13 = right_data["M3 Buntkowsky"]["bezug"] + R13 = right_data["M3 Gutfleisch"]["bezug"] + P8 = right_data["M3 Thiele"]["sammel"] # merged group total + + den = (P13 + Q13 + R13) + right_data["M3 Buntkowsky"]["rueckf_fluessig"] = (safe_div(Q13, den) * P8) if den != 0 else Decimal("0") + right_data["M3 Gutfleisch"]["rueckf_fluessig"] = (safe_div(R13, den) * P8) if den != 0 else Decimal("0") + # --- Bestand in Kannen-1 (Lit. L-He) for right table (grouped) --- + # Use Gasbestand (J36) and fallback logic, but now reading top_right B9 for each group. + TOP_RIGHT_ROW_BESTAND_KANNEN = 6 # <-- most likely correct in your setup + + def pick_bestand_top_right(base_client: str) -> Decimal: + # Go from last month in window backwards: if Gasbestand != 0, use that month's Bestand in Kannen + for (y, m) in reversed(window): + sh = sheets_by_ym.get((y, m)) + if not sh: + continue + + gasbestand = get_bottom1_value(sh, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) + if gasbestand != 0: + return get_top_right_value(sh, base_client, TOP_RIGHT_ROW_BESTAND_KANNEN) + + # Fallback to previous month (Übertrag_Dez__Vorjahr equivalent) + return get_top_right_value(prev_sheet, base_client, TOP_RIGHT_ROW_BESTAND_KANNEN) + + # Group 1 merged (Fohrer + Buntk.) + g1_best = pick_bestand_top_right("Dr. Fohrer") + right_data["Dr. Fohrer"]["bestand_kannen"] = g1_best + right_data["AG Buntk."]["bestand_kannen"] = g1_best + + # Group 2 merged (Alff + Gutfl.) + g2_best = pick_bestand_top_right("AG Alff") + right_data["AG Alff"]["bestand_kannen"] = g2_best + right_data["AG Gutfl."]["bestand_kannen"] = g2_best + + # Group 3 merged (M3 Thiele + M3 Buntkowsky + M3 Gutfleisch) + g3_best = pick_bestand_top_right("M3 Thiele") + right_data["M3 Thiele"]["bestand_kannen"] = g3_best + right_data["M3 Buntkowsky"]["bestand_kannen"] = g3_best + right_data["M3 Gutfleisch"]["bestand_kannen"] = g3_best + + # Summe Bestand = same as previous row + for cname in RIGHT_CLIENTS: + right_data[cname]['summe_bestand'] = right_data[cname]['bestand_kannen'] + + # Best. in Kannen Vormonat (Lit. L-He) from previous month top_right row_index=7 + g1_prev = get_top_right_value(prev_sheet, "Dr. Fohrer", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["Dr. Fohrer"]['best_kannen_vormonat'] = g1_prev + right_data["AG Buntk."]['best_kannen_vormonat'] = g1_prev + + g2_prev = get_top_right_value(prev_sheet, "AG Alff", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["AG Alff"]['best_kannen_vormonat'] = g2_prev + right_data["AG Gutfl."]['best_kannen_vormonat'] = g2_prev + + + # Group 1 merged (Fohrer + Buntk.) + g1_prev = get_top_right_value(prev_sheet, "Dr. Fohrer", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["Dr. Fohrer"]["best_kannen_vormonat"] = g1_prev + right_data["AG Buntk."]["best_kannen_vormonat"] = g1_prev + + # Group 2 merged (Alff + Gutfl.) + g2_prev = get_top_right_value(prev_sheet, "AG Alff", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["AG Alff"]["best_kannen_vormonat"] = g2_prev + right_data["AG Gutfl."]["best_kannen_vormonat"] = g2_prev + + # Group 3 UNMERGED (each one reads its own cell) + right_data["M3 Thiele"]["best_kannen_vormonat"] = get_top_right_value(prev_sheet, "M3 Thiele", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["M3 Buntkowsky"]["best_kannen_vormonat"] = get_top_right_value(prev_sheet, "M3 Buntkowsky", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["M3 Gutfleisch"]["best_kannen_vormonat"] = get_top_right_value(prev_sheet, "M3 Gutfleisch", TOP_RIGHT_ROW_BESTAND_KANNEN) + + # --- Rückführ. Soll (Lit. L-He) according to your formulas --- + + # Group 1: Dr. Fohrer / AG Buntk. + total_bestand_1 = right_data["Dr. Fohrer"]['summe_bestand'] + best_vormonat_1 = right_data["Dr. Fohrer"]['best_kannen_vormonat'] + diff1 = total_bestand_1 - best_vormonat_1 + share_fohrer = right_data["Dr. Fohrer"]['stand_prev_share'] or Decimal('0') + + right_data["Dr. Fohrer"]['rueckf_soll'] = ( + right_data["Dr. Fohrer"]['bezug'] - diff1 * share_fohrer + ) + right_data["AG Buntk."]['rueckf_soll'] = ( + right_data["AG Buntk."]['bezug'] - total_bestand_1 + best_vormonat_1 + ) + + # Group 2: AG Alff / AG Gutfl. + total_bestand_2 = right_data["AG Alff"]['summe_bestand'] + best_vormonat_2 = right_data["AG Alff"]['best_kannen_vormonat'] + diff2 = total_bestand_2 - best_vormonat_2 + share_alff = right_data["AG Alff"]['stand_prev_share'] or Decimal('0') + share_gutfl = right_data["AG Gutfl."]['stand_prev_share'] or Decimal('0') + + right_data["AG Alff"]['rueckf_soll'] = ( + right_data["AG Alff"]['bezug'] - diff2 * share_alff + ) + right_data["AG Gutfl."]['rueckf_soll'] = ( + right_data["AG Gutfl."]['bezug'] - diff2 * share_gutfl + ) + + # Group 3: M3 Thiele / M3 Buntkowsky / M3 Gutfleisch + for cname in ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"]: + b13 = right_data[cname]['bezug'] + b12 = right_data[cname]['best_kannen_vormonat'] + b11 = right_data[cname]['summe_bestand'] + # Excel: P13+P12-P11 etc. + right_data[cname]['rueckf_soll'] = b13 + b12 - b11 + + # --- Verluste (Soll-Rückf.) (Lit. L-He) = B14 - B6 - B7 --- + for cname in RIGHT_CLIENTS: + b14 = right_data[cname]['rueckf_soll'] + b6 = right_data[cname]['rueckf_fluessig'] + b7 = right_data[cname]['sonder'] + right_data[cname]['verluste'] = b14 - b6 - b7 + + # --- Füllungen warm (Lit. L-He) = sum of monthly 'Füllungen warm' (row_index=11 top_right) --- + for cname in RIGHT_CLIENTS: + total_warm = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + total_warm += get_top_right_value(sheet, cname, row_index=11) + right_data[cname]['fuellungen_warm'] = total_warm + + # --- Kaltgas Rückgabe (Lit. L-He) – Faktor = Bezug * 0.06 --- + for cname in RIGHT_CLIENTS: + b13 = right_data[cname]['bezug'] + right_data[cname]['kaltgas_rueckgabe'] = b13 * factor + + # --- Verbraucherverluste (Liter L-He) = Verluste - Kaltgas Rückgabe --- + for cname in RIGHT_CLIENTS: + b15 = right_data[cname]['verluste'] + b17 = right_data[cname]['kaltgas_rueckgabe'] + right_data[cname]['verbraucherverluste'] = b15 - b17 + + # --- % = Verbraucherverluste / Bezug --- + for cname in RIGHT_CLIENTS: + bezug = right_data[cname]['bezug'] + verb = right_data[cname]['verbraucherverluste'] + if bezug != 0: + right_data[cname]['percent'] = verb / bezug + else: + right_data[cname]['percent'] = None + + # Build RIGHT rows structure + right_row_defs = [ + ('Stand der Gaszähler (Vorjahr) (Nm³)', 'stand_prev_share'), + # We skip the pure-text "Gasrückführung (Nm³)" line here, + # because it’s only text (Aufteilung nach Verbrauch / Gaszähler) + # and easier to render directly in the template if needed. + ('Rückführung flüssig (Lit. L-He)', 'rueckf_fluessig'), + ('Sonderrückführungen (Lit. L-He)', 'sonder'), + ('Sammelrückführung (Lit. L-He)', 'sammel'), + ('Bestand in Kannen-1 (Lit. L-He)', 'bestand_kannen'), + ('Summe Bestand (Lit. L-He)', 'summe_bestand'), + ('Best. in Kannen Vormonat (Lit. L-He)', 'best_kannen_vormonat'), + ('Bezug (Liter L-He)', 'bezug'), + ('Rückführ. Soll (Lit. L-He)', 'rueckf_soll'), + ('Verluste (Soll-Rückf.) (Lit. L-He)', 'verluste'), + ('Füllungen warm (Lit. L-He)', 'fuellungen_warm'), + ('Kaltgas Rückgabe (Lit. L-He) – Faktor', 'kaltgas_rueckgabe'), + ('Verbraucherverluste (Liter L-He)', 'verbraucherverluste'), + ('%', 'percent'), + ] + + rows_right = [] + for label, key in right_row_defs: + values = [right_data[cname].get(key) for cname in RIGHT_CLIENTS] + + if key == 'percent': + total_bezug = sum((right_data[c]['bezug'] for c in RIGHT_CLIENTS), Decimal('0')) + total_verb = sum((right_data[c]['verbraucherverluste'] for c in RIGHT_CLIENTS), Decimal('0')) + total = (total_verb / total_bezug) if total_bezug != 0 else None + else: + total = sum((v for v in values if isinstance(v, Decimal)), Decimal('0')) + + rows_right.append({ + 'label': label, + 'values': values, + 'total': total, + 'is_percent': key == 'percent', + }) + SUM_TABLE_ROWS = [ + ("Rückführung flüssig (Lit. L-He)", "rueckf_fluessig"), + ("Sonderrückführungen (Lit. L-He)", "sonder"), + ("Sammelrückführungen (Lit. L-He)", "sammel"), + ("Bestand in Kannen-1 (Lit. L-He)", "bestand_kannen"), + ("Summe Bestand (Lit. L-He)", "summe_bestand"), + ("Best. in Kannen Vormonat (Lit. L-He)", "best_kannen_vormonat"), + ("Bezug (Liter L-He)", "bezug"), + ("Rückführ. Soll (Lit. L-He)", "rueckf_soll"), + ("Verluste (Soll-Rückf.) (Lit. L-He)", "verluste"), + ("Füllungen warm (Lit. L-He)", "fuellungen_warm"), + ("Kaltgas Rückgabe (Lit. L-He) – Faktor", "kaltgas_rueckgabe"), + ("Faktor 0.06", "factor_row"), + ("Verbraucherverluste (Liter L-He)", "verbraucherverluste"), + ("%", "percent"), + ] + + RIGHT_GROUPS = { + "chemie": ["Dr. Fohrer", "AG Buntk."], + "mawi": ["AG Alff", "AG Gutfl."], + "m3": ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"], + } + + RIGHT_ALL = ["Dr. Fohrer", "AG Buntk.", "AG Alff", "AG Gutfl.", "M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"] + LEFT_ALL = HALFYEAR_CLIENTS_LEFT + + def safe_pct(verb, bez): + return (verb / bez) if bez != 0 else None + rows_sum = [] + def d(x): + return x if isinstance(x, Decimal) else Decimal("0") + for label, key in SUM_TABLE_ROWS: + + if key == "factor_row": + lichtwiese = chemie = mawi = m3 = total = Decimal("0.06") + + elif key == "percent": + # Right totals + rw_bez = sum(d(right_data[c].get("bezug")) for c in RIGHT_ALL) + rw_verb = sum(d(right_data[c].get("verbraucherverluste")) for c in RIGHT_ALL) + lichtwiese = safe_pct(rw_verb, rw_bez) + + # Chemie + ch_bez = sum(d(right_data[c].get("bezug")) for c in RIGHT_GROUPS["chemie"]) + ch_verb = sum(d(right_data[c].get("verbraucherverluste")) for c in RIGHT_GROUPS["chemie"]) + chemie = safe_pct(ch_verb, ch_bez) + + # MaWi + mw_bez = sum(d(right_data[c].get("bezug")) for c in RIGHT_GROUPS["mawi"]) + mw_verb = sum(d(right_data[c].get("verbraucherverluste")) for c in RIGHT_GROUPS["mawi"]) + mawi = safe_pct(mw_verb, mw_bez) + + # M3 + m3_bez = sum(d(right_data[c].get("bezug")) for c in RIGHT_GROUPS["m3"]) + m3_verb = sum(d(right_data[c].get("verbraucherverluste")) for c in RIGHT_GROUPS["m3"]) + m3 = safe_pct(m3_verb, m3_bez) + + # Σ column = (left verb + right verb) / (left bez + right bez) + left_bez = sum(d(client_data_left[c].get("bezug")) for c in LEFT_ALL) + left_verb = sum(d(client_data_left[c].get("verbraucherverluste")) for c in LEFT_ALL) + total = safe_pct(left_verb + rw_verb, left_bez + rw_bez) + + else: + # normal rows = sums + lichtwiese = sum(d(right_data[c].get(key)) for c in RIGHT_ALL) + chemie = sum(d(right_data[c].get(key)) for c in RIGHT_GROUPS["chemie"]) + mawi = sum(d(right_data[c].get(key)) for c in RIGHT_GROUPS["mawi"]) + m3 = sum(d(right_data[c].get(key)) for c in RIGHT_GROUPS["m3"]) + + left_total = sum(d(client_data_left[c].get(key)) for c in LEFT_ALL) + total = left_total + lichtwiese + + rows_sum.append({ + "row_index": row_index, + "label": label, + "total": total, + "lichtwiese": lichtwiese, + "chemie": chemie, + "mawi": mawi, + "m3": m3, + "is_percent": (key == "percent"), + }) + def find_sum_row(rows, label_startswith: str): + for r in rows: + if str(r.get("label", "")).strip().startswith(label_startswith): + return r + return None + + summe_bestand_row = find_sum_row(rows_sum, "Summe Bestand") + k38 = (summe_bestand_row.get("total") if summe_bestand_row else Decimal("0")) or Decimal("0") + j38 = k38 * Decimal("0.75") + # --- FIX: now that k38 is known, update bottom2 + recompute dependent rows --- + bottom2["k38"] = k38 + bottom2["j38"] = j38 + + k39 = bottom2.get("k39") or Decimal("0") + k40 = bottom2.get("k40") or Decimal("0") + + # Row 43: Bestand flüssig He = SUMME(K38:K40) + k43 = (k38 or Decimal("0")) + k39 + k40 + j43 = k43 * Decimal("0.75") + + bottom2["k43"] = k43 + bottom2["j43"] = j43 + + # Row 44: Gesamtbestand neu = Gasbestand(Lit) from bottom table 1 + k43 + gasbestand_lit = Decimal("0") + for r in bottom1_rows: + if (r.get("label") or "").strip().startswith("Gasbestand"): + gasbestand_lit = r.get("lhe") or Decimal("0") + break + + k44 = gasbestand_lit + k43 + j44 = k44 * Decimal("0.75") + + bottom2["k44"] = k44 + bottom2["j44"] = j44 + def d(x): + return x if isinstance(x, Decimal) else Decimal("0") + + # ---- Bottom2: J38/K38 depend on rows_sum (overall summary), so do it HERE ---- + k38 = Decimal("0") + for r in rows_sum: + if r.get("label") == "Summe Bestand (Lit. L-He)": + k38 = r.get("total") or Decimal("0") + break + + j38 = k38 * FACTOR_NM3_TO_LHE # 0.75 + bottom2["k38"] = k38 + bottom2["j38"] = j38 + factor = Decimal("0.75") + + # window = the 6-month list you already build in this view: [(y,m), (y,m), ...] + # bottom2 = dict with "k44" already computed in your view + # rows_sum = overall sum group rows list (your existing halfyear logic) + + # 1) K46 = K44 from the month BEFORE the global month (interval start) + start_year = interval_year + start_month = interval_start # whatever you named your start month variable + + if start_month == 1: + prev_year, prev_month = start_year - 1, 12 + else: + prev_year, prev_month = start_year, start_month - 1 + + prev_sheet = MonthlySheet.objects.filter(year=prev_year, month=prev_month).first() + + # This assumes you have MonthlySummary.gesamtbestand_neu_lhe as K44 equivalent. + # If your field name differs, tell me your MonthlySummary model fields. + prev_k44 = Decimal("0") + if prev_sheet: + prev_sum = MonthlySummary.objects.filter(sheet=prev_sheet).first() + if prev_sum and prev_sum.gesamtbestand_neu_lhe is not None: + prev_k44 = Decimal(str(prev_sum.gesamtbestand_neu_lhe)) + + # helpers: read bottom_3 values for a given sheet/month + def get_bottom3_value(sheet, row_index, col_index): + if not sheet: + return Decimal("0") + c = Cell.objects.filter( + sheet=sheet, table_type="bottom_3", + row_index=row_index, column_index=col_index + ).first() + if not c or c.value in (None, "", "None"): + return Decimal("0") + try: + return Decimal(str(c.value)) + except Exception: + return Decimal("0") + + # 2) Sum rows across the 6-month window + g47_sum = Decimal("0") + i47_sum = Decimal("0") + j47_sum = Decimal("0") + + g50_sum = Decimal("0") + i50_sum = Decimal("0") + + for (yy, mm) in window: + s = MonthlySheet.objects.filter(year=yy, month=mm).first() + ## Excel row 47 maps to bottom_3 row_index=1 in DB (see monthly_sheet.html) + g = get_bottom3_value(s, 1, 1) # G47 editable + i = get_bottom3_value(s, 1, 2) # I47 editable + + g47_sum += g + i47_sum += i + + # In monthly_sheet, J47 is CALCULATED as (G47 + I47), not stored as an editable cell + j47_sum += (g + i) + + # row 50: G(2), I(4) + g50_sum += get_bottom3_value(s, 50, 2) + i50_sum += get_bottom3_value(s, 50, 4) + + # 3) K52 = Verbraucherverlust from overall sum group first column (global Σ) + k52 = Decimal("0") + for r in rows_sum: + label = (r.get("label") or "") + if label.startswith("Verbraucherverluste"): + k52 = r.get("total") or Decimal("0") + break + + # --- apply the SAME monthly formulas, with your overrides --- + # Row 46 + k46 = prev_k44 + j46 = k46 * factor + + # Row 47 + g47 = g47_sum + i47 = i47_sum + j47 = j47_sum + k47 = (j47 / factor) + g47 if (j47 != 0 or g47 != 0) else Decimal("0") + + # Row 48 + k48 = k46 + k47 + j48 = k48 * factor + + # Row 49 (akt. Monat) -> in halfyear we use current bottom2 K44 + k49 = bottom2.get("k44") or Decimal("0") + j49 = k49 * factor + + # Row 50 + g50 = g50_sum + i50 = i50_sum + j50 = i50 + k50 = (j50 / factor) if j50 != 0 else Decimal("0") + + # Row 51 + k51 = k48 - k49 - k50 + j51 = k51 * factor + + # Row 52 + j52 = k52 * factor + + # Row 53 + k53 = k51 - k52 + j53 = j51 - j52 + + bottom3 = { + "j46": j46, "k46": k46, + "g47": g47, "i47": i47, "j47": j47, "k47": k47, + "j48": j48, "k48": k48, + "j49": j49, "k49": k49, + "g50": g50, "i50": i50, "j50": j50, "k50": k50, + "j51": j51, "k51": k51, + "j52": j52, "k52": k52, + "j53": j53, "k53": k53, + } + # ------------------------------------------------------------------ + # 4) Context – keep old keys AND new ones + # ------------------------------------------------------------------ + context = { + 'interval_year': interval_year, + 'interval_start_month': interval_start, + 'window': window, + + # Left table – old names (for your first template) + 'clients': HALFYEAR_CLIENTS_LEFT, + 'rows': rows_left, + + # Left table – explicit + 'clients_left': HALFYEAR_CLIENTS_LEFT, + 'rows_left': rows_left, + + # Right table + 'clients_right': RIGHT_CLIENTS, + 'rows_right': rows_right, + 'rows_sum': rows_sum, + 'bottom1_rows': bottom1_rows, + + } + context["bottom2"] = bottom2 + context["bottom3"] = bottom3 + context["context_bottom2_g39"] = bottom2_inputs["g39"] + context["context_bottom2_i39"] = bottom2_inputs["i39"] + return render(request, 'halfyear_balance.html', context) + +def get_bottom2_value(sheet, row_index: int, col_index: int) -> Decimal: + """Get numeric value from bottom_2 or 0 if missing.""" + if sheet is None: + return Decimal("0") + cell = Cell.objects.filter( + sheet=sheet, + table_type="bottom_2", + row_index=row_index, + column_index=col_index, + ).first() + if cell is None or cell.value in (None, ""): + return Decimal("0") + try: + return Decimal(str(cell.value)) + except Exception: + return Decimal("0") + +def get_top_left_value(sheet, client_name: str, row_index: int) -> Decimal: + """ + Read a numeric value from the top_left table for a given month, client and row. + Does NOT use column_index, because top_left is keyed only by client + row_index. + """ + if sheet is None: + return Decimal('0') + + client_obj = Client.objects.filter(name=client_name).first() + if not client_obj: + return Decimal('0') + + cell = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client_obj, + row_index=row_index, + ).first() + + if cell is None or cell.value in (None, ''): + return Decimal('0') + + try: + return Decimal(str(cell.value)) + except Exception: + return Decimal('0') +def get_group_clients(group_key): + """Return queryset of clients that belong to a logical group.""" + from .models import Client # local import to avoid circulars + + group = CLIENT_GROUPS.get(group_key) + if not group: + return Client.objects.none() + return Client.objects.filter(name__in=group['names']) + +def calculate_summation(sheet, table_type, row_index, sum_column_index): + """Calculate summation for a row, with special handling for % row""" + from decimal import Decimal + from .models import Cell + + try: + # Special case: top_left, % row (Excel B20 -> row_index 19) + if table_type == 'top_left' and row_index == 19: + # K13 = sum of row 13 (Excel B13 -> row_index 12) across all clients + cells_row13 = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + row_index=12, # Excel B13 = row_index 12 + column_index__lt=sum_column_index # Exclude sum column itself + ) + total_13 = Decimal('0') + for cell in cells_row13: + if cell.value is not None: + total_13 += Decimal(str(cell.value)) + + # K19 = sum of row 19 (Excel B19 -> row_index 18) across all clients + cells_row19 = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + row_index=18, # Excel B19 = row_index 18 + column_index__lt=sum_column_index + ) + total_19 = Decimal('0') + for cell in cells_row19: + if cell.value is not None: + total_19 += Decimal(str(cell.value)) + + # Calculate: IF(K13=0; 0; K19/K13) + if total_13 == 0: + return Decimal('0') + return total_19 / total_13 + + # Normal summation for other rows + cells_in_row = Cell.objects.filter( + sheet=sheet, + table_type=table_type, + row_index=row_index, + column_index__lt=sum_column_index + ) + + total = Decimal('0') + for cell in cells_in_row: + if cell.value is not None: + total += Decimal(str(cell.value)) + + return total + + except Exception as e: + print(f"Error calculating summation for {table_type}[{row_index}]: {e}") + return None + +# Helper function for calculations +def evaluate_formula(formula, values_dict): + """ + Safely evaluate a formula like "10 + 9" where numbers are row indices + values_dict: {row_index: decimal_value} + """ + from decimal import Decimal + import re + + try: + # Create a copy of the formula to work with + expr = formula + + # Find all row numbers in the formula + row_refs = re.findall(r'\b\d+\b', expr) + + for row_ref in row_refs: + row_num = int(row_ref) + if row_num in values_dict and values_dict[row_num] is not None: + # Replace row reference with actual value + expr = expr.replace(row_ref, str(values_dict[row_num])) + else: + # Missing value - can't calculate + return None + + # Evaluate the expression + # Note: In production, use a safer evaluator like `asteval` + result = eval(expr, {"__builtins__": {}}, {}) + + # Convert to Decimal with proper rounding + return Decimal(str(round(result, 6))) + + except Exception: + return None + +# Monthly Sheet View +class MonthlySheetView(TemplateView): + template_name = 'monthly_sheet.html' + + def populate_helium_input_to_top_right(self, sheet): + """Populate bezug data from SecondTableEntry to top-right table (row 8 = Excel row 12)""" + from .models import SecondTableEntry, Cell, Client + from django.db.models.functions import Coalesce + from decimal import Decimal + + year = sheet.year + month = sheet.month + + TOP_RIGHT_CLIENTS = [ + "Dr. Fohrer", # Column index 0 (L) + "AG Buntk.", # Column index 1 (M) + "AG Alff", # Column index 2 (N) + "AG Gutfl.", # Column index 3 (O) + "M3 Thiele", # Column index 4 (P) + "M3 Buntkowsky", # Column index 5 (Q) + "M3 Gutfleisch", # Column index 6 (R) + ] + + # For each client in top-right table + for client_name in TOP_RIGHT_CLIENTS: + try: + client = Client.objects.get(name=client_name) + column_index = TOP_RIGHT_CLIENTS.index(client_name) + + # Calculate total LHe_output for this client in this month from SecondTableEntry + total_lhe_output = SecondTableEntry.objects.filter( + client=client, + date__year=year, + date__month=month + ).aggregate( + total=Coalesce(Sum('lhe_output'), Decimal('0')) + )['total'] + + # Get or create the cell for row_index 8 (Excel row 12) - Bezug + cell, created = Cell.objects.get_or_create( + sheet=sheet, + table_type='top_right', + client=client, + row_index=8, # Bezug row (Excel row 12) + column_index=column_index, + defaults={'value': total_lhe_output} + ) + + if not created and cell.value != total_lhe_output: + cell.value = total_lhe_output + cell.save() + + except Client.DoesNotExist: + continue + + # After populating bezug, trigger calculation for all dependent cells + # Get any cell to start the calculation + first_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right' + ).first() + + if first_cell: + save_view = SaveCellsView() + save_view.calculate_top_right_dependents(sheet, first_cell) + + return True + def calculate_bezug_from_entries(self, sheet, year, month): + """Calculate B11 (Bezug) from SecondTableEntry for all clients - ONLY for non-start sheets""" + from .models import SecondTableEntry, Cell, Client + from django.db.models import Sum + from django.db.models.functions import Coalesce + from decimal import Decimal + + # Check if this is the start sheet + if year == 2025 and month == 1: + return # Don't auto-calculate for start sheet + + for client in Client.objects.all(): + # Calculate total LHe output for this client in this month + lhe_output_sum = SecondTableEntry.objects.filter( + client=client, + date__year=year, + date__month=month + ).aggregate( + total=Coalesce(Sum('lhe_output'), Decimal('0')) + )['total'] + + # Update B11 cell (row_index 8 = UI Row 9) + b11_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client, + row_index=8 # Excel B11 + ).first() + + if b11_cell and (b11_cell.value != lhe_output_sum or b11_cell.value is None): + b11_cell.value = lhe_output_sum + b11_cell.save() + + # Also trigger dependent calculations + from .views import SaveCellsView + save_view = SaveCellsView() + save_view.calculate_top_left_dependents(sheet, b11_cell) + # In MonthlySheetView.get_context_data() method, update the TOP_RIGHT_CLIENTS and row count: + + + + return True + def get_context_data(self, **kwargs): + from decimal import Decimal + context = super().get_context_data(**kwargs) + year = self.kwargs.get('year', datetime.now().year) + month = self.kwargs.get('month', datetime.now().month) + is_start_sheet = (year == FIRST_SHEET_YEAR and month == FIRST_SHEET_MONTH) + + # Get or create the monthly sheet + sheet, created = MonthlySheet.objects.get_or_create( + year=year, month=month + ) + + # All clients (used for bottom tables etc.) + clients = Client.objects.all().order_by('name') + + # Pre-fill cells if creating new sheet + if created: + self.initialize_sheet_cells(sheet, clients) + + # Apply previous month links (for B4 and B12) + self.apply_previous_month_links(sheet, year, month) + self.calculate_bezug_from_entries(sheet, year, month) + self.populate_helium_input_to_top_right(sheet) + self.apply_previous_month_links_top_right(sheet, year, month) + + # Define client groups + TOP_LEFT_CLIENTS = [ + "AG Vogel", + "AG Halfm", + "IKP", + ] + + TOP_RIGHT_CLIENTS = [ + "Dr. Fohrer", + "AG Buntk.", + "AG Alff", + "AG Gutfl.", + "M3 Thiele", + "M3 Buntkowsky", + "M3 Gutfleisch", + ] + current_summary = MonthlySummary.objects.filter(sheet=sheet).first() + + # Get previous month summary (for Bottom Table 3: K46 = prev K44) + prev_month_info = self.get_prev_month(year, month) + prev_summary = None + if not is_start_sheet: + prev_sheet = MonthlySheet.objects.filter( + year=prev_month_info['year'], + month=prev_month_info['month'] + ).first() + if prev_sheet: + prev_summary = MonthlySummary.objects.filter(sheet=prev_sheet).first() + + context.update({ + # ... your existing context ... + 'current_summary': current_summary, + 'prev_summary': prev_summary, + }) + # Update row counts in build_group_rows function + # Update row counts in build_group_rows function + def build_group_rows(sheet, table_type, client_names): + """Build rows for display in monthly sheet.""" + from decimal import Decimal + from .models import Cell + MERGED_ROWS = {2, 3, 5, 6, 7, 9, 10, 12, 14, 15} + MERGED_PAIRS = [ + ("Dr. Fohrer", "AG Buntk."), + ("AG Alff", "AG Gutfl."), + ] + rows = [] + + # Determine row count + row_counts = { + "top_left": 16, + "top_right": 16, # rows 0–15 + "bottom_1": 10, + "bottom_2": 10, + "bottom_3": 10, + } + row_count = row_counts.get(table_type, 0) + + # Get all cells for this sheet and table + all_cells = ( + Cell.objects.filter( + sheet=sheet, + table_type=table_type, + ).select_related("client") + ) + + # Group cells by row index + cells_by_row = {} + for cell in all_cells: + if cell.row_index not in cells_by_row: + cells_by_row[cell.row_index] = {} + cells_by_row[cell.row_index][cell.client.name] = cell + + # We will store row sums for Bezug (row 8) and Verbraucherverluste (row 14) + # for top_left / top_right so we can compute the overall % in row 15. + sum_bezug = None + sum_verbrauch = None + + # Build each row + for row_idx in range(row_count): + display_cells = [] + row_cells_dict = cells_by_row.get(row_idx, {}) + + # Build cells in the requested client order + for name in client_names: + cell = row_cells_dict.get(name) + display_cells.append(cell) + + # Calculate sum for this row (includes editable + calculated cells) + sum_value = None + total = Decimal("0") + has_value = False + + merged_second_indices = set() + if table_type == 'top_right' and row_idx in MERGED_ROWS: + for left_name, right_name in MERGED_PAIRS: + try: + right_idx = client_names.index(right_name) + merged_second_indices.add(right_idx) + except ValueError: + # client not in this table; just ignore + pass + + for col_idx, cell in enumerate(display_cells): + # Skip the duplicate (second) column of each merged pair + if col_idx in merged_second_indices: + continue + + if cell and cell.value is not None: + try: + total += Decimal(str(cell.value)) + has_value = True + except Exception: + pass + + if has_value: + sum_value = total + + # Remember special rows for top tables + if table_type in ("top_left", "top_right"): + if row_idx == 8: # Bezug + sum_bezug = total + elif row_idx == 14: # Verbraucherverluste + sum_verbrauch = total + + rows.append( + { + "cells": display_cells, + "sum": sum_value, + "row_index": row_idx, + } + ) + + # Adjust the % row sum for top_left / top_right: + # Sum(%) = Sum(Verbraucherverluste) / Sum(Bezug) + if table_type in ("top_left", "top_right"): + perc_row_idx = 15 # % row + if 0 <= perc_row_idx < len(rows): + if sum_bezug is not None and sum_bezug != 0 and sum_verbrauch is not None: + rows[perc_row_idx]["sum"] = sum_verbrauch / sum_bezug + else: + rows[perc_row_idx]["sum"] = None + + return rows + + + # Now call the local function + top_left_rows = build_group_rows(sheet, 'top_left', TOP_LEFT_CLIENTS) + top_right_rows = build_group_rows(sheet, 'top_right', TOP_RIGHT_CLIENTS) + + # --- Build combined summary of top-left + top-right Sum columns --- + + # Helper to safely get the Sum for a given row index + def get_row_sum(rows, row_index): + if 0 <= row_index < len(rows): + return rows[row_index].get('sum') + return None + + # Row definitions we want in the combined Σ table + # (row_index in top tables, label shown in the small table) + summary_row_defs = [ + (2, "Rückführung flüssig (Lit. L-He)"), + (3, "Sonderrückführungen (Lit. L-He)"), + (4, "Sammelrückführungen (Lit. L-He)"), + (5, "Bestand in Kannen-1 (Lit. L-He)"), + (6, "Summe Bestand (Lit. L-He)"), + (7, "Best. in Kannen Vormonat (Lit. L-He)"), + (8, "Bezug (Liter L-He)"), + (9, "Rückführ. Soll (Lit. L-He)"), + (10, "Verluste (Soll-Rückf.) (Lit. L-He)"), + (11, "Füllungen warm (Lit. L-He)"), + (12, "Kaltgas Rückgabe (Lit. L-He) – Faktor"), + (13, "Faktor 0.06"), + (14, "Verbraucherverluste (Liter L-He)"), + (15, "%"), + ] + + # Precompute totals for Bezug and Verbraucherverluste across both tables + bezug_left = get_row_sum(top_left_rows, 8) or Decimal('0') + bezug_right = get_row_sum(top_right_rows, 8) or Decimal('0') + total_bezug = bezug_left + bezug_right + + verb_left = get_row_sum(top_left_rows, 14) or Decimal('0') + verb_right = get_row_sum(top_right_rows, 14) or Decimal('0') + total_verbrauch = verb_left + verb_right + + summary_rows = [] + + for row_index, label in summary_row_defs: + # Faktor row: always fixed 0.06 + if row_index == 13: + summary_value = Decimal('0.06') + + # % row: total Verbraucherverluste / total Bezug + elif row_index == 15: + if total_bezug != 0: + summary_value = total_verbrauch / total_bezug + else: + summary_value = None + + else: + left_sum = get_row_sum(top_left_rows, row_index) + right_sum = get_row_sum(top_right_rows, row_index) + + # Sammelrückführungen: only from top-right table + if row_index == 4: + left_sum = None + + total = Decimal('0') + + has_any = False + if left_sum is not None: + total += Decimal(str(left_sum)) + has_any = True + if right_sum is not None: + total += Decimal(str(right_sum)) + has_any = True + + summary_value = total if has_any else None + + summary_rows.append({ + 'row_index': row_index, + 'label': label, + 'sum': summary_value, + }) + + # Get cells for bottom tables + cells_by_table = self.get_cells_by_table(sheet) + + context.update({ + 'sheet': sheet, + 'clients': clients, + 'year': year, + 'month': month, + 'month_name': calendar.month_name[month], + 'prev_month': self.get_prev_month(year, month), + 'next_month': self.get_next_month(year, month), + 'cells_by_table': cells_by_table, + 'top_left_headers': TOP_LEFT_CLIENTS + ['Sum'], + 'top_right_headers': TOP_RIGHT_CLIENTS + ['Sum'], + 'top_left_rows': top_left_rows, + 'top_right_rows': top_right_rows, + 'summary_rows': summary_rows, # 👈 NEW + 'is_start_sheet': is_start_sheet, + }) + return context + + def get_cells_by_table(self, sheet): + """Organize cells by table type for easy template rendering""" + cells = sheet.cells.select_related('client').all() + organized = { + 'top_left': [[] for _ in range(16)], + 'top_right': [[] for _ in range(16)], # now 16 rows + 'bottom_1': [[] for _ in range(10)], + 'bottom_2': [[] for _ in range(10)], + 'bottom_3': [[] for _ in range(10)], + } + + for cell in cells: + if cell.table_type not in organized: + continue + + max_rows = len(organized[cell.table_type]) + if cell.row_index < 0 or cell.row_index >= max_rows: + # This is an "extra" cell from an older layout (e.g. top_left rows 18–23) + continue + + row_list = organized[cell.table_type][cell.row_index] + while len(row_list) <= cell.column_index: + row_list.append(None) + + row_list[cell.column_index] = cell + + return organized + + + def initialize_sheet_cells(self, sheet, clients): + """Create all empty cells for a new monthly sheet""" + cells_to_create = [] + summation_config = CALCULATION_CONFIG.get('summation_column', {}) + sum_column_index = summation_config.get('sum_column_index', len(clients) - 1) # Last column + + # For each table type and row + table_configs = [ + ('top_left', 16), + ('top_right', 16), + ('bottom_1', 10), + ('bottom_2', 10), + ('bottom_3', 10), + ] + + for table_type, row_count in table_configs: + for row_idx in range(row_count): + for col_idx, client in enumerate(clients): + is_summation = (col_idx == sum_column_index) + cells_to_create.append(Cell( + sheet=sheet, + client=client, + table_type=table_type, + row_index=row_idx, + column_index=col_idx, + value=None, + is_formula=is_summation, # Mark summation cells as formulas + )) + + # Bulk create all cells at once + Cell.objects.bulk_create(cells_to_create) + def get_prev_month(self, year, month): + """Get previous month year and month""" + if month == 1: + return {'year': year - 1, 'month': 12} + return {'year': year, 'month': month - 1} + + def get_next_month(self, year, month): + """Get next month year and month""" + if month == 12: + return {'year': year + 1, 'month': 1} + return {'year': year, 'month': month + 1} + def apply_previous_month_links(self, sheet, year, month): + """ + For non-start sheets: + B4 (row 2) = previous sheet B3 (row 1) + B10 (row 8) = previous sheet B9 (row 7) + """ + # Do nothing on the first sheet + if year == FIRST_SHEET_YEAR and month == FIRST_SHEET_MONTH: + return + + # Figure out previous month + if month == 1: + prev_year = year - 1 + prev_month = 12 + else: + prev_year = year + prev_month = month - 1 + + from .models import MonthlySheet, Cell, Client + + prev_sheet = MonthlySheet.objects.filter( + year=prev_year, + month=prev_month + ).first() + + if not prev_sheet: + # No previous sheet created yet → nothing to copy + return + + # For each client, copy values + for client in Client.objects.all(): + # B3(prev) → B4(curr): UI row 1 → row 2 → row_index 0 → 1 + prev_b3 = Cell.objects.filter( + sheet=prev_sheet, + table_type='top_left', + client=client, + row_index=0, # UI row 1 + ).first() + + curr_b4 = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client, + row_index=1, # UI row 2 + ).first() + + if prev_b3 and curr_b4: + curr_b4.value = prev_b3.value + curr_b4.save() + + # B9(prev) → B10(curr): UI row 7 → row 8 → row_index 6 → 7 + prev_b9 = Cell.objects.filter( + sheet=prev_sheet, + table_type='top_left', + client=client, + row_index=6, # UI row 7 (Summe Bestand) + ).first() + + curr_b10 = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client, + row_index=7, # UI row 8 (Best. in Kannen Vormonat) + ).first() + + if prev_b9 and curr_b10: + curr_b10.value = prev_b9.value + curr_b10.save() + + def apply_previous_month_links_top_right(self, sheet, year, month): + """ + top_right row 7: Best. in Kannen Vormonat (Lit. L-He) + = previous sheet's Summe Bestand (row 6). + + For merged pairs: + - Dr. Fohrer + AG Buntk. share the SAME value (from previous month's AG Buntk. or Dr. Fohrer) + - AG Alff + AG Gutfl. share the SAME value (from previous month's AG Alff or AG Gutfl.) + M3 clients just copy their own value. + """ + from .models import MonthlySheet, Cell, Client + from decimal import Decimal + + # Do nothing on first sheet + if year == FIRST_SHEET_YEAR and month == FIRST_SHEET_MONTH: + return + + # find previous month + if month == 1: + prev_year = year - 1 + prev_month = 12 + else: + prev_year = year + prev_month = month - 1 + + prev_sheet = MonthlySheet.objects.filter( + year=prev_year, + month=prev_month + ).first() + + if not prev_sheet: + return # nothing to copy from + + TOP_RIGHT_CLIENTS = [ + "Dr. Fohrer", + "AG Buntk.", + "AG Alff", + "AG Gutfl.", + "M3 Thiele", + "M3 Buntkowsky", + "M3 Gutfleisch", + ] + + # Helper function to get a cell value + def get_cell_value(sheet_obj, client_name, row_index): + client_obj = Client.objects.filter(name=client_name).first() + if not client_obj: + return None + + try: + col_idx = TOP_RIGHT_CLIENTS.index(client_name) + except ValueError: + return None + + cell = Cell.objects.filter( + sheet=sheet_obj, + table_type='top_right', + client=client_obj, + row_index=row_index, + column_index=col_idx, + ).first() + + return cell.value if cell else None + + # Helper function to set a cell value + def set_cell_value(sheet_obj, client_name, row_index, value): + client_obj = Client.objects.filter(name=client_name).first() + if not client_obj: + return False + + try: + col_idx = TOP_RIGHT_CLIENTS.index(client_name) + except ValueError: + return False + + cell, created = Cell.objects.get_or_create( + sheet=sheet_obj, + table_type='top_right', + client=client_obj, + row_index=row_index, + column_index=col_idx, + defaults={'value': value} + ) + + if not created and cell.value != value: + cell.value = value + cell.save() + elif created: + cell.save() + + return True + + # ----- Pair 1: Dr. Fohrer + AG Buntk. ----- + # Get previous month's Summe Bestand (row 6) for either client in the pair + pair1_prev_val = None + + # Try AG Buntk. first + prev_buntk_val = get_cell_value(prev_sheet, "AG Buntk.", 6) + if prev_buntk_val is not None: + pair1_prev_val = prev_buntk_val + else: + # Try Dr. Fohrer if AG Buntk. is empty + prev_fohrer_val = get_cell_value(prev_sheet, "Dr. Fohrer", 6) + if prev_fohrer_val is not None: + pair1_prev_val = prev_fohrer_val + + # Apply the value to both clients in the pair + if pair1_prev_val is not None: + set_cell_value(sheet, "Dr. Fohrer", 7, pair1_prev_val) + set_cell_value(sheet, "AG Buntk.", 7, pair1_prev_val) + + # ----- Pair 2: AG Alff + AG Gutfl. ----- + pair2_prev_val = None + + # Try AG Alff first + prev_alff_val = get_cell_value(prev_sheet, "AG Alff", 6) + if prev_alff_val is not None: + pair2_prev_val = prev_alff_val + else: + # Try AG Gutfl. if AG Alff is empty + prev_gutfl_val = get_cell_value(prev_sheet, "AG Gutfl.", 6) + if prev_gutfl_val is not None: + pair2_prev_val = prev_gutfl_val + + # Apply the value to both clients in the pair + if pair2_prev_val is not None: + set_cell_value(sheet, "AG Alff", 7, pair2_prev_val) + set_cell_value(sheet, "AG Gutfl.", 7, pair2_prev_val) + + # ----- M3 clients: copy their own Summe Bestand ----- + for name in ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"]: + prev_val = get_cell_value(prev_sheet, name, 6) + if prev_val is not None: + set_cell_value(sheet, name, 7, prev_val) +# Add this helper function to views.py +def get_factor_value(table_type, row_index): + """Get factor value (like 0.06 for top_left row 17)""" + factors = { + ('top_left', 17): Decimal('0.06'), # A18 in Excel (UI row 17, 0-based index 16) + } + return factors.get((table_type, row_index), Decimal('0')) +# Save Cells View +# views.py - Updated SaveCellsView +# views.py - Update SaveCellsView class +def debug_cell_values(self, sheet, client_id): + """Debug method to check cell values""" + from .models import Cell + cells = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client_id=client_id + ).order_by('row_index') + + debug_info = {} + for cell in cells: + debug_info[f"row_{cell.row_index}"] = { + 'value': str(cell.value) if cell.value else 'None', + 'ui_row': cell.row_index + 1, + 'excel_ref': f"B{cell.row_index + 3}" + } + + return debug_info +class DebugCalculationView(View): + """Debug view to test calculations directly""" + def get(self, request): + sheet_id = request.GET.get('sheet_id', 1) + client_name = request.GET.get('client', 'AG Vogel') + + try: + sheet = MonthlySheet.objects.get(id=sheet_id) + client = Client.objects.get(name=client_name) + + # Get SaveCellsView instance + save_view = SaveCellsView() + + # Create a dummy cell to trigger calculations + dummy_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client, + row_index=0 # B3 + ).first() + + if not dummy_cell: + return JsonResponse({'error': 'No cells found for this client'}) + + # Trigger calculation + updated = save_view.calculate_top_left_dependents(sheet, dummy_cell) + + # Get updated cell values + cells = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client + ).order_by('row_index') + + cell_data = [] + for cell in cells: + cell_data.append({ + 'row_index': cell.row_index, + 'ui_row': cell.row_index + 1, + 'excel_ref': f"B{cell.row_index + 3}", + 'value': str(cell.value) if cell.value else 'None', + 'description': self.get_row_description(cell.row_index) + }) + + return JsonResponse({ + 'sheet': f"{sheet.year}-{sheet.month:02d}", + 'client': client.name, + 'cells': cell_data, + 'updated_count': len(updated), + 'calculation': 'B5 = IF(B3>0; B3-B4; 0)' + }) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=400) + + def get_row_description(self, row_index): + """Get description for row index""" + descriptions = { + 0: "B3: Stand der Gaszähler (Nm³)", + 1: "B4: Stand der Gaszähler (Vormonat) (Nm³)", + 2: "B5: Gasrückführung (Nm³)", + 3: "B6: Rückführung flüssig (Lit. L-He)", + 4: "B7: Sonderrückführungen (Lit. L-He)", + 5: "B8: Bestand in Kannen-1 (Lit. L-He)", + 6: "B9: Summe Bestand (Lit. L-He)", + 7: "B10: Best. in Kannen Vormonat (Lit. L-He)", + 8: "B11: Bezug (Liter L-He)", + 9: "B12: Rückführ. Soll (Lit. L-He)", + 10: "B13: Verluste (Soll-Rückf.) (Lit. L-He)", + 11: "B14: Füllungen warm (Lit. L-He)", + 12: "B15: Kaltgas Rückgabe (Lit. L-He) – Faktor", + 13: "B16: Faktor", + 14: "B17: Verbraucherverluste (Liter L-He)", + 15: "B18: %" + } + return descriptions.get(row_index, f"Row {row_index}") +def recalculate_stand_der_gaszahler(self, sheet): + """Recalculate Stand der Gaszähler for all client pairs""" + from decimal import Decimal + + # For Dr. Fohrer and AG Buntk. (L & M columns) + try: + # Get Dr. Fohrer's bezug + dr_fohrer_client = Client.objects.get(name="Dr. Fohrer") + dr_fohrer_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=dr_fohrer_client, + row_index=9 # Row 9 = Bezug + ).first() + L13 = Decimal(str(dr_fohrer_cell.value)) if dr_fohrer_cell and dr_fohrer_cell.value else Decimal('0') + + # Get AG Buntk.'s bezug + ag_buntk_client = Client.objects.get(name="AG Buntk.") + ag_buntk_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_buntk_client, + row_index=9 + ).first() + M13 = Decimal(str(ag_buntk_cell.value)) if ag_buntk_cell and ag_buntk_cell.value else Decimal('0') + + total = L13 + M13 + if total > 0: + # Update Dr. Fohrer's row 0 + dr_fohrer_row0 = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=dr_fohrer_client, + row_index=0 + ).first() + if dr_fohrer_row0: + dr_fohrer_row0.value = L13 / total + dr_fohrer_row0.save() + + # Update AG Buntk.'s row 0 + ag_buntk_row0 = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_buntk_client, + row_index=0 + ).first() + if ag_buntk_row0: + ag_buntk_row0.value = M13 / total + ag_buntk_row0.save() + except Exception as e: + print(f"Error recalculating Stand der Gaszähler for Dr. Fohrer/AG Buntk.: {e}") + + # For AG Alff and AG Gutfl. (N & O columns) + try: + # Get AG Alff's bezug + ag_alff_client = Client.objects.get(name="AG Alff") + ag_alff_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_alff_client, + row_index=9 + ).first() + N13 = Decimal(str(ag_alff_cell.value)) if ag_alff_cell and ag_alff_cell.value else Decimal('0') + + # Get AG Gutfl.'s bezug + ag_gutfl_client = Client.objects.get(name="AG Gutfl.") + ag_gutfl_cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_gutfl_client, + row_index=9 + ).first() + O13 = Decimal(str(ag_gutfl_cell.value)) if ag_gutfl_cell and ag_gutfl_cell.value else Decimal('0') + + total = N13 + O13 + if total > 0: + # Update AG Alff's row 0 + ag_alff_row0 = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_alff_client, + row_index=0 + ).first() + if ag_alff_row0: + ag_alff_row0.value = N13 / total + ag_alff_row0.save() + + # Update AG Gutfl.'s row 0 + ag_gutfl_row0 = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=ag_gutfl_client, + row_index=0 + ).first() + if ag_gutfl_row0: + ag_gutfl_row0.value = O13 / total + ag_gutfl_row0.save() + except Exception as e: + print(f"Error recalculating Stand der Gaszähler for AG Alff/AG Gutfl.: {e}") +# In your SaveCellsView class in views.py +class DebugTopRightView(View): + """Debug view to check top_right calculations""" + def get(self, request): + sheet_id = request.GET.get('sheet_id', 1) + client_name = request.GET.get('client', 'Dr. Fohrer') + + try: + sheet = MonthlySheet.objects.get(id=sheet_id) + client = Client.objects.get(name=client_name) + + # Get all cells for this client in top_right + cells = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=client + ).order_by('row_index') + + cell_data = [] + descriptions = { + 0: "Stand der Gaszähler (Vormonat)", + 1: "Gasrückführung (Nm³)", + 2: "Rückführung flüssig", + 3: "Sonderrückführungen", + 4: "Sammelrückführungen", + 5: "Bestand in Kannen-1", + 6: "Summe Bestand", + 7: "Best. in Kannen Vormonat", + 8: "Same as row 9 from prev sheet", + 9: "Bezug", + 10: "Rückführ. Soll", + 11: "Verluste", + 12: "Füllungen warm", + 13: "Kaltgas Rückgabe", + 14: "Faktor 0.06", + 15: "Verbraucherverluste", + 16: "%" + } + + for cell in cells: + cell_data.append({ + 'row_index': cell.row_index, + 'ui_row': cell.row_index + 1, + 'description': descriptions.get(cell.row_index, f"Row {cell.row_index}"), + 'value': str(cell.value) if cell.value else 'Empty', + 'cell_id': cell.id + }) + + # Test calculation + row3_cell = cells.filter(row_index=3).first() + row5_cell = cells.filter(row_index=5).first() + row6_cell = cells.filter(row_index=6).first() + + calculation_info = { + 'row3_value': str(row3_cell.value) if row3_cell and row3_cell.value else '0', + 'row5_value': str(row5_cell.value) if row5_cell and row5_cell.value else '0', + 'row6_value': str(row6_cell.value) if row6_cell and row6_cell.value else '0', + 'expected_sum': '0' + } + + if row3_cell and row5_cell and row6_cell: + row3_val = Decimal(str(row3_cell.value)) if row3_cell.value else Decimal('0') + row5_val = Decimal(str(row5_cell.value)) if row5_cell.value else Decimal('0') + expected = row3_val + row5_val + calculation_info['expected_sum'] = str(expected) + calculation_info['is_correct'] = row6_cell.value == expected + + return JsonResponse({ + 'sheet': f"{sheet.year}-{sheet.month}", + 'client': client.name, + 'cells': cell_data, + 'calculation': calculation_info + }) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=400) + + +class SaveCellsView(View): + + def calculate_bottom_3_dependents(self, sheet): + updated_cells = [] + + def get_cell(row_idx, col_idx): + return Cell.objects.filter( + sheet=sheet, + table_type='bottom_3', + row_index=row_idx, + column_index=col_idx + ).first() + + def set_cell(row_idx, col_idx, value): + cell, created = Cell.objects.get_or_create( + sheet=sheet, + table_type='bottom_3', + row_index=row_idx, + column_index=col_idx, + defaults={'value': value} + ) + if not created and cell.value != value: + cell.value = value + cell.save() + elif created: + cell.save() + + updated_cells.append({ + 'id': cell.id, + 'value': str(cell.value) if cell.value is not None else '', + 'is_calculated': True, + }) + + def dec(x): + if x in (None, ''): + return Decimal('0') + try: + return Decimal(str(x)) + except Exception: + return Decimal('0') + + # ---- current summary ---- + cur_sum = MonthlySummary.objects.filter(sheet=sheet).first() + curr_k44 = dec(cur_sum.gesamtbestand_neu_lhe) if cur_sum else Decimal('0') + total_verb = dec(cur_sum.verbraucherverlust_lhe) if cur_sum else Decimal('0') + + # ---- previous month summary ---- + year, month = sheet.year, sheet.month + if month == 1: + prev_year, prev_month = year - 1, 12 + else: + prev_year, prev_month = year, month - 1 + + prev_sheet = MonthlySheet.objects.filter(year=prev_year, month=prev_month).first() + if prev_sheet: + prev_sum = MonthlySummary.objects.filter(sheet=prev_sheet).first() + prev_k44 = dec(prev_sum.gesamtbestand_neu_lhe) if prev_sum else Decimal('0') + else: + prev_k44 = Decimal('0') + + # ---- read editable inputs from bottom_3: F47,G47,I47,I50 ---- + def get_val(r, c): + cell = get_cell(r, c) + return dec(cell.value if cell else None) + + f47 = get_val(1, 0) + g47 = get_val(1, 1) + i47 = get_val(1, 2) + i50 = get_val(4, 2) + + # Now apply your formulas using prev_k44, curr_k44, total_verb, f47,g47,i47,i50 + # Row indices: 0..7 correspond to 46..53 + # col 3 = J, col 4 = K + + # Row 46 + k46 = prev_k44 + j46 = k46 * Decimal('0.75') + set_cell(0, 3, j46) + set_cell(0, 4, k46) + + # Row 47 + g47 = self._dec((get_cell(1, 1) or {}).value if get_cell(1, 1) else None) + i47 = self._dec((get_cell(1, 2) or {}).value if get_cell(1, 2) else None) + + j47 = g47 + i47 + k47 = (j47 / Decimal('0.75')) + g47 if j47 != 0 else g47 + + set_cell(1, 3, j47) + set_cell(1, 4, k47) + + # Row 48 + k48 = k46 + k47 + j48 = k48 * Decimal('0.75') + set_cell(2, 3, j48) + set_cell(2, 4, k48) + + # Row 49 + k49 = curr_k44 + j49 = k49 * Decimal('0.75') + set_cell(3, 3, j49) + set_cell(3, 4, k49) + + # Row 50 + j50 = i50 + k50 = j50 / Decimal('0.75') if j50 != 0 else Decimal('0') + set_cell(4, 3, j50) + set_cell(4, 4, k50) + + # Row 51 + k51 = k48 - k49 - k50 + j51 = k51 * Decimal('0.75') + set_cell(5, 3, j51) + set_cell(5, 4, k51) + + # Row 52 + k52 = total_verb + j52 = k52 * Decimal('0.75') + set_cell(6, 3, j52) + set_cell(6, 4, k52) + + # Row 53 + j53 = j51 - j52 + k53 = k51 - k52 + set_cell(7, 3, j53) + set_cell(7, 4, k53) + + return updated_cells + + + def _dec(self, value): + """Convert value to Decimal or return 0.""" + if value is None or value == '': + return Decimal('0') + try: + return Decimal(str(value)) + except Exception: + return Decimal('0') + + def post(self, request, *args, **kwargs): + """ + Handle AJAX saves from monthly_sheet.html + - Single-cell save: when cell_id is present (blur on one cell) + - Bulk save: when the 'Save All Cells' button is used (no cell_id) + """ + try: + sheet_id = request.POST.get('sheet_id') + if not sheet_id: + return JsonResponse({ + 'status': 'error', + 'message': 'Missing sheet_id' + }) + + sheet = MonthlySheet.objects.get(id=sheet_id) + + # -------- Single-cell update (blur) -------- + cell_id = request.POST.get('cell_id') + if cell_id: + value_raw = (request.POST.get('value') or '').strip() + + try: + cell = Cell.objects.get(id=cell_id, sheet=sheet) + except Cell.DoesNotExist: + return JsonResponse({ + 'status': 'error', + 'message': 'Cell not found' + }) + + # Convert value to Decimal or None + if value_raw == '': + new_value = None + else: + try: + # Allow comma or dot + value_clean = value_raw.replace(',', '.') + new_value = Decimal(value_clean) + except (InvalidOperation, ValueError): + # If conversion fails, treat as empty + new_value = None + + old_value = cell.value + cell.value = new_value + cell.save() + + updated_cells = [{ + 'id': cell.id, + 'value': '' if cell.value is None else str(cell.value), + 'is_calculated': cell.is_formula, # model field + }] + + # Recalculate dependents depending on table_type + if cell.table_type == 'top_left': + updated_cells.extend( + self.calculate_top_left_dependents(sheet, cell) + ) + elif cell.table_type == 'top_right': + updated_cells.extend( + self.calculate_top_right_dependents(sheet, cell) + ) + elif cell.table_type == 'bottom_1': + updated_cells.extend(self.calculate_bottom_1_dependents(sheet, cell)) + elif cell.table_type == 'bottom_3': + updated_cells.extend( + self.calculate_bottom_3_dependents(sheet) + ) + # bottom_1 / bottom_2 / bottom_3 currently have no formulas: + # they just save the new value. + updated_cells += self.calculate_bottom_3_dependents(sheet) + return JsonResponse({ + 'status': 'success', + 'updated_cells': updated_cells + }) + + + # -------- Bulk save (Save All button) -------- + return self.save_bulk_cells(request, sheet) + + except MonthlySheet.DoesNotExist: + return JsonResponse({ + 'status': 'error', + 'message': 'Sheet not found' + }) + except Exception as e: + # Generic safety net so the frontend sees an error message + return JsonResponse({ + 'status': 'error', + 'message': str(e) + }) + + # ... your other methods above ... + + # Update the calculate_top_right_dependents method in SaveCellsView class + + def calculate_top_right_dependents(self, sheet, changed_cell): + """ + Recalculate all dependent cells in the top-right table according to Excel formulas. + + Excel rows (4-20) -> 0-based indices (0-15) + Rows: + 0: Stand der Gaszähler (Vormonat) (Nm³) - shares for M3 clients + 1: Gasrückführung (Nm³) + 2: Rückführung flüssig (Lit. L-He) + 3: Sonderrückführungen (Lit. L-He) - editable + 4: Sammelrückführungen (Lit. L-He) - from helium_input groups + 5: Bestand in Kannen-1 (Lit. L-He) - editable, merged in pairs + 6: Summe Bestand (Lit. L-He) = row 5 + 7: Best. in Kannen Vormonat (Lit. L-He) - from previous month + 8: Bezug (Liter L-He) - from SecondTableEntry + 9: Rückführ. Soll (Lit. L-He) - calculated + 10: Verluste (Soll-Rückf.) (Lit. L-He) - calculated + 11: Füllungen warm (Lit. L-He) - from SecondTableEntry warm outputs + 12: Kaltgas Rückgabe (Lit. L-He) – Faktor - calculated + 13: Faktor 0.06 - fixed + 14: Verbraucherverluste (Liter L-He) - calculated + 15: % - calculated + """ + from decimal import Decimal + from django.db.models import Sum, Count + from django.db.models.functions import Coalesce + from .models import Client, Cell, ExcelEntry, SecondTableEntry + + TOP_RIGHT_CLIENTS = [ + "Dr. Fohrer", # L + "AG Buntk.", # M (merged with L) + "AG Alff", # N + "AG Gutfl.", # O (merged with N) + "M3 Thiele", # P + "M3 Buntkowsky", # Q + "M3 Gutfleisch", # R + ] + + # Define merged pairs + MERGED_PAIRS = [ + ("Dr. Fohrer", "AG Buntk."), # L and M are merged + ("AG Alff", "AG Gutfl."), # N and O are merged + ] + + # M3 clients (not merged, calculated individually) + M3_CLIENTS = ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"] + + # Groups for Sammelrückführungen (helium_input) + HELIUM_INPUT_GROUPS = { + "fohrer_buntk": ["Dr. Fohrer", "AG Buntk."], + "alff_gutfl": ["AG Alff", "AG Gutfl."], + "m3": ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"], + } + + year = sheet.year + month = sheet.month + factor = Decimal('0.06') # Fixed factor from Excel + updated_cells = [] + + # Helper functions + def get_val(client_name, row_idx): + """Get cell value for a client and row""" + try: + client = Client.objects.get(name=client_name) + col_idx = TOP_RIGHT_CLIENTS.index(client_name) + cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + client=client, + row_index=row_idx, + column_index=col_idx + ).first() + if cell and cell.value is not None: + return Decimal(str(cell.value)) + except (Client.DoesNotExist, ValueError, KeyError): + pass + return Decimal('0') + + def set_val(client_name, row_idx, value, is_calculated=True): + """Set cell value for a client and row""" + try: + client = Client.objects.get(name=client_name) + col_idx = TOP_RIGHT_CLIENTS.index(client_name) + + cell, created = Cell.objects.get_or_create( + sheet=sheet, + table_type='top_right', + client=client, + row_index=row_idx, + column_index=col_idx, + defaults={'value': value} + ) + + # Only update if value changed + if not created and cell.value != value: + cell.value = value + cell.save() + elif created: + cell.save() + + updated_cells.append({ + 'id': cell.id, + 'value': str(cell.value) if cell.value else '', + 'is_calculated': is_calculated + }) + + return True + except (Client.DoesNotExist, ValueError): + return False + + # 1. Update Summe Bestand (row 6) from Bestand in Kannen-1 (row 5) + # For merged pairs: copy value from changed cell to its pair + if changed_cell and changed_cell.table_type == 'top_right': + if changed_cell.row_index == 5: # Bestand in Kannen-1 + client_name = changed_cell.client.name + new_value = changed_cell.value + + # Check if this client is in a merged pair + for pair in MERGED_PAIRS: + if client_name in pair: + # Update both clients in the pair + for client_in_pair in pair: + if client_in_pair != client_name: + set_val(client_in_pair, 5, new_value, is_calculated=False) + break + + # 2. For all clients: Set Summe Bestand (row 6) = Bestand in Kannen-1 (row 5) + for client_name in TOP_RIGHT_CLIENTS: + bestand_value = get_val(client_name, 5) + set_val(client_name, 6, bestand_value, is_calculated=True) + + # 3. Update Sammelrückführungen (row 4) from helium_input groups + for group_name, client_names in HELIUM_INPUT_GROUPS.items(): + # Get total helium_input for this group + clients_in_group = Client.objects.filter(name__in=client_names) + total_helium = ExcelEntry.objects.filter( + client__in=clients_in_group, + date__year=year, + date__month=month + ).aggregate(total=Coalesce(Sum('lhe_ges'), Decimal('0')))['total'] + + # Set same value for all clients in group + for client_name in client_names: + set_val(client_name, 4, total_helium, is_calculated=True) + + # 4. Calculate Rückführung flüssig (row 2) + # For merged pairs: =L8 for Dr. Fohrer/AG Buntk., =N8 for AG Alff/AG Gutfl. + # For M3 clients: =$P$8 * P4, $P$8 * Q4, $P$8 * R4 + + # Get Sammelrückführungen values for groups + sammel_fohrer_buntk = get_val("Dr. Fohrer", 4) # L8 + sammel_alff_gutfl = get_val("AG Alff", 4) # N8 + sammel_m3_group = get_val("M3 Thiele", 4) # P8 (same for all M3) + + # For merged pairs + set_val("Dr. Fohrer", 2, sammel_fohrer_buntk, is_calculated=True) + set_val("AG Buntk.", 2, sammel_fohrer_buntk, is_calculated=True) + set_val("AG Alff", 2, sammel_alff_gutfl, is_calculated=True) + set_val("AG Gutfl.", 2, sammel_alff_gutfl, is_calculated=True) + + # For M3 clients: =$P$8 * column4 (Stand der Gaszähler) + for m3_client in M3_CLIENTS: + stand_value = get_val(m3_client, 0) # Stand der Gaszähler (row 0) + rueck_value = sammel_m3_group * stand_value + set_val(m3_client, 2, rueck_value, is_calculated=True) + + # 5. Calculate Füllungen warm (row 11) from SecondTableEntry warm outputs + # 5. Calculate Füllungen warm (row 11) as NUMBER of warm fillings + # (sum of 1s where each warm SecondTableEntry is one filling) + for client_name in TOP_RIGHT_CLIENTS: + client = Client.objects.get(name=client_name) + warm_count = SecondTableEntry.objects.filter( + client=client, + date__year=year, + date__month=month, + is_warm=True + ).aggregate( + total=Coalesce(Count('id'), 0) + )['total'] + + # store as Decimal so later formulas (warm * 15) still work nicely + warm_value = Decimal(warm_count) + set_val(client_name, 11, warm_value, is_calculated=True) + # 6. Set Faktor row (13) to 0.06 + for client_name in TOP_RIGHT_CLIENTS: + set_val(client_name, 13, factor, is_calculated=True) + + # 6a. Recalculate Stand der Gaszähler (row 0) for the merged pairs + # according to Excel: + # L4 = L13 / (L13 + M13), M4 = M13 / (L13 + M13) + # N4 = N13 / (N13 + O13), O4 = O13 / (N13 + O13) + + # Pair 1: Dr. Fohrer / AG Buntk. + bezug_dr = get_val("Dr. Fohrer", 8) # L13 + bezug_buntk = get_val("AG Buntk.", 8) # M13 + total_pair1 = bezug_dr + bezug_buntk + + if total_pair1 != 0: + set_val("Dr. Fohrer", 0, bezug_dr / total_pair1, is_calculated=True) + set_val("AG Buntk.", 0, bezug_buntk / total_pair1, is_calculated=True) + else: + # if no Bezug, both shares are 0 + set_val("Dr. Fohrer", 0, Decimal('0'), is_calculated=True) + set_val("AG Buntk.", 0, Decimal('0'), is_calculated=True) + + # Pair 2: AG Alff / AG Gutfl. + bezug_alff = get_val("AG Alff", 8) # N13 + bezug_gutfl = get_val("AG Gutfl.", 8) # O13 + total_pair2 = bezug_alff + bezug_gutfl + + if total_pair2 != 0: + set_val("AG Alff", 0, bezug_alff / total_pair2, is_calculated=True) + set_val("AG Gutfl.", 0, bezug_gutfl / total_pair2, is_calculated=True) + else: + set_val("AG Alff", 0, Decimal('0'), is_calculated=True) + set_val("AG Gutfl.", 0, Decimal('0'), is_calculated=True) + + + # 7. Calculate all other dependent rows for merged pairs + for pair in MERGED_PAIRS: + client1, client2 = pair + + # Get values for the pair + bezug1 = get_val(client1, 8) # Bezug client1 + bezug2 = get_val(client2, 8) # Bezug client2 + total_bezug = bezug1 + bezug2 # L13+M13 or N13+O13 + + summe_bestand = get_val(client1, 6) # L11 or N11 (merged, same value) + best_vormonat = get_val(client1, 7) # L12 or N12 (merged, same value) + rueck_fl = get_val(client1, 2) # L6 or N6 (merged, same value) + warm1 = get_val(client1, 11) # L16 or N16 + warm2 = get_val(client2, 11) # M16 or O16 + total_warm = warm1 + warm2 # L16+M16 or N16+O16 + + # Calculate Rückführ. Soll (row 9) + # = L13+M13 - L11 + L12 for first pair + # = N13+O13 - N11 + N12 for second pair + rueck_soll = total_bezug - summe_bestand + best_vormonat + set_val(client1, 9, rueck_soll, is_calculated=True) + set_val(client2, 9, rueck_soll, is_calculated=True) + + # Calculate Verluste (row 10) = Rückführ. Soll - Rückführung flüssig + verluste = rueck_soll - rueck_fl + set_val(client1, 10, verluste, is_calculated=True) + set_val(client2, 10, verluste, is_calculated=True) + + # Calculate Kaltgas Rückgabe (row 12) + # = (L13+M13)*$A18 + (L16+M16)*15 + kaltgas = (total_bezug * factor) + (total_warm * Decimal('15')) + set_val(client1, 12, kaltgas, is_calculated=True) + set_val(client2, 12, kaltgas, is_calculated=True) + + # Calculate Verbraucherverluste (row 14) = Verluste - Kaltgas + verbrauch = verluste - kaltgas + set_val(client1, 14, verbrauch, is_calculated=True) + set_val(client2, 14, verbrauch, is_calculated=True) + + # Calculate % (row 15) = Verbraucherverluste / (L13+M13) + if total_bezug != 0: + prozent = verbrauch / total_bezug + else: + prozent = Decimal('0') + set_val(client1, 15, prozent, is_calculated=True) + set_val(client2, 15, prozent, is_calculated=True) + + # 8. Calculate all dependent rows for M3 clients (individual calculations) + for m3_client in M3_CLIENTS: + # Get individual values + bezug = get_val(m3_client, 8) # Bezug for this M3 client + summe_bestand = get_val(m3_client, 6) # Summe Bestand + best_vormonat = get_val(m3_client, 7) # Best. in Kannen Vormonat + rueck_fl = get_val(m3_client, 2) # Rückführung flüssig + warm = get_val(m3_client, 11) # Füllungen warm + + # Calculate Rückführ. Soll (row 9) = Bezug - Summe Bestand + Best. Vormonat + rueck_soll = bezug - summe_bestand + best_vormonat + set_val(m3_client, 9, rueck_soll, is_calculated=True) + + # Calculate Verluste (row 10) = Rückführ. Soll - Rückführung flüssig + verluste = rueck_soll - rueck_fl + set_val(m3_client, 10, verluste, is_calculated=True) + + # Calculate Kaltgas Rückgabe (row 12) = Bezug * factor + warm * 15 + kaltgas = (bezug * factor) + (warm * Decimal('15')) + set_val(m3_client, 12, kaltgas, is_calculated=True) + + # Calculate Verbraucherverluste (row 14) = Verluste - Kaltgas + verbrauch = verluste - kaltgas + set_val(m3_client, 14, verbrauch, is_calculated=True) + + # Calculate % (row 15) = Verbraucherverluste / Bezug + if bezug != 0: + prozent = verbrauch / bezug + else: + prozent = Decimal('0') + set_val(m3_client, 15, prozent, is_calculated=True) + + return updated_cells + + + + def calculate_bottom_1_dependents(self, sheet, changed_cell): + """ + Recalculate Bottom Table 1 (table_type='bottom_1'). + + Layout (row_index 0–9, col_index 0–4): + + Rows 0–8: + 0: Batterie 1 + 1: 2 + 2: 3 + 3: 4 + 4: 5 + 5: 6 + 6: 2 Bündel + 7: 2 Ballone + 8: Reingasspeicher + + Row 9: + 9: Gasbestand (totals row) + + Columns: + 0: Volumen (fixed values, not editable) + 1: bar (editable for rows 0–8) + 2: korrigiert = bar / (bar/2000 + 1) + 3: Nm³ = Volumen * korrigiert + 4: Lit. LHe = Nm³ / 0.75 + + Row 9: + - Volumen (col 0): empty + - bar (col 1): empty + - korrigiert (col 2): empty + - Nm³ (col 3): SUM Nm³ rows 0–8 + - Lit. LHe (col 4): SUM Lit. LHe rows 0–8 + """ + from decimal import Decimal, InvalidOperation + from .models import Cell + + updated_cells = [] + + DATA_ROWS = list(range(0, 9)) # 0–8 + TOTAL_ROW = 9 + + COL_VOL = 0 + COL_BAR = 1 + COL_KORR = 2 + COL_NM3 = 3 + COL_LHE = 4 + + # Fixed Volumen values for rows 0–8 + VOLUMES = [ + Decimal("2.4"), # row 0 + Decimal("5.1"), # row 1 + Decimal("4.0"), # row 2 + Decimal("1.0"), # row 3 + Decimal("4.0"), # row 4 + Decimal("0.4"), # row 5 + Decimal("1.2"), # row 6 + Decimal("20.0"), # row 7 + Decimal("5.0"), # row 8 + ] + + def get_cell(row_idx, col_idx): + return Cell.objects.filter( + sheet=sheet, + table_type="bottom_1", + row_index=row_idx, + column_index=col_idx, + ).first() + + def set_calc(row_idx, col_idx, value): + """ + Set a calculated cell and add it to updated_cells. + If value is None, we clear the cell. + """ + cell = get_cell(row_idx, col_idx) + if not cell: + cell = Cell( + sheet=sheet, + table_type="bottom_1", + row_index=row_idx, + column_index=col_idx, + value=value, + ) + else: + cell.value = value + cell.save() + + updated_cells.append( + { + "id": cell.id, + "value": "" if cell.value is None else str(cell.value), + "is_calculated": True, + } + ) + + # ---------- Rows 0–8: per-gasspeicher calculations ---------- + for row_idx in DATA_ROWS: + bar_cell = get_cell(row_idx, COL_BAR) + + # Volumen: fixed constant or, if present, value from DB + vol = VOLUMES[row_idx] + vol_cell = get_cell(row_idx, COL_VOL) + if vol_cell and vol_cell.value is not None: + try: + vol = Decimal(str(vol_cell.value)) + except (InvalidOperation, ValueError): + # fall back to fixed constant + vol = VOLUMES[row_idx] + + bar = None + try: + if bar_cell and bar_cell.value is not None: + bar = Decimal(str(bar_cell.value)) + except (InvalidOperation, ValueError): + bar = None + + # korrigiert = bar / (bar/2000 + 1) + if bar is not None and bar != 0: + korr = bar / (bar / Decimal("2000") + Decimal("1")) + else: + korr = None + + # Nm³ = Volumen * korrigiert + if korr is not None: + nm3 = vol * korr + else: + nm3 = None + + # Lit. LHe = Nm³ / 0.75 + if nm3 is not None: + lit_lhe = nm3 / Decimal("0.75") + else: + lit_lhe = None + + # Write calculated cells back (NOT Volumen or bar) + set_calc(row_idx, COL_KORR, korr) + set_calc(row_idx, COL_NM3, nm3) + set_calc(row_idx, COL_LHE, lit_lhe) + + # ---------- Row 9: totals (Gasbestand) ---------- + total_nm3 = Decimal("0") + total_lhe = Decimal("0") + has_nm3 = False + has_lhe = False + + for row_idx in DATA_ROWS: + nm3_cell = get_cell(row_idx, COL_NM3) + if nm3_cell and nm3_cell.value is not None: + try: + total_nm3 += Decimal(str(nm3_cell.value)) + has_nm3 = True + except (InvalidOperation, ValueError): + pass + + lhe_cell = get_cell(row_idx, COL_LHE) + if lhe_cell and lhe_cell.value is not None: + try: + total_lhe += Decimal(str(lhe_cell.value)) + has_lhe = True + except (InvalidOperation, ValueError): + pass + + # Volumen (0), bar (1), korrigiert (2) on total row stay empty + set_calc(TOTAL_ROW, COL_KORR, None) # explicitly clear korrigiert + set_calc(TOTAL_ROW, COL_NM3, total_nm3 if has_nm3 else None) + set_calc(TOTAL_ROW, COL_LHE, total_lhe if has_lhe else None) + + return updated_cells + + + + + + def calculate_top_left_dependents(self, sheet, changed_cell): + """Calculate dependent cells in top_left table""" + from decimal import Decimal + from django.db.models import Sum + from django.db.models.functions import Coalesce + + client_id = changed_cell.client_id + updated_cells = [] + + # Get all cells for this client in top_left table + client_cells = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client_id=client_id + ) + + # Create a dict for easy access + cell_dict = {} + for cell in client_cells: + cell_dict[cell.row_index] = cell + + # Get values with safe defaults + def get_cell_value(row_idx): + cell = cell_dict.get(row_idx) + if cell and cell.value is not None: + try: + return Decimal(str(cell.value)) + except: + return Decimal('0') + return Decimal('0') + + # 1. B5 = IF(B3>0; B3-B4; 0) - Gasrückführung + b3 = get_cell_value(0) # row_index 0 = Excel B3 (UI row 1) + b4 = get_cell_value(1) # row_index 1 = Excel B4 (UI row 2) + + # Calculate B5 + if b3 > 0: + b5_value = b3 - b4 + if b5_value < 0: + b5_value = Decimal('0') + else: + b5_value = Decimal('0') + + # Update B5 - FORCE update even if value appears the same + b5_cell = cell_dict.get(2) # row_index 2 = Excel B5 (UI row 3) + if b5_cell: + # Always update B5 when B3 or B4 changes + b5_cell.value = b5_value + b5_cell.save() + updated_cells.append({ + 'id': b5_cell.id, + 'value': str(b5_cell.value), + 'is_calculated': True + }) + + # 2. B6 = B5 / 0.75 - Rückführung flüssig + b6_value = b5_value / Decimal('0.75') + b6_cell = cell_dict.get(3) # row_index 3 = Excel B6 (UI row 4) + if b6_cell: + b6_cell.value = b6_value + b6_cell.save() + updated_cells.append({ + 'id': b6_cell.id, + 'value': str(b6_cell.value), + 'is_calculated': True + }) + + # 3. B9 = B7 + B8 - Summe Bestand + b7 = get_cell_value(4) # row_index 4 = Excel B7 (UI row 5) + b8 = get_cell_value(5) # row_index 5 = Excel B8 (UI row 6) + b9_value = b7 + b8 + b9_cell = cell_dict.get(6) # row_index 6 = Excel B9 (UI row 7) + if b9_cell: + b9_cell.value = b9_value + b9_cell.save() + updated_cells.append({ + 'id': b9_cell.id, + 'value': str(b9_cell.value), + 'is_calculated': True + }) + + # 4. B11 = Sum of LHe Output from SecondTableEntry for this client/month - Bezug + from .models import SecondTableEntry + client = changed_cell.client + + # Calculate total LHe output for this client in this month + # 4. B11 = Bezug (Liter L-He) + # For start sheet: manual entry, for other sheets: auto-calculated from SecondTableEntry + from .models import SecondTableEntry + client = changed_cell.client + + b11_cell = cell_dict.get(8) # row_index 8 = Excel B11 (UI row 9) + + # Check if this is the start sheet (2025-01) + is_start_sheet = (sheet.year == 2025 and sheet.month == 1) + + # Get the B11 value for calculations + b11_value = Decimal('0') + + if is_start_sheet: + # For start sheet, keep whatever value is already there (manual entry) + if b11_cell and b11_cell.value is not None: + b11_value = Decimal(str(b11_cell.value)) + else: + # For non-start sheets: auto-calculate from SecondTableEntry + lhe_output_sum = SecondTableEntry.objects.filter( + client=client, + date__year=sheet.year, + date__month=sheet.month + ).aggregate( + total=Coalesce(Sum('lhe_output'), Decimal('0')) + )['total'] + + b11_value = lhe_output_sum + + if b11_cell and (b11_cell.value != b11_value or b11_cell.value is None): + b11_cell.value = b11_value + b11_cell.save() + updated_cells.append({ + 'id': b11_cell.id, + 'value': str(b11_cell.value), + 'is_calculated': True # Calculated from SecondTableEntry + }) + + # 5. B12 = B11 + B10 - B9 - Rückführ. Soll + b10 = get_cell_value(7) # row_index 7 = Excel B10 (UI row 8) + b12_value = b11_value + b10 - b9_value # Use b11_value instead of lhe_output_sum + b12_cell = cell_dict.get(9) # row_index 9 = Excel B12 (UI row 10) + if b12_cell: + b12_cell.value = b12_value + b12_cell.save() + updated_cells.append({ + 'id': b12_cell.id, + 'value': str(b12_cell.value), + 'is_calculated': True + }) + + # 6. B13 = B12 - B6 - Verluste (Soll-Rückf.) + b13_value = b12_value - b6_value + b13_cell = cell_dict.get(10) # row_index 10 = Excel B13 (UI row 11) + if b13_cell: + b13_cell.value = b13_value + b13_cell.save() + updated_cells.append({ + 'id': b13_cell.id, + 'value': str(b13_cell.value), + 'is_calculated': True + }) + + # 7. B14 = Count of warm outputs + warm_count = SecondTableEntry.objects.filter( + client=client, + date__year=sheet.year, + date__month=sheet.month, + is_warm=True + ).count() + + b14_cell = cell_dict.get(11) # row_index 11 = Excel B14 (UI row 12) + if b14_cell: + b14_cell.value = Decimal(str(warm_count)) + b14_cell.save() + updated_cells.append({ + 'id': b14_cell.id, + 'value': str(b14_cell.value), + 'is_calculated': True + }) + + # 8. B15 = IF(B11>0; B11 * factor + B14 * 15; 0) - Kaltgas Rückgabe + factor = get_cell_value(13) # row_index 13 = Excel B16 (Faktor) (UI row 14) + if factor == 0: + factor = Decimal('0.06') # default factor + + if b11_value > 0: # Use b11_value + b15_value = b11_value * factor + Decimal(str(warm_count)) * Decimal('15') + else: + b15_value = Decimal('0') + + b15_cell = cell_dict.get(12) # row_index 12 = Excel B15 (UI row 13) + if b15_cell: + b15_cell.value = b15_value + b15_cell.save() + updated_cells.append({ + 'id': b15_cell.id, + 'value': str(b15_cell.value), + 'is_calculated': True + }) + + # 9. B17 = B13 - B15 - Verbraucherverluste + b17_value = b13_value - b15_value + b17_cell = cell_dict.get(14) # row_index 14 = Excel B17 (UI row 15) + if b17_cell: + b17_cell.value = b17_value + b17_cell.save() + updated_cells.append({ + 'id': b17_cell.id, + 'value': str(b17_cell.value), + 'is_calculated': True + }) + + # 10. B18 = IF(B11=0; 0; B17/B11) - % + if b11_value == 0: # Use b11_value + b18_value = Decimal('0') + else: + b18_value = b17_value / b11_value # Use b11_value + + b18_cell = cell_dict.get(15) # row_index 15 = Excel B18 (UI row 16) + if b18_cell: + b18_cell.value = b18_value + b18_cell.save() + updated_cells.append({ + 'id': b18_cell.id, + 'value': str(b18_cell.value), + 'is_calculated': True + }) + + return updated_cells + def save_bulk_cells(self, request, sheet): + """Original bulk save logic (for backward compatibility)""" + # Get all cell updates + cell_updates = {} + for key, value in request.POST.items(): + if key.startswith('cell_'): + cell_id = key.replace('cell_', '') + cell_updates[cell_id] = value + + # Update cells and track which ones changed + updated_cells = [] + changed_clients = set() + + for cell_id, new_value in cell_updates.items(): + try: + cell = Cell.objects.get(id=cell_id, sheet=sheet) + old_value = cell.value + + # Convert new value + try: + if new_value.strip(): + cell.value = Decimal(new_value) + else: + cell.value = None + except Exception: + cell.value = None + + # Only save if value changed + if cell.value != old_value: + cell.save() + updated_cells.append({ + 'id': cell.id, + 'value': str(cell.value) if cell.value else '' + }) + # bottom_3 has no client, so this will just add None for those cells, + # which is harmless. Top-left cells still add their real client_id. + changed_clients.add(cell.client_id) + + except Cell.DoesNotExist: + continue + + # Recalculate for each changed client (top-left tables) + for client_id in changed_clients: + if client_id is not None: + self.recalculate_top_left_table(sheet, client_id) + + # --- NEW: recalc bottom_3 for the whole sheet, independent of clients --- + bottom3_updates = self.calculate_bottom_3_dependents(sheet) + + # Get all updated cells for response (top-left) + all_updated_cells = [] + for client_id in changed_clients: + if client_id is None: + continue # skip bottom_3 / non-client cells + client_cells = Cell.objects.filter( + sheet=sheet, + client_id=client_id + ) + for cell in client_cells: + all_updated_cells.append({ + 'id': cell.id, + 'value': str(cell.value) if cell.value else '', + 'is_calculated': cell.is_formula + }) + + # Add bottom_3 recalculated cells (J46..K53, etc.) + all_updated_cells.extend(bottom3_updates) + + return JsonResponse({ + 'status': 'success', + 'message': f'Saved {len(updated_cells)} cells', + 'updated_cells': all_updated_cells + }) + + + def recalculate_top_left_table(self, sheet, client_id): + """Recalculate the top-left table for a specific client""" + from decimal import Decimal + + # Get all cells for this client in top_left table + cells = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client_id=client_id + ).order_by('row_index') + + # Create a dictionary of cell values + cell_dict = {} + for cell in cells: + cell_dict[cell.row_index] = cell + + # Excel logic implementation for top-left table + # B3 (row_index 0) = Stand der Gaszähler (Nm³) - manual + # B4 (row_index 1) = Stand der Gaszähler (Vormonat) (Nm³) - from previous sheet + + # Get B3 and B4 + b3_cell = cell_dict.get(0) # UI Row 3 + b4_cell = cell_dict.get(1) # UI Row 4 + + if b3_cell and b3_cell.value and b4_cell and b4_cell.value: + # B5 = IF(B3>0; B3-B4; 0) + b3 = Decimal(str(b3_cell.value)) + b4 = Decimal(str(b4_cell.value)) + + if b3 > 0: + b5 = b3 - b4 + if b5 < 0: + b5 = Decimal('0') + else: + b5 = Decimal('0') + + # Update B5 (row_index 2) + b5_cell = cell_dict.get(2) + if b5_cell and (b5_cell.value != b5 or b5_cell.value is None): + b5_cell.value = b5 + b5_cell.save() + + # B6 = B5 / 0.75 (row_index 3) + b6 = b5 / Decimal('0.75') + b6_cell = cell_dict.get(3) + if b6_cell and (b6_cell.value != b6 or b6_cell.value is None): + b6_cell.value = b6 + b6_cell.save() + + # Get previous month's sheet for B10 + if sheet.month == 1: + prev_year = sheet.year - 1 + prev_month = 12 + else: + prev_year = sheet.year + prev_month = sheet.month - 1 + + prev_sheet = MonthlySheet.objects.filter( + year=prev_year, + month=prev_month + ).first() + + if prev_sheet: + # Get B9 from previous sheet (row_index 7 in previous) + prev_b9 = Cell.objects.filter( + sheet=prev_sheet, + table_type='top_left', + client_id=client_id, + row_index=7 # UI Row 9 + ).first() + + if prev_b9 and prev_b9.value: + # Update B10 in current sheet (row_index 8) + b10_cell = cell_dict.get(8) + if b10_cell and (b10_cell.value != prev_b9.value or b10_cell.value is None): + b10_cell.value = prev_b9.value + b10_cell.save() +@method_decorator(csrf_exempt, name='dispatch') +class SaveMonthSummaryView(View): + """ + Saves per-month summary values such as K44 (Gesamtbestand neu). + Called from JS after 'Save All' finishes. + """ + + def post(self, request, *args, **kwargs): + try: + data = json.loads(request.body.decode('utf-8')) + except json.JSONDecodeError: + return JsonResponse( + {'success': False, 'message': 'Invalid JSON'}, + status=400 + ) + + sheet_id = data.get('sheet_id') + if not sheet_id: + return JsonResponse( + {'success': False, 'message': 'Missing sheet_id'}, + status=400 + ) + + try: + sheet = MonthlySheet.objects.get(id=sheet_id) + except MonthlySheet.DoesNotExist: + return JsonResponse( + {'success': False, 'message': 'Sheet not found'}, + status=404 + ) + + # More tolerant decimal conversion: accepts "123.45" and "123,45" + def to_decimal(value): + if value is None: + return None + s = str(value).strip() + if s == '': + return None + s = s.replace(',', '.') + try: + return Decimal(s) + except (InvalidOperation, ValueError): + # Debug: show what failed in the dev server console + print("SaveMonthSummaryView to_decimal failed for:", repr(value)) + return None + + raw_k44 = data.get('gesamtbestand_neu_lhe') + raw_gas = data.get('gasbestand_lhe') + raw_verb = data.get('verbraucherverlust_lhe') + + gesamtbestand_neu_lhe = to_decimal(raw_k44) + gasbestand_lhe = to_decimal(raw_gas) + verbraucherverlust_lhe = to_decimal(raw_verb) + + summary, created = MonthlySummary.objects.get_or_create(sheet=sheet) + + if gesamtbestand_neu_lhe is not None: + summary.gesamtbestand_neu_lhe = gesamtbestand_neu_lhe + if gasbestand_lhe is not None: + summary.gasbestand_lhe = gasbestand_lhe + if verbraucherverlust_lhe is not None: + summary.verbraucherverlust_lhe = verbraucherverlust_lhe + + summary.save() + + # Small debug output so you can see in the server console what was saved + print( + f"Saved MonthlySummary for {sheet.year}-{sheet.month:02d}: " + f"K44={summary.gesamtbestand_neu_lhe}, " + f"Gasbestand={summary.gasbestand_lhe}, " + f"Verbraucherverlust={summary.verbraucherverlust_lhe}" + ) + + return JsonResponse({'success': True}) + + + +# Calculate View (placeholder for calculations) +class CalculateView(View): + def post(self, request): + # This will be implemented when you provide calculation rules + return JsonResponse({ + 'status': 'success', + 'message': 'Calculation endpoint ready' + }) + +# Summary Sheet View +class SummarySheetView(TemplateView): + template_name = 'summary_sheet.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + start_month = int(self.kwargs.get('start_month', 1)) + year = int(self.kwargs.get('year', datetime.now().year)) + + # Get 6 monthly sheets + months = [(year, m) for m in range(start_month, start_month + 6)] + sheets = MonthlySheet.objects.filter( + year=year, + month__in=list(range(start_month, start_month + 6)) + ).order_by('month') + + # Aggregate data across months + summary_data = self.calculate_summary(sheets) + + context.update({ + 'year': year, + 'start_month': start_month, + 'end_month': start_month + 5, + 'sheets': sheets, + 'clients': Client.objects.all(), + 'summary_data': summary_data, + }) + return context + + def calculate_summary(self, sheets): + """Calculate totals across 6 months""" + summary = {} + + for client in Client.objects.all(): + client_total = 0 + for sheet in sheets: + # Get specific cells and sum + cells = sheet.cells.filter( + client=client, + table_type='top_left', + row_index=0 # Example: first row + ) + for cell in cells: + if cell.value: + try: + client_total += float(cell.value) + except (ValueError, TypeError): + continue + + summary[client.id] = client_total + + return summary + +# Existing views below (keep all your existing functions) +def clients_list(request): + # --- Clients for the yearly summary table --- + clients = Client.objects.all() + + # --- Available years for output data (same as before) --- + available_years_qs = SecondTableEntry.objects.dates('date', 'year', order='DESC') + available_years = [y.year for y in available_years_qs] + + # === 1) Year used for the "Helium Output Yearly Summary" table === + # Uses ?year=... in the dropdown + year_param = request.GET.get('year') + if year_param: + selected_year = int(year_param) + else: + selected_year = available_years[0] if available_years else datetime.now().year + + # === 2) GLOBAL half-year interval (shared with other pages) ======= + # Try GET params first + interval_year_param = request.GET.get('interval_year') + start_month_param = request.GET.get('interval_start_month') + + # Fallbacks from session + session_year = request.session.get('halfyear_year') + session_start_month = request.session.get('halfyear_start_month') + + # Determine final interval_year + if interval_year_param: + interval_year = int(interval_year_param) + elif session_year: + interval_year = int(session_year) + else: + # default: same as selected_year for summary + interval_year = selected_year + + # Determine final interval_start_month + if start_month_param: + interval_start_month = int(start_month_param) + elif session_start_month: + interval_start_month = int(session_start_month) + else: + interval_start_month = 1 # default Jan + + # Store back into the session so other views can read them + request.session['halfyear_year'] = interval_year + request.session['halfyear_start_month'] = interval_start_month + + # === 3) Build a 6-month window, allowing wrap into the next year === + # Example: interval_year=2025, interval_start_month=12 + # window = [(2025,12), (2026,1), (2026,2), (2026,3), (2026,4), (2026,5)] + window = [] + for offset in range(6): + total_index = (interval_start_month - 1) + offset # 0-based index + y = interval_year + (total_index // 12) + m = (total_index % 12) + 1 + window.append((y, m)) + + # === 4) Totals per client in that 6-month window ================== + monthly_data = [] + for client in clients: + monthly_totals = [] + for (y, m) in window: + total = SecondTableEntry.objects.filter( + client=client, + date__year=y, + date__month=m + ).aggregate( + total=Coalesce( + Sum('lhe_output'), + Value(0, output_field=DecimalField()) + ) + )['total'] + monthly_totals.append(total) + + monthly_data.append({ + 'client': client, + 'monthly_totals': monthly_totals, + 'year_total': sum(monthly_totals), + }) + + # === 5) Month labels for the header (only the 6-month window) ===== + month_labels = [calendar.month_abbr[m] for (y, m) in window] + + # === 6) FINALLY: return the response ============================== + return render(request, 'clients_table.html', { + 'available_years': available_years, + 'current_year': selected_year, # used by year dropdown + 'interval_year': interval_year, # used by "Global 6-Month Interval" form + 'interval_start_month': interval_start_month, + 'months': month_labels, + 'monthly_data': monthly_data, + }) + + # === 5) Month labels for the header (only the 6-month window) ===== + month_labels = [calendar.month_abbr[m] for (y, m) in window] + + +def set_halfyear_interval(request): + if request.method == 'POST': + year = int(request.POST.get('year')) + start_month = int(request.POST.get('start_month')) + + request.session['halfyear_year'] = year + request.session['halfyear_start_month'] = start_month + + return redirect(request.META.get('HTTP_REFERER', 'clients_list')) + + return redirect('clients_list') +# Table One View (ExcelEntry) +def table_one_view(request): + from .models import ExcelEntry, Client, Institute + + # --- Base queryset for the main Helium Input table --- + base_entries = ExcelEntry.objects.all().select_related('client', 'client__institute') + + # Read the global 6-month interval from the session + interval_year = request.session.get('halfyear_year') + interval_start = request.session.get('halfyear_start_month') + + if interval_year and interval_start: + interval_year = int(interval_year) + interval_start = int(interval_start) + + # Build the same 6-month window as on the main page (can cross year) + window = [] + for offset in range(6): + total_index = (interval_start - 1) + offset # 0-based + y = interval_year + (total_index // 12) + m = (total_index % 12) + 1 + window.append((y, m)) + + # Build Q filter: (year=m_year AND month=m_month) for any of those 6 + q = Q() + for (y, m) in window: + q |= Q(date__year=y, date__month=m) + + entries_table1 = base_entries.filter(q).order_by('-date') + else: + # Fallback: if no global interval yet, show everything + entries_table1 = base_entries.order_by('-date') + clients = Client.objects.all().select_related('institute') + institutes = Institute.objects.all() + + # ---- Overview filters ---- + # years present in ExcelEntry.date + year_qs = ExcelEntry.objects.dates('date', 'year', order='DESC') + available_years = [d.year for d in year_qs] + + # default year/start month + # default year/start month (if no global interval yet) + if available_years: + default_year = available_years[0] # newest year in ExcelEntry + else: + default_year = timezone.now().year + + # 🔸 Read global half-year interval from session (set on main page) + session_year = request.session.get('halfyear_year') + session_start = request.session.get('halfyear_start_month') + + # If the user has set a global interval, use it. + # Otherwise fall back to default year / January. + year = int(session_year) if session_year else int(default_year) + start_month = int(session_start) if session_start else 1 + + + # six-month window + # --- Build a 6-month window, allowing wrap into the next year --- + # Example: year=2025, start_month=10 + # window = [(2025,10), (2025,11), (2025,12), (2026,1), (2026,2), (2026,3)] + window = [] + for offset in range(6): + total_index = (start_month - 1) + offset # 0-based + y_for_month = year + (total_index // 12) + m_for_month = (total_index % 12) + 1 + window.append((y_for_month, m_for_month)) + + overview = None + + if window: + # Build per-group data + groups_entries = [] # for internal calculations + + for key, group in CLIENT_GROUPS.items(): + clients_qs = get_group_clients(key) + + values = [] + group_total = Decimal('0') + + for (y_m, m_m) in window: + total = ExcelEntry.objects.filter( + client__in=clients_qs, + date__year=y_m, + date__month=m_m + ).aggregate( + total=Coalesce(Sum('lhe_ges'), Decimal('0')) + )['total'] + + values.append(total) + group_total += total + + groups_entries.append({ + 'key': key, + 'label': group['label'], + 'values': values, + 'total': group_total, + }) + + # month totals across all groups + month_totals = [] + for idx in range(len(window)): + s = sum((g['values'][idx] for g in groups_entries), Decimal('0')) + month_totals.append(s) + + grand_total = sum(month_totals, Decimal('0')) + + # Build rows for the template + rows = [] + for idx, (y_m, m_m) in enumerate(window): + row_values = [g['values'][idx] for g in groups_entries] + rows.append({ + 'month_number': m_m, + 'month_label': calendar.month_name[m_m], + 'values': row_values, + 'total': month_totals[idx], + }) + + groups_meta = [{'key': g['key'], 'label': g['label']} for g in groups_entries] + group_totals = [g['total'] for g in groups_entries] + + # Start/end for display – include years so wrap is clear + start_year = window[0][0] + start_month_disp = window[0][1] + end_year = window[-1][0] + end_month_disp = window[-1][1] + + overview = { + 'year': year, # keep for backwards compatibility if needed + 'start_month': start_month_disp, + 'start_year': start_year, + 'end_month': end_month_disp, + 'end_year': end_year, + 'rows': rows, + 'groups': groups_meta, + 'group_totals': group_totals, + 'grand_total': grand_total, + } + + + # Month dropdown labels + MONTH_CHOICES = [ + (1, 'Jan'), (2, 'Feb'), (3, 'Mar'), (4, 'Apr'), + (5, 'May'), (6, 'Jun'), (7, 'Jul'), (8, 'Aug'), + (9, 'Sep'), (10, 'Oct'), (11, 'Nov'), (12, 'Dec'), + ] + + return render(request, 'table_one.html', { + 'entries_table1': entries_table1, + 'clients': clients, + 'institutes': institutes, + 'available_years': available_years, + 'month_choices': MONTH_CHOICES, + 'overview': overview, + }) + +# Table Two View (SecondTableEntry) +def table_two_view(request): + try: + clients = Client.objects.all().select_related('institute') + institutes = Institute.objects.all() + + # 🔸 Read global half-year interval from session + interval_year = request.session.get('halfyear_year') + interval_start = request.session.get('halfyear_start_month') + + if interval_year and interval_start: + interval_year = int(interval_year) + interval_start = int(interval_start) + + # Build the same 6-month window as in clients_list (can cross years) + window = [] + for offset in range(6): + total_index = (interval_start - 1) + offset # 0-based + y = interval_year + (total_index // 12) + m = (total_index % 12) + 1 + window.append((y, m)) + + # Build a Q object matching any of those (year, month) pairs + q = Q() + for (y, m) in window: + q |= Q(date__year=y, date__month=m) + + entries = SecondTableEntry.objects.filter(q).order_by('-date') + else: + # Fallback if no global interval yet: show all + entries = SecondTableEntry.objects.all().order_by('-date') + + return render(request, 'table_two.html', { + 'entries_table2': entries, + 'clients': clients, + 'institutes': institutes, + 'interval_year': interval_year, + 'interval_start_month': interval_start, + }) + + except Exception as e: + return render(request, 'table_two.html', { + 'error_message': f"Failed to load data: {str(e)}", + 'entries_table2': [], + 'clients': clients, + 'institutes': institutes, + }) + + +def monthly_sheet_root(request): + """ + Redirect /sheet/ to the sheet matching the globally selected + half-year start (year + month). If not set, fall back to latest. + """ + year = request.session.get('halfyear_year') + start_month = request.session.get('halfyear_start_month') + + if year and start_month: + try: + year = int(year) + start_month = int(start_month) + return redirect('monthly_sheet', year=year, month=start_month) + except ValueError: + pass # fall through + + # Fallback: latest MonthlySheet if exists + latest_sheet = MonthlySheet.objects.order_by('-year', '-month').first() + if latest_sheet: + return redirect('monthly_sheet', year=latest_sheet.year, month=latest_sheet.month) + else: + now = timezone.now() + return redirect('monthly_sheet', year=now.year, month=now.month) +# Add Entry (Generic) +def add_entry(request, model_name): + if request.method == 'POST': + try: + if model_name == 'SecondTableEntry': + model = SecondTableEntry + + # Parse date + date_str = request.POST.get('date') + try: + date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else None + except (ValueError, TypeError): + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid date format. Use YYYY-MM-DD' + }, status=400) + + # NEW: robust parse of warm flag (handles 0/1, true/false, on/off) + raw_warm = request.POST.get('is_warm') + is_warm_bool = str(raw_warm).lower() in ('1', 'true', 'on', 'yes') + + # Handle Helium Output (Table Two) + lhe_output = request.POST.get('lhe_output') + + entry = model.objects.create( + client=Client.objects.get(id=request.POST.get('client_id')), + date=date_obj, + is_warm=is_warm_bool, # <-- use the boolean here + lhe_delivery=request.POST.get('lhe_delivery', ''), + lhe_output=Decimal(lhe_output) if lhe_output else None, + notes=request.POST.get('notes', '') + ) + + return JsonResponse({ + 'status': 'success', + 'id': entry.id, + 'client_name': entry.client.name, + 'institute_name': entry.client.institute.name, + 'date': entry.date.strftime('%Y-%m-%d') if entry.date else '', + 'is_warm': entry.is_warm, + 'lhe_delivery': entry.lhe_delivery, + 'lhe_output': str(entry.lhe_output) if entry.lhe_output else '', + 'notes': entry.notes + }) + + elif model_name == 'ExcelEntry': + model = ExcelEntry + + # Parse the date string into a date object + date_str = request.POST.get('date') + try: + date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else None + except (ValueError, TypeError): + date_obj = None + + try: + pressure = Decimal(request.POST.get('pressure', 0)) + purity = Decimal(request.POST.get('purity', 0)) + druckkorrektur = Decimal(request.POST.get('druckkorrektur', 1)) + lhe_zus = Decimal(request.POST.get('lhe_zus', 0)) + constant_300 = Decimal(request.POST.get('constant_300', 300)) + korrig_druck = Decimal(request.POST.get('korrig_druck', 0)) + nm3 = Decimal(request.POST.get('nm3', 0)) + lhe = Decimal(request.POST.get('lhe', 0)) + lhe_ges = Decimal(request.POST.get('lhe_ges', 0)) + except InvalidOperation: + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid numeric value in Helium Input' + }, status=400) + + # Create the entry with ALL fields + entry = model.objects.create( + client=Client.objects.get(id=request.POST.get('client_id')), + date=date_obj, + pressure=pressure, + purity=purity, + druckkorrektur=druckkorrektur, + lhe_zus=lhe_zus, + constant_300=constant_300, + korrig_druck=korrig_druck, + nm3=nm3, + lhe=lhe, + lhe_ges=lhe_ges, + notes=request.POST.get('notes', '') + ) + + # Prepare the response + response_data = { + 'status': 'success', + 'id': entry.id, + 'client_name': entry.client.name, + 'institute_name': entry.client.institute.name, + 'pressure': str(entry.pressure), + 'purity': str(entry.purity), + 'druckkorrektur': str(entry.druckkorrektur), + 'constant_300': str(entry.constant_300), + 'korrig_druck': str(entry.korrig_druck), + 'nm3': str(entry.nm3), + 'lhe': str(entry.lhe), + 'lhe_zus': str(entry.lhe_zus), + 'lhe_ges': str(entry.lhe_ges), + 'notes': entry.notes, + } + + if entry.date: + # JS uses this for the Date column and for the Month column + response_data['date'] = entry.date.strftime('%Y-%m-%d') + response_data['month'] = f"{entry.date.month:02d}" + else: + response_data['date'] = '' + response_data['month'] = '' + + return JsonResponse(response_data) + + + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}, status=400) + + return JsonResponse({'status': 'error', 'message': 'Invalid request'}, status=400) + +# Update Entry (Generic) +def update_entry(request, model_name): + if request.method == 'POST': + try: + if model_name == 'SecondTableEntry': + model = SecondTableEntry + elif model_name == 'ExcelEntry': + model = ExcelEntry + else: + return JsonResponse({'status': 'error', 'message': 'Invalid model'}, status=400) + + entry_id = int(request.POST.get('id')) + entry = model.objects.get(id=entry_id) + + # Common updates for both models + entry.client = Client.objects.get(id=request.POST.get('client_id')) + entry.notes = request.POST.get('notes', '') + + # Handle date properly for both models + date_str = request.POST.get('date') + if date_str: + try: + entry.date = datetime.strptime(date_str, '%Y-%m-%d').date() + except ValueError: + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid date format. Use YYYY-MM-DD' + }, status=400) + + if model_name == 'SecondTableEntry': + # Handle Helium Output specific fields + + raw_warm = request.POST.get('is_warm') + entry.is_warm = str(raw_warm).lower() in ('1', 'true', 'on', 'yes') + + entry.lhe_delivery = request.POST.get('lhe_delivery', '') + + lhe_output = request.POST.get('lhe_output') + try: + entry.lhe_output = Decimal(lhe_output) if lhe_output else None + except InvalidOperation: + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid LHe Output value' + }, status=400) + + entry.save() + + return JsonResponse({ + 'status': 'success', + 'id': entry.id, + 'client_name': entry.client.name, + 'institute_name': entry.client.institute.name, + 'date': entry.date.strftime('%Y-%m-%d') if entry.date else '', + 'is_warm': entry.is_warm, + 'lhe_delivery': entry.lhe_delivery, + 'lhe_output': str(entry.lhe_output) if entry.lhe_output else '', + 'notes': entry.notes + }) + + + elif model_name == 'ExcelEntry': + # Handle Helium Input specific fields + try: + entry.pressure = Decimal(request.POST.get('pressure', 0)) + entry.purity = Decimal(request.POST.get('purity', 0)) + entry.druckkorrektur = Decimal(request.POST.get('druckkorrektur', 1)) + entry.lhe_zus = Decimal(request.POST.get('lhe_zus', 0)) + entry.constant_300 = Decimal(request.POST.get('constant_300', 300)) + entry.korrig_druck = Decimal(request.POST.get('korrig_druck', 0)) + entry.nm3 = Decimal(request.POST.get('nm3', 0)) + entry.lhe = Decimal(request.POST.get('lhe', 0)) + entry.lhe_ges = Decimal(request.POST.get('lhe_ges', 0)) + except InvalidOperation: + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid numeric value in Helium Input' + }, status=400) + + entry.save() + + return JsonResponse({ + 'status': 'success', + 'id': entry.id, + 'client_name': entry.client.name, + 'institute_name': entry.client.institute.name, + 'date': entry.date.strftime('%Y-%m-%d') if entry.date else '', + 'month': f"{entry.date.month:02d}" if entry.date else '', + 'pressure': str(entry.pressure), + 'purity': str(entry.purity), + 'druckkorrektur': str(entry.druckkorrektur), + 'constant_300': str(entry.constant_300), + 'korrig_druck': str(entry.korrig_druck), + 'nm3': str(entry.nm3), + 'lhe': str(entry.lhe), + 'lhe_zus': str(entry.lhe_zus), + 'lhe_ges': str(entry.lhe_ges), + 'notes': entry.notes + }) + + + except model.DoesNotExist: + return JsonResponse({'status': 'error', 'message': 'Entry not found'}, status=404) + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}, status=400) + + return JsonResponse({'status': 'error', 'message': 'Invalid request method'}, status=400) + +# Delete Entry (Generic) +def delete_entry(request, model_name): + if request.method == 'POST': + try: + if model_name == 'SecondTableEntry': + model = SecondTableEntry + elif model_name == 'ExcelEntry': + model = ExcelEntry + else: + return JsonResponse({'status': 'error', 'message': 'Invalid model'}, status=400) + + entry_id = request.POST.get('id') + entry = model.objects.get(id=entry_id) + entry.delete() + return JsonResponse({'status': 'success', 'message': 'Entry deleted'}) + + except model.DoesNotExist: + return JsonResponse({'status': 'error', 'message': 'Entry not found'}, status=404) + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}, status=400) + + return JsonResponse({'status': 'error', 'message': 'Invalid request'}, status=400) + +def betriebskosten_list(request): + items = Betriebskosten.objects.all().order_by('-buchungsdatum') + return render(request, 'betriebskosten_list.html', {'items': items}) + +def betriebskosten_create(request): + if request.method == 'POST': + try: + entry_id = request.POST.get('id') + if entry_id: + # Update existing entry + entry = Betriebskosten.objects.get(id=entry_id) + else: + # Create new entry + entry = Betriebskosten() + + # Get form data + buchungsdatum_str = request.POST.get('buchungsdatum') + rechnungsnummer = request.POST.get('rechnungsnummer') + kostentyp = request.POST.get('kostentyp') + betrag = request.POST.get('betrag') + beschreibung = request.POST.get('beschreibung') + gas_volume = request.POST.get('gas_volume') + + # Validate required fields + if not all([buchungsdatum_str, rechnungsnummer, kostentyp, betrag]): + return JsonResponse({'status': 'error', 'message': 'All required fields must be filled'}) + + # Convert date string to date object + try: + buchungsdatum = parse_date(buchungsdatum_str) + if not buchungsdatum: + return JsonResponse({'status': 'error', 'message': 'Invalid date format'}) + except (ValueError, TypeError): + return JsonResponse({'status': 'error', 'message': 'Invalid date format'}) + + # Set entry values + entry.buchungsdatum = buchungsdatum + entry.rechnungsnummer = rechnungsnummer + entry.kostentyp = kostentyp + entry.betrag = betrag + entry.beschreibung = beschreibung + + # Only set gas_volume if kostentyp is helium and gas_volume is provided + if kostentyp == 'helium' and gas_volume: + entry.gas_volume = gas_volume + else: + entry.gas_volume = None + + entry.save() + + return JsonResponse({ + 'status': 'success', + 'id': entry.id, + 'buchungsdatum': entry.buchungsdatum.strftime('%Y-%m-%d'), + 'rechnungsnummer': entry.rechnungsnummer, + 'kostentyp_display': entry.get_kostentyp_display(), + 'gas_volume': str(entry.gas_volume) if entry.gas_volume else '-', + 'betrag': str(entry.betrag), + 'beschreibung': entry.beschreibung or '' + }) + + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}) + + return JsonResponse({'status': 'error', 'message': 'Invalid request method'}) + +def betriebskosten_delete(request): + if request.method == 'POST': + try: + entry_id = request.POST.get('id') + entry = Betriebskosten.objects.get(id=entry_id) + entry.delete() + return JsonResponse({'status': 'success'}) + except Betriebskosten.DoesNotExist: + return JsonResponse({'status': 'error', 'message': 'Entry not found'}) + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}) + + return JsonResponse({'status': 'error', 'message': 'Invalid request method'}) + +class CheckSheetView(View): + def get(self, request): + # Get current month/year + current_year = datetime.now().year + current_month = datetime.now().month + + # Get all sheets + sheets = MonthlySheet.objects.all() + + sheet_data = [] + for sheet in sheets: + cells_count = sheet.cells.count() + # Count non-empty cells + non_empty = sheet.cells.exclude(value__isnull=True).count() + + sheet_data.append({ + 'id': sheet.id, + 'year': sheet.year, + 'month': sheet.month, + 'month_name': calendar.month_name[sheet.month], + 'total_cells': cells_count, + 'non_empty_cells': non_empty, + 'has_data': non_empty > 0 + }) + + # Also check what the default view would show + default_sheet = MonthlySheet.objects.filter( + year=current_year, + month=current_month + ).first() + + return JsonResponse({ + 'current_year': current_year, + 'current_month': current_month, + 'current_month_name': calendar.month_name[current_month], + 'default_sheet_exists': default_sheet is not None, + 'default_sheet_id': default_sheet.id if default_sheet else None, + 'sheets': sheet_data, + 'total_sheets': len(sheet_data) + }) + + +class QuickDebugView(View): + def get(self, request): + # Get ALL sheets + sheets = MonthlySheet.objects.all().order_by('year', 'month') + + result = { + 'sheets': [] + } + + for sheet in sheets: + sheet_info = { + 'id': sheet.id, + 'display': f"{sheet.year}-{sheet.month:02d}", + 'url': f"/sheet/{sheet.year}/{sheet.month}/", # CHANGED THIS LINE + 'sheet_url_pattern': 'sheet/{year}/{month}/', # Add this for clarity + } + + # Count cells with data for first client in top_left table + first_client = Client.objects.first() + if first_client: + test_cells = sheet.cells.filter( + client=first_client, + table_type='top_left', + row_index__in=[8, 9, 10] # Rows 9, 10, 11 + ).order_by('row_index') + + cell_values = {} + for cell in test_cells: + cell_values[f"row_{cell.row_index}"] = str(cell.value) if cell.value else "Empty" + + sheet_info['test_cells'] = cell_values + else: + sheet_info['test_cells'] = "No clients" + + result['sheets'].append(sheet_info) + + return JsonResponse(result) +class TestFormulaView(View): + def get(self, request): + # Test the formula evaluation directly + test_values = { + 8: 2, # Row 9 value (0-based index 8) + 9: 2, # Row 10 value (0-based index 9) + } + + # Test formula "9 + 8" (using 0-based indices) + formula = "9 + 8" + result = evaluate_formula(formula, test_values) + + return JsonResponse({ + 'test_values': test_values, + 'formula': formula, + 'result': str(result), + 'note': 'Formula uses 0-based indices. 9=Row10, 8=Row9' + }) + + +class SimpleDebugView(View): + """Simplest debug view to check if things are working""" + def get(self, request): + sheet_id = request.GET.get('sheet_id', 1) + + try: + sheet = MonthlySheet.objects.get(id=sheet_id) + + # Get first client + client = Client.objects.first() + if not client: + return JsonResponse({'error': 'No clients found'}) + + # Check a few cells + cells = Cell.objects.filter( + sheet=sheet, + client=client, + table_type='top_left', + row_index__in=[8, 9, 10] + ).order_by('row_index') + + cell_data = [] + for cell in cells: + cell_data.append({ + 'row_index': cell.row_index, + 'ui_row': cell.row_index + 1, + 'value': str(cell.value) if cell.value is not None else 'Empty', + 'cell_id': cell.id + }) + + return JsonResponse({ + 'sheet': f"{sheet.year}-{sheet.month}", + 'sheet_id': sheet.id, + 'client': client.name, + 'cells': cell_data, + 'note': 'Row 8 = UI Row 9, Row 9 = UI Row 10, Row 10 = UI Row 11' + }) + + except MonthlySheet.DoesNotExist: + return JsonResponse({'error': f'Sheet with id {sheet_id} not found'}) + +def halfyear_settings(request): + """ + Global settings page: choose a year + first month for the 6-month interval. + These values are stored in the session and used by other views. + """ + # Determine available years from your data (use ExcelEntry or SecondTableEntry) + # Here I use SecondTableEntry; you can switch to ExcelEntry if you prefer. + available_years_qs = SecondTableEntry.objects.dates('date', 'year', order='DESC') + available_years = [d.year for d in available_years_qs] + + if not available_years: + available_years = [timezone.now().year] + + # Defaults (if nothing in session yet) + default_year = request.session.get('halfyear_year', available_years[0]) + default_start_month = request.session.get('halfyear_start_month', 1) + + if request.method == 'POST': + year = int(request.POST.get('year', default_year)) + start_month = int(request.POST.get('start_month', default_start_month)) + + request.session['halfyear_year'] = year + request.session['halfyear_start_month'] = start_month + + # Redirect back to where user came from, or to this page again + next_url = request.POST.get('next') or request.GET.get('next') or 'halfyear_settings' + return redirect(next_url) + + # Month choices for the dropdown + month_choices = [(i, calendar.month_name[i]) for i in range(1, 13)] + + context = { + 'available_years': available_years, + 'selected_year': int(default_year), + 'selected_start_month': int(default_start_month), + 'month_choices': month_choices, + } + return render(request, 'halfyear_settings.html', context) \ No newline at end of file diff --git a/sheets/services/__init__.py b/sheets/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sheets/services/halfyear_calc.py b/sheets/services/halfyear_calc.py new file mode 100644 index 0000000..705809f --- /dev/null +++ b/sheets/services/halfyear_calc.py @@ -0,0 +1,1156 @@ +# sheets/services/halfyear_calc.py +from __future__ import annotations +from django.db.models.functions import Coalesce +from decimal import Decimal +from typing import Dict, Any +from django.shortcuts import redirect, render +from decimal import Decimal +from sheets.models import ( + Client, SecondTableEntry, Institute, ExcelEntry, + Betriebskosten, MonthlySheet, Cell, CellReference, MonthlySummary ,BetriebskostenSummary,AbrechnungCell +) +from django.db.models import Sum +from django.db.models.functions import Coalesce +from django.db.models import DecimalField, Value +HALFYEAR_CLIENTS = ["AG Vogel", "AG Halfm", "IKP"] +TR_RUECKF_FLUESSIG_ROW = 2 # confirmed by your March value 172.840560 +TR_BESTAND_KANNEN_ROW = 5 +def get_top_right_value(sheet, client_name: str, row_index: int) -> Decimal: + """ + Read a numeric value from the top_right table of a MonthlySheet for + a given client (by column) and row_index. + + top_right cells are keyed by (sheet, table_type='top_right', + row_index, column_index), where column_index is the position of the + client in HALFYEAR_RIGHT_CLIENTS. + """ + if sheet is None: + return Decimal('0') + + col_index = RIGHT_CLIENT_INDEX.get(client_name) + if col_index is None: + return Decimal('0') + + cell = Cell.objects.filter( + sheet=sheet, + table_type='top_right', + row_index=row_index, + column_index=col_index, + ).first() + + if cell is None or cell.value in (None, ''): + return Decimal('0') + + try: + return Decimal(str(cell.value)) + except Exception: + return Decimal('0') + +def get_bottom2_value(sheet, row_index: int, col_index: int) -> Decimal: + """Get numeric value from bottom_2 or 0 if missing.""" + if sheet is None: + return Decimal("0") + cell = Cell.objects.filter( + sheet=sheet, + table_type="bottom_2", + row_index=row_index, + column_index=col_index, + ).first() + if cell is None or cell.value in (None, ""): + return Decimal("0") + try: + return Decimal(str(cell.value)) + except Exception: + return Decimal("0") +def get_bottom1_value(sheet, row_index: int, col_index: int) -> Decimal: + """Get a numeric value from bottom_1, or 0 if missing.""" + if sheet is None: + return Decimal('0') + + cell = Cell.objects.filter( + sheet=sheet, + table_type='bottom_1', + row_index=row_index, + column_index=col_index, + ).first() + + if cell is None or cell.value in (None, ''): + return Decimal('0') + + try: + return Decimal(str(cell.value)) + except Exception: + return Decimal('0') +def get_top_left_value(sheet, client_name: str, row_index: int) -> Decimal: + """ + Read a numeric value from the top_left table for a given month, client and row. + Does NOT use column_index, because top_left is keyed only by client + row_index. + """ + if sheet is None: + return Decimal('0') + + client_obj = Client.objects.filter(name=client_name).first() + if not client_obj: + return Decimal('0') + + cell = Cell.objects.filter( + sheet=sheet, + table_type='top_left', + client=client_obj, + row_index=row_index, + ).first() + + if cell is None or cell.value in (None, ''): + return Decimal('0') + + try: + return Decimal(str(cell.value)) + except Exception: + return Decimal('0') +def get_bestand_kannen_for_month(sheet, client_name: str) -> Decimal: + """ + 'B9' in your description: Bestand in Kannen-1 (Lit. L-He) + For this implementation we take it from top_left row_index = 5 for that client. + """ + return get_top_left_value(sheet, client_name, row_index=BESTAND_KANNEN_ROW_INDEX) + +def pick_sheet_by_gasbestand(window, sheets_by_ym, prev_sheet): + """ + Returns the last sheet in the window whose Gasbestand (J36, Nm³ column) != 0. + If none found, returns prev_sheet (Übertrag_Dez__Vorjahr equivalent). + """ + for (y, m) in reversed(window): + sheet = sheets_by_ym.get((y, m)) + if not sheet: + continue + gasbestand_nm3 = get_bottom1_value(sheet, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) + if gasbestand_nm3 != 0: + return sheet + return prev_sheet +# NEW: clients for the top-right half-year table +GASBESTAND_ROW_INDEX = 9 # <-- adjust if your bottom_1 has a different row index +GASBESTAND_COL_NM3 = 3 # <-- adjust to the column index for Nm³ in bottom_1 + +# In top_left / top_right, "Bestand in Kannen-1 (Lit. L-He)" is row_index 5 +BESTAND_KANNEN_ROW_INDEX = 5 +HALFYEAR_RIGHT_CLIENTS = [ + "Dr. Fohrer", + "AG Buntk.", + "AG Alff", + "AG Gutfl.", + "M3 Thiele", + "M3 Buntkowsky", + "M3 Gutfleisch", +] +BOTTOM1_COL_VOLUME = 0 +BOTTOM1_COL_BAR = 1 +BOTTOM1_COL_KORR = 2 +BOTTOM1_COL_NM3 = 3 +BOTTOM1_COL_LHE = 4 +BOTTOM2_ROW_ANLAGE = 0 +BOTTOM2_COL_G39 = 0 # "Gefäss 2,5" (cell id shows column_index=0) +BOTTOM2_COL_I39 = 1 # "Gefäss 1,0" (cell id shows column_index=1) +BOTTOM2_ROW_INPUTS = { + "g39": (0, 0), # row_index=0, column_index=0 (your G39) + "i39": (0, 1), # row_index=0, column_index=1 (your I39) +} +FACTOR_NM3_TO_LHE = Decimal("0.75") +RIGHT_CLIENT_INDEX = {name: idx for idx, name in enumerate(HALFYEAR_RIGHT_CLIENTS)} + +def build_halfyear_window(interval_year: int, start_month: int): + """ + Build a list of (year, month) for the 6-month interval, possibly crossing into the next year. + Example: (2025, 10) -> [(2025,10), (2025,11), (2025,12), (2026,1), (2026,2), (2026,3)] + """ + window = [] + for offset in range(6): + total_index = (start_month - 1) + offset # 0-based + y = interval_year + (total_index // 12) + m = (total_index % 12) + 1 + window.append((y, m)) + return window +# Import ONLY models + pure helpers here +from sheets.models import MonthlySheet, Cell, Client, SecondTableEntry, BetriebskostenSummary + +def compute_halfyear_context(interval_year: int, interval_start: int) -> Dict[str, Any]: + """ + Returns a context dict with the SAME keys your current halfyear_balance.html expects. + """ + # ✅ Paste the pure calculation logic here in Step 2 + window = build_halfyear_window(interval_year, interval_start) + # window = [(y1, m1), (y2, m2), ..., (y6, m6)] + + # (Year, month) of the first month + start_year, start_month = window[0] + + # Previous month (for "Stand ... (Vorjahr)" and "Best. in Kannen Vormonat") + prev_total_index = (start_month - 1) - 1 # one month back, 0-based + if prev_total_index >= 0: + prev_year = start_year + (prev_total_index // 12) + prev_month = (prev_total_index % 12) + 1 + else: + prev_year = start_year - 1 + prev_month = 12 + + # Load MonthlySheet objects for the window and for the previous month + sheets_by_ym = {} + for (y, m) in window: + sheet = MonthlySheet.objects.filter(year=y, month=m).first() + sheets_by_ym[(y, m)] = sheet + + prev_sheet = MonthlySheet.objects.filter(year=prev_year, month=prev_month).first() + def pick_bottom2_from_window(window, sheets_by_ym, prev_sheet): + # choose sheet (same logic you already use) + chosen = None + for (y, m) in reversed(window): + s = sheets_by_ym.get((y, m)) + # use your existing condition for choosing month + if s: + chosen = s + break + if chosen is None: + chosen = prev_sheet + + # Now read the two inputs safely + bottom2_inputs = {} + for key, (row_idx, col_idx) in BOTTOM2_ROW_INPUTS.items(): + bottom2_inputs[key] = get_bottom2_value(chosen, row_idx, col_idx) + + return chosen, bottom2_inputs + + + chosen_sheet_bottom2, bottom2_inputs = pick_bottom2_from_window(window, sheets_by_ym, prev_sheet) + bottom2_g39 = bottom2_inputs["g39"] + bottom2_i39 = bottom2_inputs["i39"] + # ---------------------------- + # HALF-YEAR BOTTOM TABLE 1 (Bilanz) - Read only + # ---------------------------- + chosen_sheet_bottom1 = pick_sheet_by_gasbestand(window, sheets_by_ym, prev_sheet) + + # IMPORTANT: define which bottom_1 row_index corresponds to Excel rows 27..35 + # If your bottom_1 starts at Excel row 27 => row_index 0 == Excel 27 + # then row_index = excel_row - 27 + BOTTOM1_EXCEL_START_ROW = 27 + + bottom1_excel_rows = list(range(27, 37)) # 27..36 + BOTTOM1_LABELS = [ + "Batterie 1", + "2", + "3", + "4", + "5", + "Batterie Links", + "2 Bündel", + "2 Ballone", + "Reingasspeicher", + "Gasbestand", + ] + + BOTTOM1_VOLUMES = [ + Decimal("2.4"), + Decimal("5.1"), + Decimal("4.0"), + Decimal("1.0"), + Decimal("4.0"), + Decimal("0.6"), + Decimal("1.2"), + Decimal("20.0"), + Decimal("5.0"), + None, # Gasbestand row has no volume + ] + nm3_sum_27_35 = Decimal("0") + lhe_sum_27_35 = Decimal("0") + bottom1_rows = [] + + for excel_row in bottom1_excel_rows: + row_index = excel_row - BOTTOM1_EXCEL_START_ROW + + chosen_sheet_bottom1 = None + for (y, m) in reversed(window): + s = sheets_by_ym.get((y, m)) + gasbestand = get_bottom1_value(s, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) # J36 (Nm3) + if gasbestand != 0: + chosen_sheet_bottom1 = s + break + + if chosen_sheet_bottom1 is None: + chosen_sheet_bottom1 = prev_sheet + + # Normal rows (27..35): read from chosen sheet and accumulate sums + if excel_row != 36: + nm3_val = get_bottom1_value(chosen_sheet_bottom1, row_index, BOTTOM1_COL_NM3) + lhe_val = get_bottom1_value(chosen_sheet_bottom1, row_index, BOTTOM1_COL_LHE) + + nm3_sum_27_35 += nm3_val + lhe_sum_27_35 += lhe_val + + bottom1_rows.append({ + "label": BOTTOM1_LABELS[row_index], + "volume": BOTTOM1_VOLUMES[row_index], + "bar": get_bottom1_value(chosen_sheet_bottom1, row_index, BOTTOM1_COL_BAR), + "korr": get_bottom1_value(chosen_sheet_bottom1, row_index, BOTTOM1_COL_KORR), + "nm3": nm3_val, + "lhe": lhe_val, + }) + + # Gasbestand row (36): show sums (J36 = SUM(J27:J35), K36 = SUM(K27:K35)) + else: + bottom1_rows.append({ + "label": "Gasbestand", + "volume": "", + "bar": "", + "korr": "", + "nm3": nm3_sum_27_35, + "lhe": lhe_sum_27_35, + }) + start_sheet = sheets_by_ym.get((start_year, start_month)) + # ------------------------------------------------------------ + # Bottom Table 2 (Halbjahres Bilanz) – server-side recalcBottom2() + # ------------------------------------------------------------ + + FACTOR_BT2 = Decimal("0.75") + + # 1) Helper: pick last-nonzero value of bottom_2 row0 col0/col1 from the window (fallback: prev_sheet) + def pick_last_nonzero_bottom2(row_index: int, col_index: int) -> Decimal: + # Scan from last month in window backwards + for (y, m) in reversed(window): + s = sheets_by_ym.get((y, m)) + if not s: + continue + v = get_bottom2_value(s, row_index, col_index) + if v is not None and v != 0: + return v + # fallback to month before window + v_prev = get_bottom2_value(prev_sheet, row_index, col_index) + return v_prev if v_prev is not None else Decimal("0") + + # 2) K38 comes from Overall Summary: "Summe Bestand (Lit. L-He)" + # Find it from your already built overall summary rows list. + + k38 = Decimal("0") + j38 = Decimal("0") + # 3) Inputs G39 / I39 (picked from last non-zero month in window) + g39 = pick_last_nonzero_bottom2(row_index=0, col_index=0) # G39 + i39 = pick_last_nonzero_bottom2(row_index=0, col_index=1) # I39 + + k39 = (g39 or Decimal("0")) + (i39 or Decimal("0")) + j39 = k39 * FACTOR_BT2 + + # 4) +Kaltgas (row 40) + # JS: + # g40 = (2500 - g39)/100*10 + # i40 = (1000 - i39)/100*10 + g40 = None + i40 = None + if g39 is not None: + g40 = (Decimal("2500") - g39) / Decimal("100") * Decimal("10") + if i39 is not None: + i40 = (Decimal("1000") - i39) / Decimal("100") * Decimal("10") + + k40 = (g40 or Decimal("0")) + (i40 or Decimal("0")) + j40 = k40 * FACTOR_BT2 + + # 5) Bestand flüssig He (row 43) + k43 = ( + (k38 or Decimal("0")) + + (k39 or Decimal("0")) + + (k40 or Decimal("0")) + ) + j43 = k43 * FACTOR_BT2 + + # 6) Gesamtbestand neu (row 44) = Gasbestand(Lit) from Bottom Table 1 + k43 + gasbestand_lit = Decimal("0") + for r in bottom1_rows: + if (r.get("label") or "").strip().startswith("Gasbestand"): + gasbestand_lit = r.get("lhe") or Decimal("0") + break + + k44 = (gasbestand_lit or Decimal("0")) + (k43 or Decimal("0")) + j44 = k44 * FACTOR_BT2 + + bottom2 = { + "j38": j38, "k38": k38, + "g39": g39, "i39": i39, "j39": j39, "k39": k39, + "g40": g40, "i40": i40, "j40": j40, "k40": k40, + "j43": j43, "k43": k43, + "j44": j44, "k44": k44, + } + + # ------------------------------------------------------------------ + # 2) LEFT TABLE (your existing, working logic) + # ------------------------------------------------------------------ + HALFYEAR_CLIENTS_LEFT = ["AG Vogel", "AG Halfm", "IKP"] + + # We'll collect client-wise values first for clarity. + client_data_left = {name: {} for name in HALFYEAR_CLIENTS_LEFT} + + # --- Row B3: Stand der Gaszähler (Nm³) + # = MAX(B3 from previous month, and B3 from each of the 6 months in the window) + # row_index 0 in top_left = "Stand der Gaszähler (Nm³)" + months_for_max = [(prev_year, prev_month)] + window + + for cname in HALFYEAR_CLIENTS_LEFT: + max_val = Decimal('0') + for (y, m) in months_for_max: + sheet = sheets_by_ym.get((y, m)) + if sheet is None and (y, m) == (prev_year, prev_month): + sheet = prev_sheet + val_b3 = get_top_left_value(sheet, cname, row_index=0) + if val_b3 > max_val: + max_val = val_b3 + client_data_left[cname]['stand_gas'] = max_val + + # --- Row B4: Stand der Gaszähler (Vorjahr) (Nm³) -> previous month same row --- + for cname in HALFYEAR_CLIENTS_LEFT: + val_b4 = get_top_left_value(prev_sheet, cname, row_index=0) + client_data_left[cname]['stand_gas_prev'] = val_b4 + + # --- Row B5: Gasrückführung (Nm³) = B3 - B4 --- + for cname in HALFYEAR_CLIENTS_LEFT: + b3 = client_data_left[cname]['stand_gas'] + b4 = client_data_left[cname]['stand_gas_prev'] + client_data_left[cname]['gasrueckf'] = b3 - b4 + + # --- Row B6: Rückführung flüssig (Lit. L-He) = B5 / 0.75 --- + for cname in HALFYEAR_CLIENTS_LEFT: + b5 = client_data_left[cname]['gasrueckf'] + client_data_left[cname]['rueckf_fluessig'] = (b5 / Decimal('0.75')) if b5 != 0 else Decimal('0') + + # --- Row B7: Sonderrückführungen (Lit. L-He) = sum over 6 months of that row --- + # That row index is 4 in your top_left table. + for cname in HALFYEAR_CLIENTS_LEFT: + sonder_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + sonder_total += get_top_left_value(sheet, cname, row_index=4) + client_data_left[cname]['sonder'] = sonder_total + + # --- Row B8: Bestand in Kannen-1 (Lit. L-He) --- + # Excel-style logic with Gasbestand (J36) and fallback to previous month. + for cname in HALFYEAR_CLIENTS_LEFT: + chosen_value = None + + # Go from last month (window[5]) backwards to first (window[0]) + for (y, m) in reversed(window): + sheet = sheets_by_ym.get((y, m)) + gasbestand = get_bottom1_value(sheet, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) + if gasbestand != 0: + chosen_value = get_bestand_kannen_for_month(sheet, cname) + break + + # If still None -> use previous month (Übertrag_Dez__Vorjahr equivalent) + if chosen_value is None: + sheet_prev = prev_sheet + chosen_value = get_bestand_kannen_for_month(sheet_prev, cname) + + client_data_left[cname]['bestand_kannen'] = chosen_value if chosen_value is not None else Decimal('0') + + # --- Row B9: Summe Bestand (Lit. L-He) = equal to previous row --- + for cname in HALFYEAR_CLIENTS_LEFT: + client_data_left[cname]['summe_bestand'] = client_data_left[cname]['bestand_kannen'] + + # --- Row B10: Best. in Kannen Vormonat (Lit. L-He) + # = Bestand in Kannen-1 from the month BEFORE the window (prev_year, prev_month) + for cname in HALFYEAR_CLIENTS_LEFT: + client_data_left[cname]['best_kannen_vormonat'] = get_bestand_kannen_for_month(prev_sheet, cname) + + # --- Row B13: Bezug (Liter L-He) --- + for cname in HALFYEAR_CLIENTS_LEFT: + total_bezug = Decimal('0') + for (y, m) in window: + qs = SecondTableEntry.objects.filter( + client__name=cname, + date__year=y, + date__month=m, + ).aggregate( + total=Coalesce(Sum('lhe_output'), Value(0, output_field=DecimalField())) + ) + total_bezug += Decimal(str(qs['total'])) + client_data_left[cname]['bezug'] = total_bezug + + # --- Row B14: Rückführ. Soll (Lit. L-He) = Bezug - Summe Bestand + Best. in Kannen Vormonat --- + for cname in HALFYEAR_CLIENTS_LEFT: + b13 = client_data_left[cname]['bezug'] + b11 = client_data_left[cname]['summe_bestand'] + b12 = client_data_left[cname]['best_kannen_vormonat'] + client_data_left[cname]['rueckf_soll'] = b13 - b11 + b12 + + # --- Row B15: Verluste (Soll-Rückf.) (Lit. L-He) + # AG Vogel, AG Halfm: B14 - B6 + # IKP: B14 - B6 - B7 (Sonderrückführungen) + for cname in HALFYEAR_CLIENTS_LEFT: + b14 = client_data_left[cname]['rueckf_soll'] + b6 = client_data_left[cname]['rueckf_fluessig'] + + if (cname or "").strip() == "IKP": + b7 = client_data_left[cname].get('sonder', Decimal('0')) + client_data_left[cname]['verluste'] = b14 - b6 - b7 + else: + client_data_left[cname]['verluste'] = b14 - b6 + # --- Row B16: Füllungen warm (Lit. L-He) = sum over 6 months (row_index=11) --- + for cname in HALFYEAR_CLIENTS_LEFT: + total_warm = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + total_warm += get_top_left_value(sheet, cname, row_index=11) + client_data_left[cname]['fuellungen_warm'] = total_warm + + # --- Row B17: Kaltgas Rückgabe (Lit. L-He) = Bezug * 0.06 --- + factor = Decimal('0.06') + for cname in HALFYEAR_CLIENTS_LEFT: + b13 = client_data_left[cname]['bezug'] + client_data_left[cname]['kaltgas_rueckgabe'] = b13 * factor + + # --- Row B18: Verbraucherverluste (Liter L-He) = Verluste - Kaltgas Rückgabe --- + for cname in HALFYEAR_CLIENTS_LEFT: + b15 = client_data_left[cname]['verluste'] + b17 = client_data_left[cname]['kaltgas_rueckgabe'] + client_data_left[cname]['verbraucherverluste'] = b15 - b17 + + # --- Row B19: % = Verbraucherverluste / Bezug --- + for cname in HALFYEAR_CLIENTS_LEFT: + bezug = client_data_left[cname]['bezug'] + verb = client_data_left[cname]['verbraucherverluste'] + if bezug != 0: + client_data_left[cname]['percent'] = verb / bezug + else: + client_data_left[cname]['percent'] = None + + # Build LEFT rows structure + left_row_defs = [ + ('Stand der Gaszähler (Nm³)', 'stand_gas'), + ('Stand der Gaszähler (Vorjahr) (Nm³)', 'stand_gas_prev'), + ('Gasrückführung (Nm³)', 'gasrueckf'), + ('Rückführung flüssig (Lit. L-He)', 'rueckf_fluessig'), + ('Sonderrückführungen (Lit. L-He)', 'sonder'), + ('Bestand in Kannen-1 (Lit. L-He)', 'bestand_kannen'), + ('Summe Bestand (Lit. L-He)', 'summe_bestand'), + ('Best. in Kannen Vormonat (Lit. L-He)', 'best_kannen_vormonat'), + ('Bezug (Liter L-He)', 'bezug'), + ('Rückführ. Soll (Lit. L-He)', 'rueckf_soll'), + ('Verluste (Soll-Rückf.) (Lit. L-He)', 'verluste'), + ('Füllungen warm (Lit. L-He)', 'fuellungen_warm'), + ('Kaltgas Rückgabe (Lit. L-He) – Faktor', 'kaltgas_rueckgabe'), + ('Verbraucherverluste (Liter L-He)', 'verbraucherverluste'), + ('%', 'percent'), + ] + + rows_left = [] + for label, key in left_row_defs: + values = [client_data_left[cname][key] for cname in HALFYEAR_CLIENTS_LEFT] + if key == 'percent': + total_bezug = sum((client_data_left[c]['bezug'] for c in HALFYEAR_CLIENTS_LEFT), Decimal('0')) + total_verb = sum((client_data_left[c]['verbraucherverluste'] for c in HALFYEAR_CLIENTS_LEFT), Decimal('0')) + total = (total_verb / total_bezug) if total_bezug != 0 else None + else: + total = sum((v for v in values if v is not None), Decimal('0')) + rows_left.append({ + 'label': label, + 'values': values, + 'total': total, + 'is_percent': key == 'percent', + }) + + # ------------------------------------------------------------------ + # 3) RIGHT TABLE (top-right half-year aggregation) + # ------------------------------------------------------------------ + RIGHT_CLIENTS = HALFYEAR_RIGHT_CLIENTS # for brevity + + right_data = {name: {} for name in RIGHT_CLIENTS} + + # --- Bezug (Liter L-He) for each right client (same as for left) --- + for cname in RIGHT_CLIENTS: + total_bezug = Decimal('0') + for (y, m) in window: + qs = SecondTableEntry.objects.filter( + client__name=cname, + date__year=y, + date__month=m, + ).aggregate( + total=Coalesce(Sum('lhe_output'), Value(0, output_field=DecimalField())) + ) + total_bezug += Decimal(str(qs['total'])) + right_data[cname]['bezug'] = total_bezug + def find_bestand_from_window(reference_client: str) -> Decimal: + """ + Implements: + WENN(last_month!J36=0; WENN(prev_month!J36=0; ...; prev_sheet!9); last_month!9) + reference_client decides which column (L/N/P/Q/R) we read from monthly top_right row_index=5. + """ + # scan backward through window + for (y, m) in reversed(window): + sh = sheets_by_ym.get((y, m)) + if not sh: + continue + gasbestand = get_bottom1_value(sh, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) + if gasbestand != 0: + return get_top_right_value(sh, reference_client, TR_BESTAND_KANNEN_ROW) + + # fallback to previous month (Übertrag_Dez__Vorjahr equivalent) + return get_top_right_value(prev_sheet, reference_client, TR_BESTAND_KANNEN_ROW) + + # Fohrer+Buntk merged: BOTH use Fohrer column (L9) + val_L = find_bestand_from_window("Dr. Fohrer") + right_data["Dr. Fohrer"]["bestand_kannen"] = val_L + right_data["AG Buntk."]["bestand_kannen"] = val_L + + # Alff+Gutfl merged: BOTH use Alff column (N9) + val_N = find_bestand_from_window("AG Alff") + right_data["AG Alff"]["bestand_kannen"] = val_N + right_data["AG Gutfl."]["bestand_kannen"] = val_N + + # M3 each uses its own column (P9/Q9/R9) + right_data["M3 Thiele"]["bestand_kannen"] = find_bestand_from_window("M3 Thiele") + right_data["M3 Buntkowsky"]["bestand_kannen"] = find_bestand_from_window("M3 Buntkowsky") + right_data["M3 Gutfleisch"]["bestand_kannen"] = find_bestand_from_window("M3 Gutfleisch") + # Helper for pair shares (L13/($L13+$M13), etc.) + def pair_share(c1, c2): + total = right_data[c1]['bezug'] + right_data[c2]['bezug'] + if total == 0: + return (Decimal('0'), Decimal('0')) + return ( + right_data[c1]['bezug'] / total, + right_data[c2]['bezug'] / total, + ) + + # --- "Stand der Gaszähler (Vorjahr) (Nm³)" row: share based on Bezug --- + # Dr. Fohrer / AG Buntk. + s_fohrer, s_buntk = pair_share("Dr. Fohrer", "AG Buntk.") + right_data["Dr. Fohrer"]['stand_prev_share'] = s_fohrer + right_data["AG Buntk."]['stand_prev_share'] = s_buntk + + # AG Alff / AG Gutfl. + s_alff, s_gutfl = pair_share("AG Alff", "AG Gutfl.") + right_data["AG Alff"]['stand_prev_share'] = s_alff + right_data["AG Gutfl."]['stand_prev_share'] = s_gutfl + + # M3 Thiele / M3 Buntkowsky / M3 Gutfleisch → empty in Excel → None + for cname in ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"]: + right_data[cname]['stand_prev_share'] = None + + # --- Rückführung flüssig per month (raw sums) --- + # top_right row_index=2 is "Rückführung flüssig (Lit. L-He)" + + # --- Sonderrückführungen (row_index=3 in top_right) --- + for cname in RIGHT_CLIENTS: + sonder_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + sonder_total += get_top_right_value(sheet, cname, row_index=3) + right_data[cname]['sonder'] = sonder_total + + # --- Sammelrückführung (row_index=4 in top_right), grouped & merged --- + # Group 1: Dr. Fohrer + AG Buntk. + group1_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + group1_total += get_top_right_value(sheet, "Dr. Fohrer", row_index=4) + right_data["Dr. Fohrer"]['sammel'] = group1_total + right_data["AG Buntk."]['sammel'] = group1_total + + # Group 2: AG Alff + AG Gutfl. + group2_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + group2_total += get_top_right_value(sheet, "AG Alff", row_index=4) + right_data["AG Alff"]['sammel'] = group2_total + right_data["AG Gutfl."]['sammel'] = group2_total + + # Group 3: M3 Thiele + M3 Buntkowsky + M3 Gutfleisch + group3_total = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + group3_total += get_top_right_value(sheet, "M3 Thiele", row_index=4) + right_data["M3 Thiele"]['sammel'] = group3_total + right_data["M3 Buntkowsky"]['sammel'] = group3_total + right_data["M3 Gutfleisch"]['sammel'] = group3_total + def safe_div(a: Decimal, b: Decimal) -> Decimal: + return (a / b) if b != 0 else Decimal("0") + + # --- Rückführung flüssig (Lit. L-He) for Halbjahres-Bilanz top-right --- + # Uses your exact formulas. + + # 1) Fohrer / Buntk split by BEZUG share times group SAMMEL (L8) + L13 = right_data["Dr. Fohrer"]["bezug"] + M13 = right_data["AG Buntk."]["bezug"] + L8 = right_data["Dr. Fohrer"]["sammel"] # merged group total + + den = (L13 + M13) + right_data["Dr. Fohrer"]["rueckf_fluessig"] = (safe_div(L13, den) * L8) if den != 0 else Decimal("0") + right_data["AG Buntk."]["rueckf_fluessig"] = (safe_div(M13, den) * L8) if den != 0 else Decimal("0") + + # 2) Alff / Gutfl split by BEZUG share times group SAMMEL (N8) + N13 = right_data["AG Alff"]["bezug"] + O13 = right_data["AG Gutfl."]["bezug"] + N8 = right_data["AG Alff"]["sammel"] # merged group total + + den = (N13 + O13) + right_data["AG Alff"]["rueckf_fluessig"] = (safe_div(N13, den) * N8) if den != 0 else Decimal("0") + right_data["AG Gutfl."]["rueckf_fluessig"] = (safe_div(O13, den) * N8) if den != 0 else Decimal("0") + + # 3) M3 Thiele = sum of monthly Rückführung flüssig (monthly top_right row_index=2) over window + P6_sum = Decimal("0") + for (y, m) in window: + sh = sheets_by_ym.get((y, m)) + P6_sum += get_top_right_value(sh, "M3 Thiele", TR_RUECKF_FLUESSIG_ROW) + right_data["M3 Thiele"]["rueckf_fluessig"] = P6_sum + + # 4) M3 Buntkowsky / M3 Gutfleisch split by BEZUG share times M3-group SAMMEL (P8) + P13 = right_data["M3 Thiele"]["bezug"] + Q13 = right_data["M3 Buntkowsky"]["bezug"] + R13 = right_data["M3 Gutfleisch"]["bezug"] + P8 = right_data["M3 Thiele"]["sammel"] # merged group total + + den = (P13 + Q13 + R13) + right_data["M3 Buntkowsky"]["rueckf_fluessig"] = (safe_div(Q13, den) * P8) if den != 0 else Decimal("0") + right_data["M3 Gutfleisch"]["rueckf_fluessig"] = (safe_div(R13, den) * P8) if den != 0 else Decimal("0") + # --- Bestand in Kannen-1 (Lit. L-He) for right table (grouped) --- + # Use Gasbestand (J36) and fallback logic, but now reading top_right B9 for each group. + TOP_RIGHT_ROW_BESTAND_KANNEN = TR_BESTAND_KANNEN_ROW # <-- most likely correct in your setup + + def pick_bestand_top_right(base_client: str) -> Decimal: + # Go from last month in window backwards: if Gasbestand != 0, use that month's Bestand in Kannen + for (y, m) in reversed(window): + sh = sheets_by_ym.get((y, m)) + if not sh: + continue + + gasbestand = get_bottom1_value(sh, GASBESTAND_ROW_INDEX, GASBESTAND_COL_NM3) + if gasbestand != 0: + return get_top_right_value(sh, base_client, TOP_RIGHT_ROW_BESTAND_KANNEN) + + # Fallback to previous month (Übertrag_Dez__Vorjahr equivalent) + return get_top_right_value(prev_sheet, base_client, TOP_RIGHT_ROW_BESTAND_KANNEN) + + # Group 1 merged (Fohrer + Buntk.) + g1_best = pick_bestand_top_right("Dr. Fohrer") + right_data["Dr. Fohrer"]["bestand_kannen"] = g1_best + right_data["AG Buntk."]["bestand_kannen"] = g1_best + + # Group 2 merged (Alff + Gutfl.) + g2_best = pick_bestand_top_right("AG Alff") + right_data["AG Alff"]["bestand_kannen"] = g2_best + right_data["AG Gutfl."]["bestand_kannen"] = g2_best + + # Group 3 NOT merged: each M3 client uses its own most recent nonzero Bestand in Kannen-1 + right_data["M3 Thiele"]["bestand_kannen"] = pick_bestand_top_right("M3 Thiele") + right_data["M3 Buntkowsky"]["bestand_kannen"] = pick_bestand_top_right("M3 Buntkowsky") + right_data["M3 Gutfleisch"]["bestand_kannen"] = pick_bestand_top_right("M3 Gutfleisch") + + # Summe Bestand = same as previous row + for cname in RIGHT_CLIENTS: + right_data[cname]['summe_bestand'] = right_data[cname]['bestand_kannen'] + + # Best. in Kannen Vormonat (Lit. L-He) from previous month top_right row_index=7 + g1_prev = get_top_right_value(prev_sheet, "Dr. Fohrer", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["Dr. Fohrer"]['best_kannen_vormonat'] = g1_prev + right_data["AG Buntk."]['best_kannen_vormonat'] = g1_prev + + g2_prev = get_top_right_value(prev_sheet, "AG Alff", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["AG Alff"]['best_kannen_vormonat'] = g2_prev + right_data["AG Gutfl."]['best_kannen_vormonat'] = g2_prev + + + # Group 1 merged (Fohrer + Buntk.) + g1_prev = get_top_right_value(prev_sheet, "Dr. Fohrer", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["Dr. Fohrer"]["best_kannen_vormonat"] = g1_prev + right_data["AG Buntk."]["best_kannen_vormonat"] = g1_prev + + # Group 2 merged (Alff + Gutfl.) + g2_prev = get_top_right_value(prev_sheet, "AG Alff", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["AG Alff"]["best_kannen_vormonat"] = g2_prev + right_data["AG Gutfl."]["best_kannen_vormonat"] = g2_prev + + # Group 3 UNMERGED (each one reads its own cell) + right_data["M3 Thiele"]["best_kannen_vormonat"] = get_top_right_value(prev_sheet, "M3 Thiele", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["M3 Buntkowsky"]["best_kannen_vormonat"] = get_top_right_value(prev_sheet, "M3 Buntkowsky", TOP_RIGHT_ROW_BESTAND_KANNEN) + right_data["M3 Gutfleisch"]["best_kannen_vormonat"] = get_top_right_value(prev_sheet, "M3 Gutfleisch", TOP_RIGHT_ROW_BESTAND_KANNEN) + + # --- Rückführ. Soll (Lit. L-He) according to your formulas --- + + # Group 1: Dr. Fohrer / AG Buntk. + total_bestand_1 = right_data["Dr. Fohrer"]['summe_bestand'] + best_vormonat_1 = right_data["Dr. Fohrer"]['best_kannen_vormonat'] + diff1 = total_bestand_1 - best_vormonat_1 + share_fohrer = right_data["Dr. Fohrer"]['stand_prev_share'] or Decimal('0') + + right_data["Dr. Fohrer"]['rueckf_soll'] = ( + right_data["Dr. Fohrer"]['bezug'] - diff1 * share_fohrer + ) + right_data["AG Buntk."]['rueckf_soll'] = ( + right_data["AG Buntk."]['bezug'] - total_bestand_1 + best_vormonat_1 + ) + + # Group 2: AG Alff / AG Gutfl. + total_bestand_2 = right_data["AG Alff"]['summe_bestand'] + best_vormonat_2 = right_data["AG Alff"]['best_kannen_vormonat'] + diff2 = total_bestand_2 - best_vormonat_2 + share_alff = right_data["AG Alff"]['stand_prev_share'] or Decimal('0') + share_gutfl = right_data["AG Gutfl."]['stand_prev_share'] or Decimal('0') + + right_data["AG Alff"]['rueckf_soll'] = ( + right_data["AG Alff"]['bezug'] - diff2 * share_alff + ) + right_data["AG Gutfl."]['rueckf_soll'] = ( + right_data["AG Gutfl."]['bezug'] - diff2 * share_gutfl + ) + + # Group 3: M3 Thiele / M3 Buntkowsky / M3 Gutfleisch + for cname in ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"]: + b13 = right_data[cname]['bezug'] + b12 = right_data[cname]['best_kannen_vormonat'] + b11 = right_data[cname]['summe_bestand'] + # Excel: P13+P12-P11 etc. + right_data[cname]['rueckf_soll'] = b13 + b12 - b11 + + # --- Verluste (Soll-Rückf.) (Lit. L-He) = B14 - B6 - B7 --- + for cname in RIGHT_CLIENTS: + b14 = right_data[cname]['rueckf_soll'] + b6 = right_data[cname]['rueckf_fluessig'] + b7 = right_data[cname]['sonder'] + right_data[cname]['verluste'] = b14 - b6 - b7 + + # --- Füllungen warm (Lit. L-He) = sum of monthly 'Füllungen warm' (row_index=11 top_right) --- + for cname in RIGHT_CLIENTS: + total_warm = Decimal('0') + for (y, m) in window: + sheet = sheets_by_ym.get((y, m)) + if sheet: + total_warm += get_top_right_value(sheet, cname, row_index=11) + right_data[cname]['fuellungen_warm'] = total_warm + + # --- Kaltgas Rückgabe (Lit. L-He) – Faktor = Bezug * 0.06 --- + for cname in RIGHT_CLIENTS: + b13 = right_data[cname]['bezug'] + right_data[cname]['kaltgas_rueckgabe'] = b13 * factor + + # --- Verbraucherverluste (Liter L-He) = Verluste - Kaltgas Rückgabe --- + for cname in RIGHT_CLIENTS: + b15 = right_data[cname]['verluste'] + b17 = right_data[cname]['kaltgas_rueckgabe'] + right_data[cname]['verbraucherverluste'] = b15 - b17 + + # --- % = Verbraucherverluste / Bezug --- + for cname in RIGHT_CLIENTS: + bezug = right_data[cname]['bezug'] + verb = right_data[cname]['verbraucherverluste'] + if bezug != 0: + right_data[cname]['percent'] = verb / bezug + else: + right_data[cname]['percent'] = None + + # Build RIGHT rows structure + right_row_defs = [ + ('Stand der Gaszähler (Vorjahr) (Nm³)', 'stand_prev_share'), + # We skip the pure-text "Gasrückführung (Nm³)" line here, + # because it’s only text (Aufteilung nach Verbrauch / Gaszähler) + # and easier to render directly in the template if needed. + ('Rückführung flüssig (Lit. L-He)', 'rueckf_fluessig'), + ('Sonderrückführungen (Lit. L-He)', 'sonder'), + ('Sammelrückführung (Lit. L-He)', 'sammel'), + ('Bestand in Kannen-1 (Lit. L-He)', 'bestand_kannen'), + ('Summe Bestand (Lit. L-He)', 'summe_bestand'), + ('Best. in Kannen Vormonat (Lit. L-He)', 'best_kannen_vormonat'), + ('Bezug (Liter L-He)', 'bezug'), + ('Rückführ. Soll (Lit. L-He)', 'rueckf_soll'), + ('Verluste (Soll-Rückf.) (Lit. L-He)', 'verluste'), + ('Füllungen warm (Lit. L-He)', 'fuellungen_warm'), + ('Kaltgas Rückgabe (Lit. L-He) – Faktor', 'kaltgas_rueckgabe'), + ('Verbraucherverluste (Liter L-He)', 'verbraucherverluste'), + ('%', 'percent'), + ] + + rows_right = [] + for label, key in right_row_defs: + values = [right_data[cname].get(key) for cname in RIGHT_CLIENTS] + + if key == 'percent': + total_bezug = sum((right_data[c]['bezug'] for c in RIGHT_CLIENTS), Decimal('0')) + total_verb = sum((right_data[c]['verbraucherverluste'] for c in RIGHT_CLIENTS), Decimal('0')) + total = (total_verb / total_bezug) if total_bezug != 0 else None + else: + total = sum((v for v in values if isinstance(v, Decimal)), Decimal('0')) + + rows_right.append({ + 'label': label, + 'values': values, + 'total': total, + 'is_percent': key == 'percent', + }) + SUM_TABLE_ROWS = [ + ("Rückführung flüssig (Lit. L-He)", "rueckf_fluessig"), + ("Sonderrückführungen (Lit. L-He)", "sonder"), + ("Sammelrückführungen (Lit. L-He)", "sammel"), + ("Bestand in Kannen-1 (Lit. L-He)", "bestand_kannen"), + ("Summe Bestand (Lit. L-He)", "summe_bestand"), + ("Best. in Kannen Vormonat (Lit. L-He)", "best_kannen_vormonat"), + ("Bezug (Liter L-He)", "bezug"), + ("Rückführ. Soll (Lit. L-He)", "rueckf_soll"), + ("Verluste (Soll-Rückf.) (Lit. L-He)", "verluste"), + ("Füllungen warm (Lit. L-He)", "fuellungen_warm"), + ("Kaltgas Rückgabe (Lit. L-He) – Faktor", "kaltgas_rueckgabe"), + ("Faktor 0.06", "factor_row"), + ("Verbraucherverluste (Liter L-He)", "verbraucherverluste"), + ("%", "percent"), + ] + + RIGHT_GROUPS = { + "chemie": ["Dr. Fohrer", "AG Buntk."], + "mawi": ["AG Alff", "AG Gutfl."], + "m3": ["M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"], + } + + RIGHT_ALL = ["Dr. Fohrer", "AG Buntk.", "AG Alff", "AG Gutfl.", "M3 Thiele", "M3 Buntkowsky", "M3 Gutfleisch"] + LEFT_ALL = HALFYEAR_CLIENTS_LEFT + + def safe_pct(verb, bez): + return (verb / bez) if bez != 0 else None + rows_sum = [] + def d(x): + return x if isinstance(x, Decimal) else Decimal("0") + for label, key in SUM_TABLE_ROWS: + + if key == "factor_row": + lichtwiese = chemie = mawi = m3 = total = Decimal("0.06") + + elif key == "percent": + # Right totals + rw_bez = sum(d(right_data[c].get("bezug")) for c in RIGHT_ALL) + rw_verb = sum(d(right_data[c].get("verbraucherverluste")) for c in RIGHT_ALL) + lichtwiese = safe_pct(rw_verb, rw_bez) + + # Chemie + ch_bez = sum(d(right_data[c].get("bezug")) for c in RIGHT_GROUPS["chemie"]) + ch_verb = sum(d(right_data[c].get("verbraucherverluste")) for c in RIGHT_GROUPS["chemie"]) + chemie = safe_pct(ch_verb, ch_bez) + + # MaWi + mw_bez = sum(d(right_data[c].get("bezug")) for c in RIGHT_GROUPS["mawi"]) + mw_verb = sum(d(right_data[c].get("verbraucherverluste")) for c in RIGHT_GROUPS["mawi"]) + mawi = safe_pct(mw_verb, mw_bez) + + # M3 + m3_bez = sum(d(right_data[c].get("bezug")) for c in RIGHT_GROUPS["m3"]) + m3_verb = sum(d(right_data[c].get("verbraucherverluste")) for c in RIGHT_GROUPS["m3"]) + m3 = safe_pct(m3_verb, m3_bez) + + # Σ column = (left verb + right verb) / (left bez + right bez) + left_bez = sum(d(client_data_left[c].get("bezug")) for c in LEFT_ALL) + left_verb = sum(d(client_data_left[c].get("verbraucherverluste")) for c in LEFT_ALL) + total = safe_pct(left_verb + rw_verb, left_bez + rw_bez) + + else: + # normal rows = sums + lichtwiese = sum(d(right_data[c].get(key)) for c in RIGHT_ALL) + chemie = sum(d(right_data[c].get(key)) for c in RIGHT_GROUPS["chemie"]) + mawi = sum(d(right_data[c].get(key)) for c in RIGHT_GROUPS["mawi"]) + m3 = sum(d(right_data[c].get(key)) for c in RIGHT_GROUPS["m3"]) + + left_total = sum(d(client_data_left[c].get(key)) for c in LEFT_ALL) + total = left_total + lichtwiese + + rows_sum.append({ + "row_index": row_index, + "label": label, + "total": total, + "lichtwiese": lichtwiese, + "chemie": chemie, + "mawi": mawi, + "m3": m3, + "is_percent": (key == "percent"), + }) + def find_sum_row(rows, label_startswith: str): + for r in rows: + if str(r.get("label", "")).strip().startswith(label_startswith): + return r + return None + + summe_bestand_row = find_sum_row(rows_sum, "Summe Bestand") + k38 = (summe_bestand_row.get("total") if summe_bestand_row else Decimal("0")) or Decimal("0") + j38 = k38 * Decimal("0.75") + # --- FIX: now that k38 is known, update bottom2 + recompute dependent rows --- + bottom2["k38"] = k38 + bottom2["j38"] = j38 + + k39 = bottom2.get("k39") or Decimal("0") + k40 = bottom2.get("k40") or Decimal("0") + + # Row 43: Bestand flüssig He = SUMME(K38:K40) + k43 = (k38 or Decimal("0")) + k39 + k40 + j43 = k43 * Decimal("0.75") + + bottom2["k43"] = k43 + bottom2["j43"] = j43 + + # Row 44: Gesamtbestand neu = Gasbestand(Lit) from bottom table 1 + k43 + gasbestand_lit = Decimal("0") + for r in bottom1_rows: + if (r.get("label") or "").strip().startswith("Gasbestand"): + gasbestand_lit = r.get("lhe") or Decimal("0") + break + + k44 = gasbestand_lit + k43 + j44 = k44 * Decimal("0.75") + + bottom2["k44"] = k44 + bottom2["j44"] = j44 + def d(x): + return x if isinstance(x, Decimal) else Decimal("0") + + # ---- Bottom2: J38/K38 depend on rows_sum (overall summary), so do it HERE ---- + k38 = Decimal("0") + for r in rows_sum: + if r.get("label") == "Summe Bestand (Lit. L-He)": + k38 = r.get("total") or Decimal("0") + break + + j38 = k38 * FACTOR_NM3_TO_LHE # 0.75 + bottom2["k38"] = k38 + bottom2["j38"] = j38 + factor = Decimal("0.75") + + # window = the 6-month list you already build in this view: [(y,m), (y,m), ...] + # bottom2 = dict with "k44" already computed in your view + # rows_sum = overall sum group rows list (your existing halfyear logic) + + # 1) K46 = K44 from the month BEFORE the global month (interval start) + start_year = interval_year + start_month = interval_start # whatever you named your start month variable + + if start_month == 1: + prev_year, prev_month = start_year - 1, 12 + else: + prev_year, prev_month = start_year, start_month - 1 + + prev_sheet = MonthlySheet.objects.filter(year=prev_year, month=prev_month).first() + + # This assumes you have MonthlySummary.gesamtbestand_neu_lhe as K44 equivalent. + # If your field name differs, tell me your MonthlySummary model fields. + prev_k44 = Decimal("0") + if prev_sheet: + prev_sum = MonthlySummary.objects.filter(sheet=prev_sheet).first() + if prev_sum and prev_sum.gesamtbestand_neu_lhe is not None: + prev_k44 = Decimal(str(prev_sum.gesamtbestand_neu_lhe)) + + # helpers: read bottom_3 values for a given sheet/month + def get_bottom3_value(sheet, row_index, col_index): + if not sheet: + return Decimal("0") + c = Cell.objects.filter( + sheet=sheet, table_type="bottom_3", + row_index=row_index, column_index=col_index + ).first() + if not c or c.value in (None, "", "None"): + return Decimal("0") + try: + return Decimal(str(c.value)) + except Exception: + return Decimal("0") + + # 2) Sum rows across the 6-month window + g47_sum = Decimal("0") + i47_sum = Decimal("0") + j47_sum = Decimal("0") + + g50_sum = Decimal("0") + i50_sum = Decimal("0") + + for (yy, mm) in window: + s = MonthlySheet.objects.filter(year=yy, month=mm).first() + ## Excel row 47 maps to bottom_3 row_index=1 in DB (see monthly_sheet.html) + g = get_bottom3_value(s, 1, 1) # G47 editable + i = get_bottom3_value(s, 1, 2) # I47 editable + + g47_sum += g + i47_sum += i + + # In monthly_sheet, J47 is CALCULATED as (G47 + I47), not stored as an editable cell + j47_sum += (g + i) + + # row 50: G(2), I(4) + g50_sum += get_bottom3_value(s, 50, 2) + i50_sum += get_bottom3_value(s, 50, 4) + + # 3) K52 = Verbraucherverlust from overall sum group first column (global Σ) + k52 = Decimal("0") + for r in rows_sum: + label = (r.get("label") or "") + if label.startswith("Verbraucherverluste"): + k52 = r.get("total") or Decimal("0") + break + + # --- apply the SAME monthly formulas, with your overrides --- + # Row 46 + k46 = prev_k44 + j46 = k46 * factor + + # Row 47 + g47 = g47_sum + i47 = i47_sum + j47 = j47_sum + k47 = (j47 / factor) + g47 if (j47 != 0 or g47 != 0) else Decimal("0") + + # Row 48 + k48 = k46 + k47 + j48 = k48 * factor + + # Row 49 (akt. Monat) -> in halfyear we use current bottom2 K44 + k49 = bottom2.get("k44") or Decimal("0") + j49 = k49 * factor + + # Row 50 + g50 = g50_sum + i50 = i50_sum + j50 = i50 + k50 = (j50 / factor) if j50 != 0 else Decimal("0") + + # Row 51 + k51 = k48 - k49 - k50 + j51 = k51 * factor + + # Row 52 + j52 = k52 * factor + + # Row 53 + k53 = k51 - k52 + j53 = j51 - j52 + + bottom3 = { + "j46": j46, "k46": k46, + "g47": g47, "i47": i47, "j47": j47, "k47": k47, + "j48": j48, "k48": k48, + "j49": j49, "k49": k49, + "g50": g50, "i50": i50, "j50": j50, "k50": k50, + "j51": j51, "k51": k51, + "j52": j52, "k52": k52, + "j53": j53, "k53": k53, + } + # ------------------------------------------------------------------ + # 4) Context – keep old keys AND new ones + # ------------------------------------------------------------------ + context = { + 'interval_year': interval_year, + 'interval_start_month': interval_start, + 'window': window, + + 'clients': HALFYEAR_CLIENTS_LEFT, + 'rows': rows_left, + + 'clients_left': HALFYEAR_CLIENTS_LEFT, + 'rows_left': rows_left, + + 'clients_right': RIGHT_CLIENTS, + 'rows_right': rows_right, + 'rows_sum': rows_sum, + 'bottom1_rows': bottom1_rows, + } + context["bottom2"] = bottom2 + context["bottom3"] = bottom3 + context["context_bottom2_g39"] = bottom2_inputs["g39"] + context["context_bottom2_i39"] = bottom2_inputs["i39"] + return context \ No newline at end of file diff --git a/sheets/templates/Rechnung.html b/sheets/templates/Rechnung.html new file mode 100644 index 0000000..7f6679a --- /dev/null +++ b/sheets/templates/Rechnung.html @@ -0,0 +1,123 @@ +{% extends "base.html" %} +{% block content %} +
+ + +
+ ← Übersicht +

Rechnung

+ Abrechnung +
+ + {% if needs_interval %} +
+

Bitte zuerst ein 6-Monats-Intervall auf der Übersichtsseite auswählen.

+
+ {% else %} + +
+ + + + + + + + + + + + + + + + + + + + + + + + {% for r in rows %} + + + + + + + + + + + + + + + + + + + + + {% endfor %} + +
ArbeitsgruppeTabellenkürzelNameHeliumverluste (€)Heliumverluste (l)Verbr. u. Repar. (€)Gesamtverflüssigung (l)Preis (€/l)Bezogene Menge (l)Verflüssigungskosten (€)Verlustkosten (€)Gaskosten (€)Gutschriften (€)Personalkosten (€)Anteil Personalkosten (€)Gesamtkosten (€)
{{ r.arbeitsgruppe }}{{ r.tabellenkuerzel }}{{ r.name }}{{ r.heliumverluste_eur|floatformat:2 }}{{ r.heliumverluste_l|floatformat:2 }}{{ r.verbr_u_repar_eur|floatformat:2 }}{{ r.gesamtverfluessigung_l|floatformat:2 }}{{ r.preis_eur_l|floatformat:4 }}{{ r.bezogene_menge_l|floatformat:2 }}{{ r.verfluessigungskosten_eur|floatformat:2 }}{{ r.verlustkosten_eur|floatformat:2 }}{{ r.gaskosten_eur|floatformat:2 }}{{ r.gutschriften_eur|floatformat:2 }}{{ r.personalkosten_eur|floatformat:2 }}{{ r.anteil_personal_eur|floatformat:2 }}{{ r.gesamtkosten_eur|floatformat:2 }}
+
+ + {% endif %} +
+{% endblock %} diff --git a/sheets/templates/abrechnung.html b/sheets/templates/abrechnung.html new file mode 100644 index 0000000..015d8c1 --- /dev/null +++ b/sheets/templates/abrechnung.html @@ -0,0 +1,370 @@ +{% extends "base.html" %} +{% load dict_extras %} +{% block content %} +
+ + + +
+ ← Übersicht +

Abrechnung

+ Halbjahres-Bilanz +
+
+ {% if needs_interval %} +
+

Bitte zuerst ein Halbjahr auswählen (auf der Übersicht-Seite), damit der Zeitraum bekannt ist.

+
+ {% else %} +
+ +
+ Aufstellung Heliumverbrauch für Zeitraum: {{ interval_text }} +
+
+
+ + + + + + {% for col_key, col_label in columns %} + + {% endfor %} + + + + + + + {% for r in rows %} + + + + {% for c in r.cells %} + + {% endfor %} + + + + {% endfor %} + + + +
← Fachgebiet →{{ col_label }}Einheit
{{ r.label }} + {% if r.row_key == "sonstiges" %} + + {{ sonstiges_text|get_item:c.col_key }} + + {% else %} + {% if r.editable %} + + {% else %} + + {% if c.value is not None %}{{ c.value|floatformat:2 }}{% endif %} + + {% endif %} + {% endif %} + {{ r.unit }}
+
+ +
+ + + + + + + + + + + + + {% for r in right_summary_rows %} + + + + + + + + + + + + {% endfor %} + +
Summe Lichtwiese
IJKL
+ ← Fachgebiet → + Summe Lichtwiese
NOP (Check)
Summe
Stadtmitte
Summe
(Check)
+ {% if r.is_text %}{{ r.ijkl }}{% else %}{{ r.ijkl|floatformat:2 }}{% endif %} + + {{ r.label }} + {% if r.unit %} + {{ r.unit }} + {% endif %} + + {% if r.is_text %}{{ r.nop }}{% else %}{{ r.nop|floatformat:2 }}{% endif %} + + {% if r.is_text %}{{ r.stadt }}{% else %}{{ r.stadt|floatformat:2 }}{% endif %} + + {% if r.is_text %}{{ r.check }}{% else %}{{ r.check|floatformat:2 }}{% endif %} +
+ +
+ +
+ + + +
+ + + + + + +{% endif %} +
+{% endblock %} diff --git a/sheets/templates/betriebskosten_list.html b/sheets/templates/betriebskosten_list.html index a745799..7d9ab68 100644 --- a/sheets/templates/betriebskosten_list.html +++ b/sheets/templates/betriebskosten_list.html @@ -35,15 +35,19 @@ text-align: center; border-bottom: 1px solid #ddd; } - th:nth-child(1), td:nth-child(1) { width: 5%; } /* # column */ - th:nth-child(2), td:nth-child(2) { width: 10%; } /* Buchungsdatum */ - th:nth-child(3), td:nth-child(3) { width: 15%; } /* Rechnungsnummer */ - th:nth-child(4), td:nth-child(4) { width: 10%; } /* Kostentyp */ - th:nth-child(5), td:nth-child(5) { width: 10%; } /* Gasvolumen */ - th:nth-child(6), td:nth-child(6) { width: 10%; } /* Betrag */ - th:nth-child(7), td:nth-child(7) { width: 25%; } /* Beschreibung */ - th:nth-child(8), td:nth-child(8) { width: 15%; } /* Actions */ - + th:nth-child(1), td:nth-child(1) { width: 4%; } + th:nth-child(2), td:nth-child(2) { width: 12%; } + th:nth-child(3), td:nth-child(3) { width: 8%; } + th:nth-child(4), td:nth-child(4) { width: 12%; } + th:nth-child(5), td:nth-child(5) { width: 8%; } + th:nth-child(6), td:nth-child(6) { width: 8%; } + th:nth-child(7), td:nth-child(7) { width: 8%; } + th:nth-child(8), td:nth-child(8) { width: 8%; } + th:nth-child(9), td:nth-child(9) { width: 8%; } + th:nth-child(10), td:nth-child(10) { width: 8%; } + th:nth-child(11), td:nth-child(11) { width: 12%; } + th:nth-child(12), td:nth-child(12) { width: 8%; } + .actions { white-space: nowrap; /* Prevent buttons from wrapping */ } @@ -147,74 +151,126 @@

Betriebskosten

- - - - - - - - - - - - - - - - {% for item in items %} - - - - - - - - - - - {% empty %} - - - - {% endfor %} - +
+ + +
+ +
#BuchungsdatumRechnungsnummerKostentypGasvolumen (L)Betrag (€)BeschreibungActions
{{ forloop.counter }}{{ item.buchungsdatum|date:"Y-m-d" }}{{ item.rechnungsnummer }}{{ item.get_kostentyp_display }}{{ item.gas_volume|default_if_none:"-" }}{{ item.betrag }}{{ item.beschreibung|default:"" }} - - -
No entries found
+ + + + + + + + + + + + + + + + + + + +
Instandhaltung{{ summary.instandhaltung|floatformat:2 }} €
Verflüssigungskosten L-He{{ verfluessigungskosten_lhe|floatformat:6 }} €/L-He
Heliumkosten{{ summary.heliumkosten|floatformat:2 }} €
Bezugskosten -GasHe{{ summary.bezugskosten_gashe|floatformat:2 }} €/m³
Umlage Personal{{ summary.umlage_personal|floatformat:2 }} €
+ + + + + + + + + + + + + + + + + + + + + + {% for item in items %} + + + + + + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
#GegenstandZahlungsdatumFirmaKostentypGasvolumen (m³)Gasvolumen (Liter)Preis pro m³ (€)Preis pro Liter (€)Betrag (€)BeschreibungActions
{{ forloop.counter }}{{ item.gegenstand }}{{ item.buchungsdatum|date:"Y-m-d" }}{{ item.rechnungsnummer }}{{ item.get_kostentyp_display }}{{ item.gas_volume|default_if_none:"-" }}{% if item.gas_volume %}{{ item.gas_volume_liter|floatformat:2 }}{% else %}-{% endif %}{% if item.price_per_m3 %}{{ item.price_per_m3|floatformat:2 }}{% else %}-{% endif %}{% if item.price_per_liter %}{{ item.price_per_liter|floatformat:2 }}{% else %}-{% endif %}{{ item.betrag }}{{ item.beschreibung|default:"" }} + + +
+ No entries found +
+