feat: Seite Aufteilung – Auswahl, Kennzahlen, Tabelle, Tortendiagramm (A13-T5)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 16:24:20 +02:00
co-authored by Claude Opus 5.5
parent 1cded22e90
commit 2a62bdca6c
10 changed files with 396 additions and 4 deletions
+8
View File
@@ -28,3 +28,11 @@ def eur(value) -> str:
def de_label(value: str) -> str: def de_label(value: str) -> str:
return DE_LABELS.get(value, value) return DE_LABELS.get(value, value)
def pct(value) -> str:
"""Decimal("12.5") -> "12,5 %"; None -> "–" (z. B. keine Einkünfte)."""
if value is None:
return "–"
d = Decimal(str(value)).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
return f"{d}".replace(".", ",") + " %"
+90 -2
View File
@@ -1,4 +1,5 @@
import logging import logging
import re
from datetime import date, timedelta from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from typing import Callable from typing import Callable
@@ -13,10 +14,12 @@ from app.config import get_settings
from app.db import get_session from app.db import get_session
from app.engine.loans import add_months from app.engine.loans import add_months
from app.engine.recurrence import occurrences from app.engine.recurrence import occurrences
from app.formats import de_label, eur from app.engine.breakdown import FREI, BreakdownRow, Filters, share
from app.engine.pie import pie_slices
from app.formats import de_label, eur, pct
from app.models.tables import (Account, Category, PlannedItem, from app.models.tables import (Account, Category, PlannedItem,
ProjectionResult, RecurringItem, ProjectionResult, RecurringItem,
ScenarioLoan, ScenarioModifier, Scenario, ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem, Transaction) ScenarioPlannedItem, Transaction)
from app.routers.imports import list_imports as _list_imports from app.routers.imports import list_imports as _list_imports
from app.routers.imports import preview as _preview_import from app.routers.imports import preview as _preview_import
@@ -27,6 +30,7 @@ from app.routers.planning import recurring_suggestions as _recurring_suggestions
from app.routers.scenarios import list_scenarios as _list_scenarios from app.routers.scenarios import list_scenarios as _list_scenarios
from app.routers.transactions import count_transactions, list_transactions, normalize_sort from app.routers.transactions import count_transactions, list_transactions, normalize_sort
from app.services.balances import account_balance, total_balance from app.services.balances import account_balance, total_balance
from app.services.breakdown import PLAN_NAME, compute_breakdown
from app.services.double_count import all_conflicts from app.services.double_count import all_conflicts
from app.services.planning_chart import refresh_planning_chart from app.services.planning_chart import refresh_planning_chart
from app.services.projection_service import refresh_stale_projections from app.services.projection_service import refresh_stale_projections
@@ -54,6 +58,7 @@ def grafana_public_base(request: Request) -> str:
templates.env.globals["grafana_public_base"] = grafana_public_base templates.env.globals["grafana_public_base"] = grafana_public_base
templates.env.filters["eur"] = eur templates.env.filters["eur"] = eur
templates.env.filters["de_label"] = de_label templates.env.filters["de_label"] = de_label
templates.env.filters["pct"] = pct
router = APIRouter() router = APIRouter()
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -374,3 +379,86 @@ def szenarien_page(request: Request, session: Session = Depends(get_session)):
"scenario_rows": _scenario_rows(session, scenarios, loans), "scenario_rows": _scenario_rows(session, scenarios, loans),
"modifier_kinds": ["percent", "absolute", "remove", "ende"], "modifier_kinds": ["percent", "absolute", "remove", "ende"],
}) })
# --- Aufteilung (Ausbaustufe 13) ---
FEHLER_SZENARIO_VERGANGENHEIT = ("Szenarien sind erst ab dem laufenden Monat wählbar. "
"Für vergangene Monate „System-Plan“ wählen.")
def _parse_monat(value: str | None, today: date) -> date | None:
"""„JJJJ-MM" -> Monatserster; leer -> laufender Monat; ungültig -> None."""
if not value:
return today.replace(day=1)
m = re.fullmatch(r"(\d{4})-(\d{2})", value.strip())
if not m or not 1 <= int(m.group(2)) <= 12:
return None
return date(int(m.group(1)), int(m.group(2)), 1)
def _art(row: BreakdownRow) -> str:
if row.kind == "recurring":
return f"Fixposten ({de_label(row.rhythm)})"
return "Einmalposten" if row.kind == "planned" else "Kredit"
def _segmente(slices) -> list[dict]:
return [{"s": s, "q": None, "gewaehlt": False, "zusatz": None} for s in slices]
def _aufteilung_context(session: Session, today: date, monat: str | None,
szenario: str | None, fixposten: str | None,
einmal: str | None, kredite: str | None,
gesendet: str | None) -> dict:
# Erstaufruf (ohne gesendet): alle Filter an. Nach Absenden zählt nur,
# was angehakt ist - der Browser sendet leere Checkboxen gar nicht.
filters = (Filters(recurring=fixposten == "1", planned=einmal == "1",
loans=kredite == "1") if gesendet else Filters())
ctx: dict = {
"scenarios": session.execute(select(Scenario).order_by(Scenario.name)).scalars().all(),
"filters": filters, "plan_name": PLAN_NAME,
"monat_wert": monat or today.strftime("%Y-%m"), "monat_anzeige": None,
"szenario_id": None, "szenario_name": PLAN_NAME, "error": None, "b": None,
}
month = _parse_monat(monat, today)
if month is None:
ctx["error"] = f"Ungültiger Monat „{monat}“ – bitte im Format JJJJ-MM wählen."
return ctx
ctx["monat_wert"] = month.strftime("%Y-%m")
ctx["monat_anzeige"] = month.strftime("%m.%Y")
scenario = None
if szenario:
valid_id = szenario.isdigit() and len(szenario) <= 9 # kein Integer-Überlauf in der DB
scenario = session.get(Scenario, int(szenario)) if valid_id else None
if scenario is None:
ctx["error"] = "Unbekanntes Szenario – bitte neu wählen."
return ctx
ctx["szenario_id"], ctx["szenario_name"] = scenario.id, scenario.name
if month < today.replace(day=1):
ctx["error"] = FEHLER_SZENARIO_VERGANGENHEIT
return ctx
b = compute_breakdown(session, month, scenario, filters)
s = b.summary
ctx.update({
"b": b, "s": s,
"zeilen": [{"r": r, "art": _art(r), "einnahme": r.amount > 0,
"anteil": share(r.amount, s.income) if r.amount < 0 else None}
for r in b.rows],
"frei_anteil": share(s.free, s.income),
"haupt": _segmente(pie_slices(s.category_costs, rest=s.pie_rest, rest_label=FREI)),
"haupt_titel": ("Anteile an den Einkünften" if s.pie_on_income
else "Anteile an den Gesamtkosten"),
})
return ctx
@router.get("/aufteilung", dependencies=[Depends(gui_session)])
def aufteilung_page(request: Request, monat: str | None = None,
szenario: str | None = None, fixposten: str | None = None,
einmal: str | None = None, kredite: str | None = None,
gesendet: str | None = None,
session: Session = Depends(get_session)):
ctx = _aufteilung_context(session, date.today(), monat, szenario, fixposten,
einmal, kredite, gesendet)
return templates.TemplateResponse(request, "aufteilung.html", ctx)
+36
View File
@@ -236,3 +236,39 @@ label:has(input:disabled), label:has(select:disabled) {
#doppelzaehlung-dialog { #doppelzaehlung-dialog {
max-width: 32rem; max-width: 32rem;
} }
/* Aufteilung (Ausbaustufe 13). Tortenfarben: dataviz-Referenzpalette,
Slots 1-8 (Nachbarpaare inkl. Kreisschluss und Grautöne CVD-validiert auf
weißem Grund); Übrige/Frei neutral. Max. 8 benannte + Übrige + Frei. */
.aufteilung {
--seg-1: #2a78d6; --seg-2: #eb6834; --seg-3: #1baf7a; --seg-4: #eda100;
--seg-5: #e87ba4; --seg-6: #008300; --seg-7: #4a3aa7; --seg-8: #e34948;
--seg-uebrige: #8a8984; --seg-frei: #d6d5cf;
}
.kennzahlen { display: flex; flex-wrap: wrap; gap: 1.5rem; margin-bottom: 1rem; }
.kennzahlen div { display: flex; flex-direction: column; }
.kennzahlen strong { font-size: 1.25rem; }
.aufteilung-grid {
display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: 1.5rem; align-items: start;
}
@media (max-width: 800px) { .aufteilung-grid { grid-template-columns: minmax(0, 1fr); } }
.torte { margin: 0; }
.torte figcaption { font-weight: bold; margin-bottom: 0.5rem; }
.torte-svg { width: 100%; max-width: 280px; display: block; margin: 0 auto; }
.torte-svg .seg { stroke: #fff; stroke-width: 0.02; }
.seg.s1 { fill: var(--seg-1); } .swatch.s1 { background: var(--seg-1); }
.seg.s2 { fill: var(--seg-2); } .swatch.s2 { background: var(--seg-2); }
.seg.s3 { fill: var(--seg-3); } .swatch.s3 { background: var(--seg-3); }
.seg.s4 { fill: var(--seg-4); } .swatch.s4 { background: var(--seg-4); }
.seg.s5 { fill: var(--seg-5); } .swatch.s5 { background: var(--seg-5); }
.seg.s6 { fill: var(--seg-6); } .swatch.s6 { background: var(--seg-6); }
.seg.s7 { fill: var(--seg-7); } .swatch.s7 { background: var(--seg-7); }
.seg.s8 { fill: var(--seg-8); } .swatch.s8 { background: var(--seg-8); }
.seg.uebrige { fill: var(--seg-uebrige); } .swatch.uebrige { background: var(--seg-uebrige); }
.seg.frei { fill: var(--seg-frei); } .swatch.frei { background: var(--seg-frei); box-shadow: inset 0 0 0 1px #aaa; }
.legende { list-style: none; padding: 0; margin: 0.75rem 0 0; font-size: 0.9rem; }
.legende li { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: baseline; padding: 0.15rem 0; }
.swatch { display: inline-block; width: 0.8rem; height: 0.8rem; border-radius: 2px; flex: none; }
.legende-wert { margin-left: auto; color: #555; white-space: nowrap; }
.hinweis-klein { font-size: 0.85rem; color: #555; }
@@ -0,0 +1,4 @@
{% from "_torte_macros.html" import torte %}
<div id="aufteilung-torte">
{{ torte(haupt, haupt_titel) }}
</div>
+19
View File
@@ -0,0 +1,19 @@
{# Torte + Legende (Ausbaustufe 13). segmente: Liste von Dicts
{s: engine.pie.Slice, q: Query-String für den Drill-down oder None,
gewaehlt: bool, zusatz: Zusatztext oder None}. #}
{% macro torte(segmente, titel) %}
<figure class="torte">
<figcaption>{{ titel }}</figcaption>
<svg class="torte-svg" viewBox="-1.05 -1.05 2.1 2.1" role="img" aria-label="{{ titel }}">
{% for seg in segmente %}{% set s = seg.s %}
{% if s.path %}<path d="{{ s.path }}" class="seg {{ s.color }}"><title>{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }})</title></path>
{% else %}<circle r="1" class="seg {{ s.color }}"><title>{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }})</title></circle>{% endif %}
{% endfor %}
</svg>
<ul class="legende">
{% for seg in segmente %}{% set s = seg.s %}
<li><span class="swatch {{ s.color }}"></span><span>{{ s.label }}</span><span class="legende-wert">{{ s.value|eur }} € · {{ s.share|pct }}{% if seg.zusatz %} · {{ seg.zusatz }}{% endif %}</span></li>
{% endfor %}
</ul>
</figure>
{% endmacro %}
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Aufteilung – Finanzberatung{% endblock %}
{% block content %}
<div class="aufteilung">
<h1>Aufteilung</h1>
<form method="get" action="/aufteilung" class="filter-form">
<input type="hidden" name="gesendet" value="1">
<label>Monat <input type="month" name="monat" value="{{ monat_wert }}" required></label>
<label>Szenario
<select name="szenario">
<option value="">{{ plan_name }}</option>
{% for sc in scenarios %}<option value="{{ sc.id }}"{% if sc.id == szenario_id %} selected{% endif %}>{{ sc.name }}</option>{% endfor %}
</select>
</label>
<label><span><input type="checkbox" name="fixposten" value="1"{% if filters.recurring %} checked{% endif %}> Fixposten</span></label>
<label><span><input type="checkbox" name="einmal" value="1"{% if filters.planned %} checked{% endif %}> Einmalposten</span></label>
<label><span><input type="checkbox" name="kredite" value="1"{% if filters.loans %} checked{% endif %}> Kredite</span></label>
<button type="submit">Anzeigen</button>
</form>
{% if monat_anzeige %}<p class="hinweis-klein">{{ monat_anzeige }} · {{ szenario_name }}</p>{% endif %}
{% if error %}
<div class="warning" role="alert">{{ error }}</div>
{% elif b %}
<div class="kennzahlen">
<div><span>Einkünfte</span><strong>{{ s.income|eur }} €</strong></div>
<div><span>Kosten</span><strong>{{ s.costs|eur }} €</strong></div>
<div{% if s.deficit %} class="neg"{% endif %}><span>{{ 'Unterdeckung' if s.deficit else 'Frei verfügbar' }}</span><strong>{{ s.free|eur }} €</strong></div>
</div>
{% for k in b.conflicts %}
<div class="warning">⚠ Fixposten „{{ k.posten_name }}“ zählt evtl. doppelt mit Kredit „{{ k.kredit_name }}“.</div>
{% endfor %}
{% if not b.rows %}
<p>Für diese Auswahl gibt es keine Posten.</p>
{% else %}
{% if s.deficit %}
<div class="warning">Kosten übersteigen die Einkünfte um {{ (s.costs - s.income)|eur }} € — das Diagramm zeigt die Aufteilung der Gesamtkosten.</div>
{% endif %}
<div class="aufteilung-grid">
<div>
<table>
<thead>
<tr><th>Posten</th><th>Kategorie</th><th>Art</th><th>Monatsbetrag</th><th>% der Einkünfte</th></tr>
</thead>
<tbody>
{% for z in zeilen %}
<tr>
<td data-sort="{{ z.r.name }}">{{ z.r.name }}</td>
<td>{{ z.r.category }}</td>
<td>{{ z.art }}</td>
<td class="amount{% if z.r.amount < 0 %} neg{% endif %}" data-sort="{{ z.r.amount }}">{{ z.r.amount|eur }} €</td>
<td class="amount" data-sort="{{ z.anteil if z.anteil is not none else '' }}">{% if not z.einnahme %}{{ z.anteil|pct }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr{% if s.deficit %} class="neg"{% endif %}>
<th colspan="3">{{ 'Unterdeckung' if s.deficit else 'Frei verfügbar' }}</th>
<td class="amount">{{ s.free|eur }} €</td>
<td class="amount">{{ frei_anteil|pct }}</td>
</tr>
</tfoot>
</table>
<p>Gesamte Einkünfte: <strong>{{ s.income|eur }} €</strong></p>
</div>
<div>
{% include "_aufteilung_torte.html" %}
</div>
</div>
{% endif %}
{% endif %}
</div>
{% endblock %}
+1
View File
@@ -17,6 +17,7 @@
<a href="/salden">Salden</a> <a href="/salden">Salden</a>
<a href="/planung">Planung</a> <a href="/planung">Planung</a>
<a href="/szenarien">Szenarien</a> <a href="/szenarien">Szenarien</a>
<a href="/aufteilung">Aufteilung</a>
<a href="/admin">Admin</a> <a href="/admin">Admin</a>
<a href="/hilfe">Hilfe</a> <a href="/hilfe">Hilfe</a>
<a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a> <a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a>
+150
View File
@@ -0,0 +1,150 @@
import re
from datetime import date
from decimal import Decimal
from app.engine.loans import add_months
from app.models.tables import (Category, Loan, RecurringItem, Scenario,
ScenarioModifier)
D = Decimal
MONAT = "2030-03"
def _login(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
def _daten(db, gehalt="3000.00"):
wohnen, eink, vers = Category(name="Wohnen"), Category(name="Einkommen"), Category(name="Versicherung")
db.add_all([wohnen, eink, vers])
db.flush()
db.add_all([
RecurringItem(name="Gehalt", amount=D(gehalt), rhythm="monthly", due_day=1, category_id=eink.id),
RecurringItem(name="Miete", amount=D("-1000.00"), rhythm="monthly", due_day=1, category_id=wohnen.id),
RecurringItem(name="Police", amount=D("-1200.00"), rhythm="yearly", due_day=1, category_id=vers.id),
])
db.commit()
return wohnen
def _get(client, **params):
return client.get("/aufteilung", params=params)
def test_menuepunkt(client):
_login(client)
assert '<a href="/aufteilung">Aufteilung</a>' in client.get("/").text
def test_standardauswahl(client):
_login(client)
r = _get(client)
assert r.status_code == 200
assert f'value="{date.today().strftime("%Y-%m")}"' in r.text
assert re.search(r'<option value="">System-Plan</option>', r.text)
assert len(re.findall(r'type="checkbox"[^>]*checked', r.text)) == 3
def test_tabelle_kennzahlen_torte(client, db):
_login(client)
_daten(db)
t = _get(client, monat=MONAT).text
assert "03.2030" in t
assert "Fixposten (jährlich)" in t
assert "-100,00 €" in t # Police 1.200 / 12
assert "33,3 %" in t # Miete 1.000 von 3.000
assert "Gesamte Einkünfte: <strong>3.000,00 €</strong>" in t
tfoot = re.search(r"<tfoot>(.*?)</tfoot>", t, re.S).group(1)
assert "Frei verfügbar" in tfoot and "1.900,00 €" in tfoot and "63,3 %" in tfoot
assert '<svg class="torte-svg"' in t
# Präfix ohne schließendes Anführungszeichen: ab Task 6 folgt „ klickbar".
assert 'class="seg s1' in t and 'class="seg frei"' in t
assert "Anteile an den Einkünften" in t
def test_einnahmezeile_ohne_prozent(client, db):
_login(client)
_daten(db)
t = _get(client, monat=MONAT).text
zeile = re.search(r"<tr>\s*<td[^>]*>Gehalt</td>.*?</tr>", t, re.S).group(0)
assert "%" not in zeile
def test_alle_filter_aus_zeigt_keine_posten(client, db):
_login(client)
_daten(db)
t = _get(client, monat=MONAT, gesendet="1").text
assert "Für diese Auswahl gibt es keine Posten." in t
assert '<form method="get" action="/aufteilung"' in t
assert not re.findall(r'type="checkbox"[^>]*checked', t)
def test_unterdeckung(client, db):
_login(client)
_daten(db, gehalt="500.00")
t = _get(client, monat=MONAT).text
assert "Kosten übersteigen die Einkünfte um 600,00 €" in t
assert "Anteile an den Gesamtkosten" in t
assert 'class="seg frei"' not in t
tfoot = re.search(r"<tfoot>(.*?)</tfoot>", t, re.S).group(1)
assert "Unterdeckung" in tfoot and "-600,00 €" in tfoot
def test_szenario_mit_modifikator(client, db):
_login(client)
wohnen = _daten(db)
sc = Scenario(name="Sparen", description="")
db.add(sc)
db.flush()
db.add(ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=wohnen.id,
kind="percent", value=D("50")))
db.commit()
t = _get(client, monat=MONAT, szenario=str(sc.id)).text
assert "-500,00 €" in t and "Sparen" in t
assert f'<option value="{sc.id}" selected>Sparen</option>' in t
def test_szenario_vergangenheit_fehler_inline(client, db):
_login(client)
_daten(db)
sc = Scenario(name="S", description="")
db.add(sc)
db.commit()
vormonat = add_months(date.today().replace(day=1), -1).strftime("%Y-%m")
r = _get(client, monat=vormonat, szenario=str(sc.id))
assert r.status_code == 200
assert "Szenarien sind erst ab dem laufenden Monat wählbar" in r.text
assert '<form method="get" action="/aufteilung"' in r.text
assert "<table" not in r.text
def test_system_plan_in_vergangenheit_erlaubt(client, db):
_login(client)
_daten(db)
r = _get(client, monat="2020-01")
assert r.status_code == 200 and "<table" in r.text
def test_ungueltiger_monat_und_unbekanntes_szenario(client):
_login(client)
for params, text in (({"monat": "2030-13"}, "Ungültiger Monat"),
({"monat": "abc"}, "Ungültiger Monat"),
({"monat": MONAT, "szenario": "999"}, "Unbekanntes Szenario"),
({"monat": MONAT, "szenario": "x"}, "Unbekanntes Szenario"),
({"monat": MONAT, "szenario": "9" * 30}, "Unbekanntes Szenario")):
r = _get(client, **params)
assert r.status_code == 200 and text in r.text
assert '<form method="get" action="/aufteilung"' in r.text
def test_doppelzaehlungs_hinweis(client, db):
_login(client)
_daten(db)
db.add_all([
RecurringItem(name="Autorate", amount=D("-100.00"), rhythm="monthly", due_day=15),
Loan(name="Autokredit", principal=D("1200.00"), annual_rate_pct=D("0"), term_months=12,
payout_date=date(2030, 1, 15), repayment_type="annuity"),
])
db.commit()
assert "⚠ Fixposten „Autorate“ zählt evtl. doppelt mit Kredit „Autokredit“" in _get(client, monat=MONAT).text
assert "⚠" not in _get(client, monat=MONAT, gesendet="1", fixposten="1", einmal="1").text
+2 -2
View File
@@ -11,7 +11,7 @@ H = {"Authorization": "Bearer test-key"}
def test_pages_require_login(client): def test_pages_require_login(client):
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/hilfe"): for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/aufteilung", "/hilfe"):
r = client.get(path, follow_redirects=False) r = client.get(path, follow_redirects=False)
assert r.status_code in (302, 303), path assert r.status_code in (302, 303), path
assert r.headers["location"] == "/login" assert r.headers["location"] == "/login"
@@ -19,7 +19,7 @@ def test_pages_require_login(client):
def test_pages_render_after_login(client): def test_pages_render_after_login(client):
client.post("/login", data={"username": "admin", "password": "geheim"}) client.post("/login", data={"username": "admin", "password": "geheim"})
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/hilfe"): for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/aufteilung", "/hilfe"):
r = client.get(path) r = client.get(path)
assert r.status_code == 200 assert r.status_code == 200
assert "Finanzberatung" in r.text assert "Finanzberatung" in r.text
+11
View File
@@ -70,3 +70,14 @@ def test_index_aktionsspalten_ohne_sortierung(client):
def test_buchungen_tabelle_ist_serverseitig(client): def test_buchungen_tabelle_ist_serverseitig(client):
_login(client) _login(client)
assert 'data-sort-mode="server"' in client.get("/buchungen").text assert 'data-sort-mode="server"' in client.get("/buchungen").text
def test_aufteilung_tabelle_sortierbar_tfoot_fest(client, db):
_login(client)
db.add(RecurringItem(name="R", amount=Decimal("-1.00"), rhythm="monthly", due_day=1))
db.add(RecurringItem(name="G", amount=Decimal("10.00"), rhythm="monthly", due_day=1))
db.commit()
t = client.get("/aufteilung", params={"monat": "2030-03"}).text
table = re.search(r"<table[^>]*>.*?</table>", t, re.S).group(0)
assert 'data-sort-mode="server"' not in table
assert re.search(r"</tbody>\s*<tfoot>", table)