#!/usr/bin/env python3 """ Import BreakPilot Finanzplan Excel into PostgreSQL fp_* tables. Usage: python3 scripts/import-finanzplan.py Requires: pip3 install openpyxl psycopg2-binary """ import sys import json import os from datetime import date, datetime import openpyxl import psycopg2 from psycopg2.extras import Json # --- Config --- DB_URL = os.environ.get('DATABASE_URL', 'postgresql://breakpilot:breakpilot@localhost:5432/breakpilot_db') MONTHS = 60 # Jan 2026 – Dec 2030 # Excel columns: D=4 (month 1) through BQ=69 (month 60, approx) # Actually: D=m1(Jan2026), E=m2(Feb2026), ..., O=m12(Dec2026), # Q=m13(Jan2027), ..., AB=m24(Dec2027), etc. # Year-column (C, P, AC, AP, BC) = annual sums, skip those # Monthly columns per year: D-O (12), Q-AB (12), AD-AO (12), AQ-BB (12), BD-BO (12) # Map: month_index (1-60) -> Excel column index (1-based) def build_month_columns(): """Build mapping from month 1-60 to Excel column index.""" cols = [] # Year 1 (2026): cols D(4) - O(15) = 12 months for c in range(4, 16): cols.append(c) # Year 2 (2027): cols Q(17) - AB(28) = 12 months for c in range(17, 29): cols.append(c) # Year 3 (2028): cols AD(30) - AO(41) = 12 months for c in range(30, 42): cols.append(c) # Year 4 (2029): cols AQ(43) - BB(54) = 12 months for c in range(43, 55): cols.append(c) # Year 5 (2030): cols BD(56) - BO(67) = 12 months for c in range(56, 68): cols.append(c) return cols MONTH_COLS = build_month_columns() def read_monthly_values(ws, row, data_only=True): """Read 60 monthly values from an Excel row.""" values = {} for m_idx, col in enumerate(MONTH_COLS): v = ws.cell(row, col).value if v is not None and v != '' and not isinstance(v, str): try: values[f'm{m_idx+1}'] = round(float(v), 2) except (ValueError, TypeError): values[f'm{m_idx+1}'] = 0 else: values[f'm{m_idx+1}'] = 0 return values def safe_str(v): if v is None: return '' return str(v).strip() def safe_float(v, default=0): if v is None: return default try: return float(v) except (ValueError, TypeError): return default def safe_date(v): if v is None: return None if isinstance(v, datetime): return v.date() if isinstance(v, date): return v return None def import_personalkosten(cur, ws, scenario_id): """Import Personalkosten sheet (rows 10-29 = 20 positions).""" print(" Importing Personalkosten...") count = 0 for i in range(20): row = 10 + i name = safe_str(ws.cell(row, 1).value) nr = safe_str(ws.cell(row, 2).value) position = safe_str(ws.cell(row, 3).value) start = safe_date(ws.cell(row, 4).value) end = safe_date(ws.cell(row, 5).value) brutto = safe_float(ws.cell(row, 7).value) raise_pct = safe_float(ws.cell(row, 8).value, 3.0) if not name and not nr: continue # Read computed monthly totals from the "Personalaufwand" section # The actual monthly values are in a different row range (rows 32-51 for brutto, 56-75 for sozial) # But we store the inputs and let the engine compute values_total = {} # will be computed by engine cur.execute(""" INSERT INTO fp_personalkosten (scenario_id, person_name, person_nr, position, start_date, end_date, brutto_monthly, annual_raise_pct, is_editable, values_brutto, values_sozial, values_total, excel_row, sort_order) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s, %s) """, (scenario_id, name, nr, position, start, end, brutto, raise_pct, Json({}), Json({}), Json({}), row, i + 1)) count += 1 print(f" -> {count} Positionen importiert") def import_betriebliche_aufwendungen(cur, ws, scenario_id): """Import Betriebliche Aufwendungen (rows 3-47).""" print(" Importing Betriebliche Aufwendungen...") # Define the structure based on Excel analysis rows_config = [ (3, 'personal', 'Personalkosten', False, True, '=Personalkosten total'), (4, 'raumkosten', 'Raumkosten', True, False, None), (5, 'steuern', 'Betriebliche Steuern', False, True, '=SUM(6,7)'), (6, 'steuern', 'Gewerbesteuer', True, False, None), (7, 'steuern', 'KFZ-Steuern', True, False, None), (8, 'versicherungen', 'Versich./Beitraege', False, True, '=SUM(9:18)'), (9, 'versicherungen', 'IHK', True, False, None), (10, 'versicherungen', 'Rundfunkbeitrag', True, False, None), (11, 'versicherungen', 'Berufsgenossenschaft', True, False, None), (12, 'versicherungen', 'Bundesanzeiger/Transparenzregister', True, False, None), (13, 'versicherungen', 'D&O-Versicherung', True, False, None), (14, 'versicherungen', 'E&O-Versicherung', True, False, None), (15, 'versicherungen', 'Produkthaftpflicht', True, False, None), (16, 'versicherungen', 'Cyber-Versicherung', True, False, None), (17, 'versicherungen', 'Rechtsschutzversicherung', True, False, None), (18, 'versicherungen', 'KFZ-Versicherung', True, False, None), (19, 'besondere', 'Besondere Kosten', False, True, '=SUM(20:22)'), (20, 'besondere', 'Schutzrechte/Lizenzkosten', True, False, None), (21, 'besondere', 'Marketing Videos', True, False, None), (22, 'besondere', 'Fort-/Weiterbildungskosten', True, False, None), (23, 'fahrzeug', 'Fahrzeugkosten', True, False, None), (24, 'marketing', 'Werbe-/Reisekosten', False, True, '=SUM(25:30)'), (25, 'marketing', 'Reisekosten', True, False, None), (26, 'marketing', 'Teilnahme an Messen', True, False, None), (27, 'marketing', 'Allgemeine Marketingkosten', True, False, None), (28, 'marketing', 'Marketing-Agentur', True, False, None), (29, 'marketing', 'Editorial Content', True, False, None), (30, 'marketing', 'Bewirtungskosten', True, False, None), (31, 'warenabgabe', 'Kosten Warenabgabe', True, False, None), (32, 'abschreibungen', 'Abschreibungen', False, False, '=Investitionen AfA'), (33, 'reparatur', 'Reparatur/Instandh.', True, False, None), (34, 'sonstige', 'Sonstige Kosten', False, True, '=SUM(35:45)'), (35, 'sonstige', 'Telefon', True, False, None), (36, 'sonstige', 'Bankgebuehren', True, False, None), (37, 'sonstige', 'Buchfuehrung', True, False, None), (38, 'sonstige', 'Jahresabschluss', True, False, None), (39, 'sonstige', 'Rechts-/Beratungskosten', True, False, None), (40, 'sonstige', 'Werkzeuge/Kleingeraete', True, False, None), (41, 'sonstige', 'Serverkosten (Cloud)', True, False, None), (42, 'sonstige', 'Verbrauchsmaterialien', True, False, None), (43, 'sonstige', 'Mietkosten Software', True, False, None), (44, 'sonstige', 'Nebenkosten Geldverkehr', True, False, None), (46, 'summe', 'Summe sonstige (ohne Pers., Abschr.)', False, True, '=computed'), (47, 'summe', 'Gesamtkosten (Klasse 6)', False, True, '=computed'), ] count = 0 for idx, (row, cat, label, editable, is_sum, formula) in enumerate(rows_config): values = read_monthly_values(ws, row) cur.execute(""" INSERT INTO fp_betriebliche_aufwendungen (scenario_id, category, row_label, row_index, is_editable, is_sum_row, formula_desc, values, excel_row, sort_order) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, (scenario_id, cat, label, row, editable, is_sum, formula, Json(values), row, idx + 1)) count += 1 print(f" -> {count} Kostenposten importiert") def import_investitionen(cur, ws, scenario_id): """Import Investitionen (rows 6-42).""" print(" Importing Investitionen...") count = 0 for i in range(37): row = 6 + i name = safe_str(ws.cell(row, 1).value) amount = safe_float(ws.cell(row, 2).value) purchase = safe_date(ws.cell(row, 3).value) afa_years = safe_float(ws.cell(row, 4).value) afa_end = safe_date(ws.cell(row, 5).value) if not name and amount == 0: continue # Read monthly investment values (col G onwards in Investitionen sheet) # This sheet has different column mapping — dates as headers # For simplicity, store purchase amount and let engine compute AfA cur.execute(""" INSERT INTO fp_investitionen (scenario_id, item_name, category, purchase_amount, purchase_date, afa_years, afa_end_date, is_editable, values_invest, values_afa, excel_row, sort_order) VALUES (%s, %s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) """, (scenario_id, name, 'ausstattung' if 'Ausstattung' in name else 'gwg', amount, purchase, int(afa_years) if afa_years else None, afa_end, Json({}), Json({}), row, count + 1)) count += 1 print(f" -> {count} Investitionsgueter importiert") def import_sonst_ertraege(cur, ws, scenario_id): """Import Sonst. betr. Ertraege (6 categories).""" print(" Importing Sonst. betr. Ertraege...") categories = [ (3, 8, 'Interne Kostenstellen'), (9, 12, 'Entwicklung National'), (13, 16, 'Entwicklung International'), (17, 20, 'Beratungsdienstleistung'), (21, 24, 'Zuwendungen'), (25, 28, 'TBD'), ] count = 0 for sum_row, end_row, cat_name in categories: # Sum row values = read_monthly_values(ws, sum_row) cur.execute(""" INSERT INTO fp_sonst_ertraege (scenario_id, category, row_label, row_index, is_editable, is_sum_row, values, excel_row, sort_order) VALUES (%s, %s, %s, %s, false, true, %s, %s, %s) """, (scenario_id, cat_name, cat_name, sum_row, Json(values), sum_row, count + 1)) count += 1 # Detail rows for r in range(sum_row + 1, end_row + 1): values = read_monthly_values(ws, r) label = safe_str(ws.cell(r, 1).value) or f'Position {r - sum_row}' cur.execute(""" INSERT INTO fp_sonst_ertraege (scenario_id, category, row_label, row_index, is_editable, is_sum_row, values, excel_row, sort_order) VALUES (%s, %s, %s, %s, true, false, %s, %s, %s) """, (scenario_id, cat_name, label, r, Json(values), r, count + 1)) count += 1 # Total row (29) values = read_monthly_values(ws, 29) cur.execute(""" INSERT INTO fp_sonst_ertraege (scenario_id, category, row_label, row_index, is_editable, is_sum_row, values, excel_row, sort_order) VALUES (%s, %s, %s, %s, false, true, %s, %s, %s) """, (scenario_id, 'GESAMT', 'GESAMTUMSATZ', 29, Json(values), 29, count + 1)) print(f" -> {count + 1} Ertragsposten importiert") def import_liquiditaet(cur, ws, scenario_id): """Import Liquiditaet (rows 4-27).""" print(" Importing Liquiditaet...") rows_config = [ (4, 'Umsatzerloese', 'einzahlung', False, '=Umsatzerloese!GESAMT'), (5, 'Sonst. betriebl. Ertraege', 'einzahlung', False, '=Sonst.Ertraege!GESAMT'), (6, 'Anzahlungen', 'einzahlung', True, None), (7, 'Neuer Eigenkapitalzugang', 'einzahlung', True, None), (8, 'Erhaltenes Fremdkapital', 'einzahlung', True, None), (9, 'Summe EINZAHLUNGEN', 'einzahlung', False, '=SUM(4:8)'), (12, 'Materialaufwand', 'auszahlung', False, '=Materialaufwand!SUMME'), (13, 'Personalkosten', 'auszahlung', False, '=Personalkosten!Total'), (14, 'Sonstige Kosten', 'auszahlung', False, '=Betriebliche!Summe_sonstige'), (15, 'Kreditrueckzahlungen', 'auszahlung', True, None), (16, 'Umsatzsteuer', 'auszahlung', True, None), (17, 'Gewerbesteuer', 'auszahlung', True, None), (18, 'Koerperschaftsteuer', 'auszahlung', True, None), (19, 'Summe AUSZAHLUNGEN', 'auszahlung', False, '=SUM(12:18)'), (21, 'UEBERSCHUSS VOR INVESTITIONEN', 'ueberschuss', False, '=Einzahlungen-Auszahlungen'), (22, 'Investitionen', 'ueberschuss', False, '=Investitionen!Gesamt'), (23, 'UEBERSCHUSS VOR ENTNAHMEN', 'ueberschuss', False, '=21-22'), (24, 'Kapitalentnahmen/Ausschuettungen', 'ueberschuss', True, None), (25, 'UEBERSCHUSS', 'ueberschuss', False, '=23-24'), (26, 'Kontostand zu Beginn des Monats', 'kontostand', False, '=prev_month_liquiditaet'), (27, 'LIQUIDITAET', 'kontostand', False, '=26+25'), ] count = 0 for row, label, row_type, editable, formula in rows_config: values = read_monthly_values(ws, row) cur.execute(""" INSERT INTO fp_liquiditaet (scenario_id, row_label, row_type, is_editable, formula_desc, values, excel_row, sort_order) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) """, (scenario_id, label, row_type, editable, formula, Json(values), row, count + 1)) count += 1 print(f" -> {count} Liquiditaetszeilen importiert") def import_kunden(cur, ws, scenario_id): """Import Kunden (6 segments, rows 26-167).""" print(" Importing Kunden...") # Summary rows (4-23) = aggregation across segments for i in range(20): row = 4 + i label = safe_str(ws.cell(row, 1).value) if not label: label = f'Produkt {i + 1}' values = read_monthly_values(ws, row) cur.execute(""" INSERT INTO fp_kunden_summary (scenario_id, row_label, row_index, values, excel_row, sort_order) VALUES (%s, %s, %s, %s, %s, %s) """, (scenario_id, label, row, Json(values), row, i + 1)) # 6 segments, each starts 24 rows apart: 26, 50, 74, 98, 122, 146 segment_starts = [ (26, 'Care (Privat)'), (50, 'Horse (Haendler)'), (74, 'Segment 3'), (98, 'Segment 4'), (122, 'Segment 5'), (146, 'Segment 6'), ] # Read segment names from Excel for idx, (start_row, default_name) in enumerate(segment_starts): seg_name = safe_str(ws.cell(start_row, 1).value) or default_name # Each segment: row+2 = header, row+2..row+21 = module rows module_start = start_row + 2 # row 28, 52, 76, 100, 124, 148 count = 0 for m in range(20): row = module_start + m label = safe_str(ws.cell(row, 1).value) if not label: continue pct = safe_float(ws.cell(row, 2).value) pct_label = safe_str(ws.cell(row, 2).value) values = read_monthly_values(ws, row) # First module per segment (m=0) is the base editable input is_base = (m == 0) formula_type = None if pct_label == 'gerechnet': formula_type = 'cumulative' elif pct > 0 and not is_base: formula_type = 'rounddown_pct' cur.execute(""" INSERT INTO fp_kunden (scenario_id, segment_name, segment_index, row_label, row_index, percentage, formula_type, is_editable, values, excel_row, sort_order) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, (scenario_id, seg_name, idx + 1, label, row, pct if pct else None, formula_type, is_base, Json(values), row, count + 1)) count += 1 print(f" -> Kunden importiert (6 Segmente)") def import_umsatzerloese(cur, ws, scenario_id): """Import Umsatzerloese (revenue, quantity, prices).""" print(" Importing Umsatzerloese...") count = 0 # Revenue rows (3-23): computed = quantity * price for i in range(21): row = 3 + i label = safe_str(ws.cell(row, 1).value) or f'Produkt {i+1}' if not label.strip(): continue values = read_monthly_values(ws, row) cur.execute(""" INSERT INTO fp_umsatzerloese (scenario_id, section, row_label, row_index, is_editable, values, excel_row, sort_order) VALUES (%s, 'revenue', %s, %s, false, %s, %s, %s) """, (scenario_id, label, row, Json(values), row, count + 1)) count += 1 # Total row (24) values = read_monthly_values(ws, 24) cur.execute(""" INSERT INTO fp_umsatzerloese (scenario_id, section, row_label, row_index, is_editable, values, excel_row, sort_order) VALUES (%s, 'revenue', 'GESAMTUMSATZ', 24, false, %s, 24, %s) """, (scenario_id, Json(values), count + 1)) count += 1 # Quantity rows (27-46): from Kunden for i in range(20): row = 27 + i label = safe_str(ws.cell(row, 1).value) or f'Produkt {i+1}' if not label.strip(): continue values = read_monthly_values(ws, row) cur.execute(""" INSERT INTO fp_umsatzerloese (scenario_id, section, row_label, row_index, is_editable, values, excel_row, sort_order) VALUES (%s, 'quantity', %s, %s, false, %s, %s, %s) """, (scenario_id, label, row, Json(values), row, count + 1)) count += 1 # Price rows (49-73): editable VK prices for i in range(25): row = 49 + i label = safe_str(ws.cell(row, 1).value) or f'Produkt {i+1}' if not label.strip(): continue values = read_monthly_values(ws, row) cur.execute(""" INSERT INTO fp_umsatzerloese (scenario_id, section, row_label, row_index, is_editable, values, excel_row, sort_order) VALUES (%s, 'price', %s, %s, true, %s, %s, %s) """, (scenario_id, label, row, Json(values), row, count + 1)) count += 1 print(f" -> {count} Umsatzzeilen importiert") def import_materialaufwand(cur, ws, scenario_id): """Import Materialaufwand (simplified: only Mac Mini + Mac Studio).""" print(" Importing Materialaufwand...") count = 0 # Cost rows (3-23): computed = quantity * unit_cost for i in range(21): row = 3 + i label = safe_str(ws.cell(row, 1).value) if not label: continue values = read_monthly_values(ws, row) cur.execute(""" INSERT INTO fp_materialaufwand (scenario_id, section, row_label, row_index, is_editable, values, excel_row, sort_order) VALUES (%s, 'cost', %s, %s, false, %s, %s, %s) """, (scenario_id, label, row, Json(values), row, count + 1)) count += 1 # Total (24) values = read_monthly_values(ws, 24) cur.execute(""" INSERT INTO fp_materialaufwand (scenario_id, section, row_label, row_index, is_editable, values, excel_row, sort_order) VALUES (%s, 'cost', 'SUMME', 24, false, %s, 24, %s) """, (scenario_id, Json(values), count + 1)) count += 1 # Unit cost rows (51-73): editable EK prices for i in range(23): row = 51 + i label = safe_str(ws.cell(row, 1).value) if not label: continue values = read_monthly_values(ws, row) cur.execute(""" INSERT INTO fp_materialaufwand (scenario_id, section, row_label, row_index, is_editable, values, excel_row, sort_order) VALUES (%s, 'unit_cost', %s, %s, true, %s, %s, %s) """, (scenario_id, label, row, Json(values), row, count + 1)) count += 1 print(f" -> {count} Materialzeilen importiert (Mac Mini 3.200 / Mac Studio 13.000 EK)") def import_guv(cur, ws, scenario_id): """Import GuV Jahresabschluss (annual summary).""" print(" Importing GuV Jahresabschluss...") # Annual columns: B=2026, C=2027, D=2028, E=2029, F=2030 year_cols = {2: 'y2026', 3: 'y2027', 4: 'y2028', 5: 'y2029', 6: 'y2030'} rows_config = [ (3, 'Umsatzerloese', False, '=Umsatzerloese!Jahressumme'), (4, 'Bestandsveraenderungen', True, None), (5, 'Gesamtleistung', False, '=SUM(3:4)'), (9, 'Sonst. betriebl. Ertraege', False, '=Sonst.Ertraege!Jahressumme'), (10, 'Summe sonst. Ertraege', False, '=SUM(8:9)'), (13, 'Materialaufwand Waren', False, '=Materialaufwand!Jahressumme'), (14, 'Materialaufwand Leistungen', False, '=Materialaufwand!bezogene_Leistungen'), (15, 'Summe Materialaufwand', False, '=13+14'), (17, 'Rohergebnis', False, '=5+10-15'), (20, 'Loehne und Gehaelter', False, '=Personalkosten!Brutto'), (21, 'Soziale Abgaben', False, '=Personalkosten!Sozial'), (22, 'Summe Personalaufwand', False, '=20+21'), (25, 'Abschreibungen', False, '=Investitionen!AfA'), (27, 'Sonst. betriebl. Aufwendungen', False, '=Betriebliche!Summe_sonstige'), (29, 'EBIT', False, '=5+10-15-22-25-27'), (31, 'Zinsertraege', True, None), (33, 'Zinsaufwendungen', True, None), (35, 'Steuern gesamt', False, '=36+37'), (36, 'Koerperschaftssteuer', False, '=computed'), (37, 'Gewerbesteuer', False, '=computed'), (39, 'Ergebnis nach Steuern', False, '=29+31-33-35'), (41, 'Sonstige Steuern', True, None), (43, 'Jahresueberschuss', False, '=39-41'), ] count = 0 for row, label, is_edit, formula in rows_config: values = {} for col, key in year_cols.items(): v = ws.cell(row, col).value values[key] = round(float(v), 2) if v and not isinstance(v, str) else 0 cur.execute(""" INSERT INTO fp_guv (scenario_id, row_label, row_index, is_sum_row, formula_desc, values, excel_row, sort_order) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) """, (scenario_id, label, row, formula is not None and not is_edit, formula, Json(values), row, count + 1)) count += 1 print(f" -> {count} GuV-Zeilen importiert") def main(): if len(sys.argv) < 2: print("Usage: python3 import-finanzplan.py ") sys.exit(1) xlsx_path = sys.argv[1] print(f"Opening: {xlsx_path}") wb = openpyxl.load_workbook(xlsx_path, data_only=True) print(f"Connecting to: {DB_URL.split('@')[1] if '@' in DB_URL else DB_URL}") conn = psycopg2.connect(DB_URL) cur = conn.cursor() # Schema already applied separately — skip if tables exist cur.execute("SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = 'fp_scenarios')") if not cur.fetchone()[0]: schema_path = os.path.join(os.path.dirname(__file__), '001_finanzplan_tables.sql') print(f"Applying schema: {schema_path}") with open(schema_path) as f: cur.execute(f.read()) conn.commit() else: print("Schema already exists, skipping.") # Get or create default scenario cur.execute("SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1") row = cur.fetchone() if row: scenario_id = row[0] # Clear existing data for re-import for table in ['fp_kunden', 'fp_kunden_summary', 'fp_umsatzerloese', 'fp_materialaufwand', 'fp_personalkosten', 'fp_betriebliche_aufwendungen', 'fp_investitionen', 'fp_sonst_ertraege', 'fp_liquiditaet', 'fp_guv']: cur.execute(f"DELETE FROM {table} WHERE scenario_id = %s", (scenario_id,)) else: cur.execute("INSERT INTO fp_scenarios (name, is_default) VALUES ('Base Case', true) RETURNING id") scenario_id = cur.fetchone()[0] print(f"Scenario ID: {scenario_id}") print(f"\nImporting sheets...") # Import each sheet import_kunden(cur, wb['Kunden'], scenario_id) import_umsatzerloese(cur, wb['Umsatzerlöse'], scenario_id) import_materialaufwand(cur, wb['Materialaufwand'], scenario_id) import_personalkosten(cur, wb['Personalkosten'], scenario_id) import_betriebliche_aufwendungen(cur, wb['Betriebliche Aufwendungen'], scenario_id) import_investitionen(cur, wb['Investitionen'], scenario_id) import_sonst_ertraege(cur, wb['Sonst. betr. Erträge'], scenario_id) import_liquiditaet(cur, wb['Liquidität'], scenario_id) import_guv(cur, wb['GuV Jahresabschluss'], scenario_id) conn.commit() print(f"\nImport abgeschlossen!") # Summary for table in ['fp_kunden', 'fp_kunden_summary', 'fp_umsatzerloese', 'fp_materialaufwand', 'fp_personalkosten', 'fp_betriebliche_aufwendungen', 'fp_investitionen', 'fp_sonst_ertraege', 'fp_liquiditaet', 'fp_guv']: cur.execute(f"SELECT COUNT(*) FROM {table} WHERE scenario_id = %s", (scenario_id,)) count = cur.fetchone()[0] print(f" {table}: {count} Zeilen") cur.close() conn.close() if __name__ == '__main__': main()