Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1672 lines
92 KiB
Markdown
1672 lines
92 KiB
Markdown
# Ausbaustufe 14: Szenario-Einträge mit Zeitraum, „Neuer Betrag", szenario-eigene wiederkehrende Zahlungen — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Szenario-Einträge (Prozent, Kürzung, Neuer Betrag, Entfällt) wirken optional nur in einem Zeitraum „Gültig ab/bis"; neue Eintragsart „Neuer Betrag"; Szenarien bekommen eigene wiederkehrende Zahlungen (mit Kategorie) und Einmalzahlungen mit Kategorie; dazu fünf Kleinpunkte aus der A14-Kandidatenliste; Release v0.14.0.
|
||
|
||
**Architecture:** Eine Alembic-Revision erweitert `scenario_modifiers` (`start_date`), `scenario_planned_items` (`category_id`) und legt `scenario_recurring_items` an. Die reine Engine (`engine/scenario.py`) bewertet Einträge künftig je Stichtag (`modified_amount(..., on=)`): Projektion je Fälligkeit, Aufteilung am Fälligkeitstag des Monats. `load_scenario_inputs` lädt szenario-eigene Zahlungen als `PlainRecurring(id=None, …)`, damit Posten-Einträge sie nie treffen. Die Szenarien-Seite bekommt zwei Formulare; ihre Umschaltlogik zieht aus dem Template in `static/szenarien.js` (`window.FBSzenario`, jsdom-testbar wie `doppelzaehlung.js`).
|
||
|
||
**Tech Stack:** FastAPI, Pydantic v2, SQLAlchemy 2.0, Alembic, Jinja2, htmx 2.0.6, pytest (SQLite-Fixtures `db`/`client`), node:test + jsdom (per Podman), Postgres 17 (Wegwerf-Container für Migrationstest).
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-09-30-ausbaustufe-14-design.md`
|
||
|
||
## Global Constraints
|
||
|
||
- **DATENSCHUTZ:** `finance/tests/fixtures/*` = echte Kontodaten, nie lesen/committen/zitieren. Tests nur mit erfundenen Werten; der Posten aus dem Anlass heißt in Tests/Doku nie beim echten Namen. Live-Smoke: nur Statuscodes, Zählwerte, Booleans.
|
||
- **Geld = `decimal.Decimal`** überall, kein `float` für Beträge.
|
||
- **Deutsch** für GUI-Texte, Fehlermeldungen, Kommentare, Commit-Messages; Datum TT.MM.JJJJ.
|
||
- **Engine bleibt rein:** `app/engine/*` importiert nur Standardbibliothek und `app.engine.*` (kein `app.models`, `app.services`).
|
||
- **UX-Regel:** nicht passende Felder bleiben sichtbar, werden `disabled` + Tooltip `Für diese Eintragsart nicht relevant`; Fehler über den bestehenden `htmx:responseError`-Handler.
|
||
- **Exakte Namen:** Art-Literal `set` (Label „Neuer Betrag"), `absolute` → Label „Kürzung (€)"; Tabelle `scenario_recurring_items`, Modell `ScenarioRecurringItem`; Endpunkt `/api/scenarios/{id}/recurring`; Alembic-Revision `e7f1a9c3b214` (`down_revision = 'd5e8b3c7a410'`); JS-Global `window.FBSzenario` mit `zielTyp`, `aenderungArt`, `zahlungArt`, `init`; Engine-Helfer `gilt_am`, `modified_amount(..., *, on)`, `recurrence.due_in_month`, `loans.schedule_for`.
|
||
- **Fehlertexte (exakt):** „Art »Ende« erfordert ein Datum", „Art »Neuer Betrag« ist nur für Posten möglich", „»Gültig ab« liegt nach »Gültig bis«", „Wiederkehrende Zahlung nicht gefunden", „Kategorie nicht gefunden".
|
||
- **Nicht ändern:** `.torte figcaption` bleibt `font-weight: bold` (ausdrücklicher Nutzerwunsch). Grafana, Views, `planning_monthly` bleiben unverändert.
|
||
- **Test-Suite grün:** `cd finance && .venv/bin/python -m pytest -q` — Basis **377 passed, 3 skipped**; jede Task erhöht „passed".
|
||
- **JS-Tests** laufen auf dem Host nicht (kein node): Gate per Podman `docker.io/library/node:22-alpine` mit jsdom, Dateien explizit übergeben (kein Verzeichnis-Argument).
|
||
- **Fable-Testagent-Gate je Task** (CLAUDE.md): Implementer committet, Fable prüft; kein Task gilt vor dem Gate als abgeschlossen. Ledger-Eintrag je Task in `.superpowers/sdd/progress.md`.
|
||
- **Direkt auf `main`** (wie A13). Commits enden mit `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`.
|
||
- **Live-Szenario des Nutzers nicht anfassen** — der Nutzer passt „sein" Szenario selbst an.
|
||
|
||
## Review Focus
|
||
|
||
1. **Bestandsdaten nach der Migration unverändert:** vorhandene `ende`-Einträge behalten `end_date`, Prozent/Absolut/Entfällt ohne Datum wirken weiter unbegrenzt; Downgrade stellt die alte Semantik her — Test in Task 1 (`test_migration_a14_up_down`, Postgres).
|
||
2. **Grenztage inklusiv:** eine Fälligkeit genau am „Gültig ab"- bzw. „Gültig bis"-Tag wird geändert, der Tag davor/danach nicht — Test in Task 2 (`test_zeitraum_grenzen_inklusiv`).
|
||
3. **ID-Kollision:** ein Posten-Eintrag, dessen `target_id` zufällig der ID einer szenario-eigenen Zahlung entspricht, darf diese nicht treffen — Test in Task 3 (`test_szenario_zahlung_zaehlt_immer_und_id_kollision`).
|
||
4. **Artwechsel per PATCH:** Prozent mit Zeitraum → Ende nullt `start_date`; Ende → Prozent behält `end_date` als „Gültig bis" — Test in Task 3 (`test_modifier_patch_artwechsel_zeitraum`).
|
||
5. **Formular zurückschalten:** Zahlung „Wiederkehrend" → „Einmalzahlung" muss wieder an `/planned` senden und Rhythmus/Fälligkeitstag/Start/Ende sperren (sonst 422 oder falscher Endpunkt) — Test in Task 4 (`szenarien.dom.test.js`, „Zahlung: Einmal → Wiederkehrend → Einmal").
|
||
|
||
---
|
||
|
||
### Task 1: Datenmodell + Alembic-Revision
|
||
|
||
**Files:**
|
||
- Create: `finance/alembic/versions/e7f1a9c3b214_szenario_zeitraum.py`
|
||
- Modify: `finance/app/models/tables.py` (`ScenarioModifier`, `ScenarioPlannedItem`, neu `ScenarioRecurringItem`)
|
||
- Test: `finance/tests/test_models.py`, Create: `finance/tests/test_migration_pg.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `ScenarioModifier.start_date: date | None`; `ScenarioPlannedItem.category_id: int | None`; `ScenarioRecurringItem` (Tabelle `scenario_recurring_items`: `id`, `scenario_id`, `name`, `amount`, `rhythm`, `due_day`, `start_date`, `end_date`, `category_id`).
|
||
|
||
- [x] **Step 1: Failing Test (SQLite-Roundtrip)** — an `finance/tests/test_models.py` anhängen:
|
||
|
||
```python
|
||
def test_a14_szenario_felder_roundtrip(db):
|
||
from app.models.tables import (Category, Scenario, ScenarioModifier,
|
||
ScenarioPlannedItem, ScenarioRecurringItem)
|
||
cat = Category(name="A14-Kat")
|
||
sc = Scenario(name="A14-Modell", description="")
|
||
db.add_all([cat, sc])
|
||
db.flush()
|
||
db.add_all([
|
||
ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1,
|
||
kind="set", value=Decimal("-80.00"),
|
||
start_date=date(2030, 2, 1), end_date=date(2030, 3, 31)),
|
||
ScenarioPlannedItem(scenario_id=sc.id, name="E", amount=Decimal("-5.00"),
|
||
due=date(2030, 1, 1), category_id=cat.id),
|
||
ScenarioRecurringItem(scenario_id=sc.id, name="W", amount=Decimal("-10.00"),
|
||
rhythm="monthly", due_day=14,
|
||
start_date=date(2030, 1, 16), end_date=None,
|
||
category_id=cat.id),
|
||
])
|
||
db.commit()
|
||
m = db.query(ScenarioModifier).one()
|
||
assert (m.kind, m.start_date, m.end_date) == ("set", date(2030, 2, 1), date(2030, 3, 31))
|
||
assert db.query(ScenarioPlannedItem).one().category_id == cat.id
|
||
w = db.query(ScenarioRecurringItem).one()
|
||
assert (w.scenario_id, w.rhythm, w.due_day, w.category_id) == (sc.id, "monthly", 14, cat.id)
|
||
```
|
||
|
||
- [x] **Step 2: Test laufen lassen** — `cd finance && .venv/bin/python -m pytest -q tests/test_models.py::test_a14_szenario_felder_roundtrip` → FAIL (`ImportError: cannot import name 'ScenarioRecurringItem'`).
|
||
|
||
- [x] **Step 3: Modelle** — in `finance/app/models/tables.py`:
|
||
|
||
`ScenarioModifier` (Kommentar über `end_date` ersetzen, `start_date` ergänzen):
|
||
|
||
```python
|
||
class ScenarioModifier(Base):
|
||
__tablename__ = "scenario_modifiers"
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"))
|
||
target_type: Mapped[str] = mapped_column(String(20))
|
||
target_id: Mapped[int] = mapped_column(Integer)
|
||
kind: Mapped[str] = mapped_column(String(20))
|
||
value: Mapped[Decimal] = mapped_column(MONEY, default=Decimal("0"))
|
||
# kind="ende": Datum, an dem der Ziel-Posten in diesem Szenario endet (A5).
|
||
# Alle anderen Arten: „Gültig bis" (inklusiv, NULL = unbegrenzt, A14).
|
||
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||
# „Gültig ab" (inklusiv, NULL = unbegrenzt); bei kind="ende" immer NULL (A14).
|
||
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||
```
|
||
|
||
`ScenarioPlannedItem` bekommt als letzte Spalte:
|
||
|
||
```python
|
||
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||
```
|
||
|
||
Neu direkt nach `ScenarioPlannedItem`:
|
||
|
||
```python
|
||
class ScenarioRecurringItem(Base):
|
||
"""Wiederkehrende Zahlung, die NUR in einem bestimmten Szenario zählt -
|
||
unabhängig von Scenario.include_recurring. Posten-Einträge
|
||
(target_type="recurring") treffen sie nie, Kategorie-Einträge schon.
|
||
Ausbaustufe 14."""
|
||
__tablename__ = "scenario_recurring_items"
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=True)
|
||
name: Mapped[str] = mapped_column(String(200))
|
||
amount: Mapped[Decimal] = mapped_column(MONEY)
|
||
rhythm: Mapped[str] = mapped_column(String(20))
|
||
due_day: Mapped[int] = mapped_column(Integer)
|
||
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||
```
|
||
|
||
- [x] **Step 4: Migration** — `finance/alembic/versions/e7f1a9c3b214_szenario_zeitraum.py`:
|
||
|
||
```python
|
||
"""szenario zeitraum, neuer betrag, szenario-eigene wiederkehrende zahlungen
|
||
|
||
Revision ID: e7f1a9c3b214
|
||
Revises: d5e8b3c7a410
|
||
Create Date: 2026-09-30
|
||
|
||
"""
|
||
from typing import Sequence, Union
|
||
|
||
from alembic import op
|
||
import sqlalchemy as sa
|
||
|
||
|
||
revision: str = 'e7f1a9c3b214'
|
||
down_revision: Union[str, Sequence[str], None] = 'd5e8b3c7a410'
|
||
branch_labels: Union[str, Sequence[str], None] = None
|
||
depends_on: Union[str, Sequence[str], None] = None
|
||
|
||
FK_PLANNED_CATEGORY = 'fk_scenario_planned_items_category_id'
|
||
|
||
|
||
def upgrade() -> None:
|
||
# Bestandsdaten: percent/absolute/remove haben end_date NULL (API hat es
|
||
# bisher genullt) und gelten damit unverändert unbegrenzt.
|
||
op.add_column('scenario_modifiers', sa.Column('start_date', sa.Date(), nullable=True))
|
||
op.add_column('scenario_planned_items',
|
||
sa.Column('category_id', sa.Integer(), nullable=True))
|
||
op.create_foreign_key(FK_PLANNED_CATEGORY, 'scenario_planned_items',
|
||
'categories', ['category_id'], ['id'])
|
||
op.create_table(
|
||
'scenario_recurring_items',
|
||
sa.Column('id', sa.Integer(), primary_key=True),
|
||
sa.Column('scenario_id', sa.Integer(), sa.ForeignKey('scenarios.id'), nullable=False),
|
||
sa.Column('name', sa.String(length=200), nullable=False),
|
||
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||
sa.Column('rhythm', sa.String(length=20), nullable=False),
|
||
sa.Column('due_day', sa.Integer(), nullable=False),
|
||
sa.Column('start_date', sa.Date(), nullable=True),
|
||
sa.Column('end_date', sa.Date(), nullable=True),
|
||
sa.Column('category_id', sa.Integer(), sa.ForeignKey('categories.id'), nullable=True),
|
||
)
|
||
op.create_index('ix_scenario_recurring_items_scenario_id',
|
||
'scenario_recurring_items', ['scenario_id'])
|
||
|
||
|
||
def downgrade() -> None:
|
||
# Alte Semantik: "set" gab es nicht, end_date nur bei "ende".
|
||
op.execute("DELETE FROM scenario_modifiers WHERE kind = 'set'")
|
||
op.execute("UPDATE scenario_modifiers SET end_date = NULL WHERE kind <> 'ende'")
|
||
op.drop_index('ix_scenario_recurring_items_scenario_id', 'scenario_recurring_items')
|
||
op.drop_table('scenario_recurring_items')
|
||
op.drop_constraint(FK_PLANNED_CATEGORY, 'scenario_planned_items', type_='foreignkey')
|
||
op.drop_column('scenario_planned_items', 'category_id')
|
||
op.drop_column('scenario_modifiers', 'start_date')
|
||
```
|
||
|
||
- [x] **Step 5: Postgres-Migrationstest** — `finance/tests/test_migration_pg.py` (skip ohne `FB_TEST_PG_URL`, Muster `test_views_pg.py`):
|
||
|
||
```python
|
||
"""Postgres-only: Alembic-Revision e7f1a9c3b214 (Ausbaustufe 14) hoch/runter
|
||
auf einer Wegwerf-DB (Name muss auf '_test' enden). Beispiel:
|
||
podman run -d --rm --name a14-pg -e POSTGRES_PASSWORD=wegwerf \
|
||
-e POSTGRES_DB=fb_test -p 127.0.0.1:55432:5432 docker.io/library/postgres:17.10
|
||
FB_TEST_PG_URL=postgresql+psycopg://postgres:wegwerf@127.0.0.1:55432/fb_test \
|
||
.venv/bin/python -m pytest -q tests/test_migration_pg.py
|
||
"""
|
||
import os
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from alembic import command
|
||
from alembic.config import Config
|
||
from sqlalchemy import create_engine, inspect, text
|
||
from sqlalchemy.engine import make_url
|
||
|
||
PG_URL = os.environ.get("FB_TEST_PG_URL")
|
||
pytestmark = pytest.mark.skipif(not PG_URL, reason="FB_TEST_PG_URL nicht gesetzt")
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
|
||
|
||
@pytest.fixture
|
||
def alembic_cfg(monkeypatch):
|
||
assert make_url(PG_URL).database.endswith("_test"), "nur Wegwerf-DB *_test!"
|
||
monkeypatch.setenv("FB_DATABASE_URL", PG_URL)
|
||
from app.config import get_settings
|
||
get_settings.cache_clear()
|
||
engine = create_engine(PG_URL)
|
||
with engine.begin() as conn:
|
||
conn.execute(text("DROP SCHEMA public CASCADE; CREATE SCHEMA public"))
|
||
cfg = Config(str(ROOT / "alembic.ini"))
|
||
cfg.set_main_option("script_location", str(ROOT / "alembic"))
|
||
yield cfg, engine
|
||
# Schema leeren, damit test_views_pg.py (create_all) nicht auf dem
|
||
# herabgestuften Alembic-Stand aufsetzt.
|
||
with engine.begin() as conn:
|
||
conn.execute(text("DROP SCHEMA public CASCADE; CREATE SCHEMA public"))
|
||
engine.dispose()
|
||
get_settings.cache_clear()
|
||
|
||
|
||
def test_migration_a14_up_down(alembic_cfg):
|
||
cfg, engine = alembic_cfg
|
||
command.upgrade(cfg, "d5e8b3c7a410")
|
||
with engine.begin() as conn:
|
||
conn.execute(text("INSERT INTO scenarios (id, name, description, include_recurring, "
|
||
"include_planned) VALUES (1, 'S', '', true, true)"))
|
||
conn.execute(text(
|
||
"INSERT INTO scenario_modifiers (id, scenario_id, target_type, target_id, kind, "
|
||
"value, end_date) VALUES "
|
||
"(1, 1, 'recurring', 7, 'ende', 0, '2030-10-15'), "
|
||
"(2, 1, 'category', 3, 'percent', 10, NULL)"))
|
||
command.upgrade(cfg, "head")
|
||
with engine.begin() as conn:
|
||
rows = conn.execute(text("SELECT id, kind, value, start_date, end_date "
|
||
"FROM scenario_modifiers ORDER BY id")).all()
|
||
assert [(r.id, r.kind, str(r.value), r.start_date, str(r.end_date))
|
||
for r in rows] == [(1, "ende", "0.00", None, "2030-10-15"),
|
||
(2, "percent", "10.00", None, "None")]
|
||
conn.execute(text(
|
||
"INSERT INTO scenario_modifiers (id, scenario_id, target_type, target_id, kind, "
|
||
"value, start_date, end_date) VALUES "
|
||
"(3, 1, 'recurring', 7, 'set', -80, '2030-11-01', NULL), "
|
||
"(4, 1, 'recurring', 7, 'percent', 50, '2030-11-01', '2030-12-31')"))
|
||
conn.execute(text(
|
||
"INSERT INTO scenario_recurring_items (scenario_id, name, amount, rhythm, due_day) "
|
||
"VALUES (1, 'W', -10, 'monthly', 14)"))
|
||
insp = inspect(engine)
|
||
assert "scenario_recurring_items" in insp.get_table_names()
|
||
assert "category_id" in {c["name"] for c in insp.get_columns("scenario_planned_items")}
|
||
command.downgrade(cfg, "d5e8b3c7a410")
|
||
with engine.begin() as conn:
|
||
rows = conn.execute(text("SELECT id, kind, end_date FROM scenario_modifiers "
|
||
"ORDER BY id")).all()
|
||
assert [(r.id, r.kind, str(r.end_date)) for r in rows] == [
|
||
(1, "ende", "2030-10-15"), (2, "percent", "None"), (4, "percent", "None")]
|
||
insp = inspect(engine)
|
||
assert "scenario_recurring_items" not in insp.get_table_names()
|
||
assert "start_date" not in {c["name"] for c in insp.get_columns("scenario_modifiers")}
|
||
```
|
||
|
||
- [x] **Step 6: Tests laufen lassen** — `.venv/bin/python -m pytest -q` → alle grün, `test_migration_pg` skipped (Host ohne `FB_TEST_PG_URL`). Dann mit Wegwerf-Postgres (Kommando aus dem Docstring) `FB_TEST_PG_URL=… .venv/bin/python -m pytest -q tests/test_migration_pg.py tests/test_views_pg.py` → PASS; Container danach `podman stop a14-pg`.
|
||
|
||
- [x] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add finance/app/models/tables.py finance/alembic/versions/e7f1a9c3b214_szenario_zeitraum.py \
|
||
finance/tests/test_models.py finance/tests/test_migration_pg.py
|
||
git commit -m "feat: Szenario-Zeitraum, Neuer Betrag, szenario-eigene Zahlungen – Datenmodell + Migration (A14-T1)"
|
||
```
|
||
|
||
- [x] **Step 8: Fable-Gate** — Wegwerf-Postgres selbst starten; `test_migration_pg.py` + `test_views_pg.py` grün; zusätzlich `alembic upgrade head` → `alembic downgrade -1` → `alembic upgrade head` per CLI (`FB_DATABASE_URL=… .venv/bin/alembic …`) ohne Fehler; Modell ↔ Migration abgleichen (Spaltentypen, Nullbarkeit, FK-Ziele, Index-Name) per `inspect` gegen `Base.metadata`; bestätigen, dass Live-DB unberührt ist (keine Verbindung zur Produktiv-DB).
|
||
|
||
---
|
||
|
||
### Task 2: Engine — Stichtag, Zeitraum, „Neuer Betrag"
|
||
|
||
**Files:**
|
||
- Modify: `finance/app/engine/scenario.py`, `finance/app/engine/breakdown.py`, `finance/app/engine/recurrence.py`
|
||
- Test: `finance/tests/test_scenario_engine.py`, `finance/tests/test_breakdown.py`, `finance/tests/test_recurrence.py`, `finance/tests/test_scenario_inputs.py` (bestehender Aufruf anpassen)
|
||
|
||
**Interfaces:**
|
||
- Consumes: nichts aus Task 1 (Engine ist rein).
|
||
- Produces:
|
||
- `PlainRecurring.id: int | None` (`None` = szenario-eigene Zahlung).
|
||
- `PlainModifier(target_type, target_id, kind, value, end_date=None, start_date=None)` — **`start_date` als letztes Feld** (bestehende Positionsaufrufe mit `end_date` an 5. Stelle bleiben gültig).
|
||
- `gilt_am(m: PlainModifier, on: date) -> bool`.
|
||
- `modified_amount(amount, category_id, recurring_id, modifiers, *, on: date) -> Decimal | None` (`on` ist Pflicht, keyword-only).
|
||
- `recurrence.due_in_month(month: date, due_day: int) -> date` (Fälligkeitstag im Monat von `month`, auf Monatslänge geklemmt).
|
||
|
||
- [x] **Step 1: Failing Tests** — an `finance/tests/test_scenario_engine.py` anhängen:
|
||
|
||
```python
|
||
D = Decimal
|
||
|
||
|
||
def _rec(amount="-100.00", due_day=14, id=1, cat=None):
|
||
return PlainRecurring(id, "R", D(amount), "monthly", due_day, None, None, cat)
|
||
|
||
|
||
def test_zeitraum_grenzen_inklusiv():
|
||
mods = [PlainModifier("recurring", 1, "percent", D("50"),
|
||
end_date=date(2030, 3, 14), start_date=date(2030, 2, 14))]
|
||
flows = build_cashflows([_rec()], [], [], [], mods, date(2030, 1, 1), date(2030, 4, 30))
|
||
assert flows == [(date(2030, 1, 14), D("-100.00")),
|
||
(date(2030, 2, 14), D("-50.00")),
|
||
(date(2030, 3, 14), D("-50.00")),
|
||
(date(2030, 4, 14), D("-100.00"))]
|
||
|
||
|
||
def test_ab_stichtag_erst_folgemonat():
|
||
# Fällig am 14., gültig ab 16.10. -> Oktober voll, ab November halbiert.
|
||
mods = [PlainModifier("recurring", 1, "percent", D("50"), start_date=date(2030, 10, 16))]
|
||
flows = build_cashflows([_rec("-200.00")], [], [], [], mods,
|
||
date(2030, 10, 1), date(2030, 12, 31))
|
||
assert flows == [(date(2030, 10, 14), D("-200.00")),
|
||
(date(2030, 11, 14), D("-100.00")),
|
||
(date(2030, 12, 14), D("-100.00"))]
|
||
|
||
|
||
def test_neuer_betrag_dann_prozent():
|
||
mods = [PlainModifier("recurring", 1, "set", D("-80.00"), start_date=date(2030, 2, 1)),
|
||
PlainModifier("recurring", 1, "percent", D("25"))]
|
||
flows = build_cashflows([_rec(due_day=1)], [], [], [], mods,
|
||
date(2030, 1, 1), date(2030, 2, 28))
|
||
assert flows == [(date(2030, 1, 1), D("-75.00")), (date(2030, 2, 1), D("-60.00"))]
|
||
|
||
|
||
def test_entfaellt_nur_im_zeitraum():
|
||
mods = [PlainModifier("recurring", 1, "remove", D("0"),
|
||
end_date=date(2030, 2, 28), start_date=date(2030, 2, 1))]
|
||
flows = build_cashflows([_rec(due_day=1)], [], [], [], mods,
|
||
date(2030, 1, 1), date(2030, 3, 31))
|
||
assert [d for d, _ in flows] == [date(2030, 1, 1), date(2030, 3, 1)]
|
||
|
||
|
||
def test_szenario_zahlung_ohne_id_nur_von_kategorie_getroffen():
|
||
rec = [PlainRecurring(None, "S", D("-100.00"), "monthly", 1, None, None, 7)]
|
||
mods = [PlainModifier("recurring", 1, "remove", D("0")),
|
||
PlainModifier("category", 7, "percent", D("10"))]
|
||
flows = build_cashflows(rec, [], [], [], mods, date(2030, 1, 1), date(2030, 1, 31))
|
||
assert flows == [(date(2030, 1, 1), D("-90.00"))]
|
||
|
||
|
||
def test_einmalposten_stichtag_ist_faelligkeit():
|
||
planned = [PlainPlanned("E", D("-100.00"), date(2030, 1, 31), 7),
|
||
PlainPlanned("F", D("-100.00"), date(2030, 2, 1), 7)]
|
||
mods = [PlainModifier("category", 7, "percent", D("50"), start_date=date(2030, 2, 1))]
|
||
flows = build_cashflows([], planned, [], [], mods, date(2030, 1, 1), date(2030, 2, 28))
|
||
assert flows == [(date(2030, 1, 31), D("-100.00")), (date(2030, 2, 1), D("-50.00"))]
|
||
|
||
|
||
def test_ende_unveraendert_mit_zeitraum_feldern():
|
||
# "ende" wirkt weiter nur über effective_end, nie als Betragsänderung.
|
||
mods = [PlainModifier("recurring", 1, "ende", D("0"), date(2030, 2, 15))]
|
||
flows = build_cashflows([_rec(due_day=1)], [], [], [], mods,
|
||
date(2030, 1, 1), date(2030, 4, 30))
|
||
assert flows == [(date(2030, 1, 1), D("-100.00")), (date(2030, 2, 1), D("-100.00"))]
|
||
```
|
||
|
||
An `finance/tests/test_breakdown.py` anhängen:
|
||
|
||
```python
|
||
def test_stichtag_ist_faelligkeitstag_im_monat():
|
||
r = PlainRecurring(1, "R1", D("-200.00"), "monthly", 14, None, None, None)
|
||
mods = [PlainModifier("recurring", 1, "percent", D("50"), start_date=date(2030, 10, 16))]
|
||
assert by_name(rows(month=date(2030, 10, 1), recurring=[r], modifiers=mods)) == {"R1": D("-200.00")}
|
||
assert by_name(rows(month=date(2030, 11, 1), recurring=[r], modifiers=mods)) == {"R1": D("-100.00")}
|
||
|
||
|
||
def test_stichtag_monatsende_geklemmt():
|
||
# Fällig am 31.: im Februar ist der Stichtag der 28.02. (liegt im Zeitraum).
|
||
r = PlainRecurring(1, "R1", D("-100.00"), "monthly", 31, None, None, None)
|
||
mods = [PlainModifier("recurring", 1, "remove", D("0"), end_date=date(2030, 2, 28))]
|
||
assert rows(month=date(2030, 2, 1), recurring=[r], modifiers=mods) == []
|
||
assert by_name(rows(month=date(2030, 3, 1), recurring=[r], modifiers=mods)) == {"R1": D("-100.00")}
|
||
|
||
|
||
def test_szenario_zahlung_ohne_recurring_id():
|
||
r = PlainRecurring(None, "S", D("-30.00"), "monthly", 1, None, None, 1)
|
||
rs = rows(recurring=[r])
|
||
assert by_name(rs) == {"S": D("-30.00")}
|
||
assert (rs[0].recurring_id, rs[0].category) == (None, "Wohnen")
|
||
```
|
||
|
||
An `finance/tests/test_recurrence.py` anhängen (Import `due_in_month` ergänzen):
|
||
|
||
```python
|
||
def test_due_in_month_klemmt_auf_monatsende():
|
||
from app.engine.recurrence import due_in_month
|
||
assert due_in_month(date(2030, 2, 1), 31) == date(2030, 2, 28)
|
||
assert due_in_month(date(2032, 2, 10), 30) == date(2032, 2, 29)
|
||
assert due_in_month(date(2030, 10, 1), 14) == date(2030, 10, 14)
|
||
```
|
||
|
||
In `finance/tests/test_scenario_inputs.py::test_modifikator_helfer_oeffentlich` die zwei `modified_amount`-Aufrufe um `on=date(2030, 1, 1)` ergänzen:
|
||
|
||
```python
|
||
assert modified_amount(Decimal("-100.00"), None, 1, mods, on=date(2030, 1, 1)) == Decimal("-90.00")
|
||
assert modified_amount(Decimal("-100.00"), None, 2, mods, on=date(2030, 1, 1)) == Decimal("-100.00")
|
||
```
|
||
|
||
- [x] **Step 2: Tests laufen lassen** — `.venv/bin/python -m pytest -q tests/test_scenario_engine.py tests/test_breakdown.py tests/test_recurrence.py tests/test_scenario_inputs.py` → FAIL (`unexpected keyword argument 'start_date'` / `'on'`, `cannot import name 'due_in_month'`).
|
||
|
||
- [x] **Step 3: `recurrence.py`** — nach `_clamp` ergänzen:
|
||
|
||
```python
|
||
def due_in_month(month: date, due_day: int) -> date:
|
||
"""Fälligkeitstag `due_day` im Monat von `month`, auf die Monatslänge
|
||
geklemmt (gleiche Regel wie occurrences)."""
|
||
return _clamp(month.year, month.month, due_day)
|
||
```
|
||
|
||
- [x] **Step 4: `scenario.py`** — Dataclasses und Helfer ersetzen:
|
||
|
||
```python
|
||
@dataclass(frozen=True)
|
||
class PlainRecurring:
|
||
id: int | None # None = szenario-eigene Zahlung (Posten-Einträge treffen sie nie)
|
||
name: str
|
||
amount: Decimal
|
||
rhythm: str
|
||
due_day: int
|
||
start_date: date | None
|
||
end_date: date | None
|
||
category_id: int | None
|
||
```
|
||
|
||
```python
|
||
@dataclass(frozen=True)
|
||
class PlainModifier:
|
||
target_type: str
|
||
target_id: int
|
||
kind: str
|
||
value: Decimal
|
||
end_date: date | None = None # ende: Enddatum; sonst „Gültig bis" (inklusiv)
|
||
start_date: date | None = None # „Gültig ab" (inklusiv); bei ende immer None
|
||
|
||
|
||
def _trifft(m: PlainModifier, category_id: int | None, recurring_id: int | None) -> bool:
|
||
return ((m.target_type == "category" and category_id is not None
|
||
and category_id == m.target_id)
|
||
or (m.target_type == "recurring" and recurring_id is not None
|
||
and recurring_id == m.target_id))
|
||
|
||
|
||
def gilt_am(m: PlainModifier, on: date) -> bool:
|
||
"""Liegt der Stichtag im Zeitraum des Eintrags (Grenzen inklusiv)?"""
|
||
return ((m.start_date is None or on >= m.start_date)
|
||
and (m.end_date is None or on <= m.end_date))
|
||
|
||
|
||
def modified_amount(amount: Decimal, category_id: int | None, recurring_id: int | None,
|
||
modifiers: list[PlainModifier], *, on: date) -> Decimal | None:
|
||
"""Betrag einer Fälligkeit am Stichtag `on` nach allen treffenden, dort
|
||
gültigen Einträgen in Anlage-Reihenfolge. None = entfällt. Art "ende"
|
||
wirkt nur über effective_end."""
|
||
for m in modifiers:
|
||
if m.kind == "ende" or not _trifft(m, category_id, recurring_id) or not gilt_am(m, on):
|
||
continue
|
||
if m.kind == "remove":
|
||
return None
|
||
if m.kind == "set":
|
||
amount = m.value
|
||
elif m.kind == "percent":
|
||
amount = (amount * (Decimal(100) - m.value) / Decimal(100)).quantize(CENT, ROUND_HALF_UP)
|
||
elif m.kind == "absolute":
|
||
if amount < 0:
|
||
amount = min(Decimal("0"), amount + m.value)
|
||
else:
|
||
amount = max(Decimal("0"), amount - m.value)
|
||
return amount
|
||
|
||
|
||
def effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None:
|
||
"""Frühestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren."""
|
||
end = r.end_date
|
||
for m in modifiers:
|
||
if m.kind != "ende" or m.end_date is None or not _trifft(m, r.category_id, r.id):
|
||
continue
|
||
end = m.end_date if end is None else min(end, m.end_date)
|
||
return end
|
||
```
|
||
|
||
In `build_cashflows` die zwei Schleifen über `recurring`/`planned` ersetzen:
|
||
|
||
```python
|
||
for r in recurring:
|
||
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
|
||
r.start_date, effective_end(r, modifiers)):
|
||
amount = modified_amount(r.amount, r.category_id, r.id, modifiers, on=d)
|
||
if amount is not None:
|
||
flows.append((d, amount))
|
||
for p in planned:
|
||
if not window_start <= p.due <= window_end:
|
||
continue
|
||
amount = modified_amount(p.amount, p.category_id, None, modifiers, on=p.due)
|
||
if amount is not None:
|
||
flows.append((p.due, amount))
|
||
```
|
||
|
||
- [x] **Step 5: `breakdown.py`** — Import `from app.engine.recurrence import STEP, due_in_month`; im Modul-Docstring den Satz „Modifikatoren wirken exakt wie in build_cashflows (gleiche Helfer)." ersetzen durch „Modifikatoren wirken wie in build_cashflows (gleiche Helfer); Stichtag je Fixposten ist sein Fälligkeitstag im Monat, je Einmalposten das Fälligkeitsdatum." In `breakdown_rows`:
|
||
|
||
```python
|
||
amount = modified_amount(r.amount, r.category_id, r.id, modifiers,
|
||
on=due_in_month(first, r.due_day))
|
||
```
|
||
|
||
```python
|
||
amount = modified_amount(p.amount, p.category_id, None, modifiers, on=p.due)
|
||
```
|
||
|
||
- [x] **Step 6: Tests laufen lassen** — `.venv/bin/python -m pytest -q` → alle grün. `grep -n "^from\|^import" app/engine/scenario.py app/engine/breakdown.py` zeigt nur Stdlib und `app.engine.*`.
|
||
|
||
- [x] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add finance/app/engine/scenario.py finance/app/engine/breakdown.py finance/app/engine/recurrence.py \
|
||
finance/tests/test_scenario_engine.py finance/tests/test_breakdown.py \
|
||
finance/tests/test_recurrence.py finance/tests/test_scenario_inputs.py
|
||
git commit -m "feat: Szenario-Einträge je Stichtag mit Zeitraum und Art Neuer Betrag (A14-T2)"
|
||
```
|
||
|
||
- [x] **Step 8: Fable-Gate** — unabhängige Handrechnung mit eigenen Werten: vierteljährlicher Posten mit Zeitraum, der eine Fälligkeit einschließt und eine ausschließt; `set` → `absolute` → `percent` in Folge; Kategorie- und Posten-Eintrag auf denselben Posten; Aufteilung eines vierteljährlichen Postens in einem Monat ohne Fälligkeit (Stichtag = Fälligkeitstag dieses Monats); Einträge ohne Datum liefern exakt dieselben Ergebnisse wie vor dem Commit (Projektion eines synthetischen Szenarios vor/nach dem Commit vergleichen, z. B. zwei Checkouts in einem Wegwerf-Verzeichnis); Reinheit + kein `float`.
|
||
|
||
---
|
||
|
||
### Task 3: Ladefunktion + API (Einträge, `/recurring`, Kategorie an Einmalzahlungen)
|
||
|
||
**Files:**
|
||
- Modify: `finance/app/services/projection_service.py` (`load_scenario_inputs`)
|
||
- Modify: `finance/app/routers/scenarios.py`
|
||
- Test: `finance/tests/test_scenario_inputs.py`, `finance/tests/test_crud_api.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ScenarioRecurringItem`, `ScenarioModifier.start_date`, `ScenarioPlannedItem.category_id` (Task 1); `PlainRecurring(id=None, …)`, `PlainModifier(..., start_date=…)`, `build_cashflows` (Task 2); `app.routers.planning.RecurringIn`, `app.routers.planning._check_category` (bestehend).
|
||
- Produces: `GET/POST /api/scenarios/{id}/recurring`, `PATCH/DELETE /api/scenarios/{id}/recurring/{item_id}` (Schema `RecurringIn`, Antwort `ScenarioRecurringOut` = `RecurringIn` + `id` + `scenario_id`); `ModifierIn.start_date`, `kind` inkl. `set`; `ScenarioPlannedIn.category_id`.
|
||
|
||
- [x] **Step 1: Failing Tests (Service)** — an `finance/tests/test_scenario_inputs.py` anhängen (Imports oben um `build_cashflows` aus `app.engine.scenario` und `ScenarioRecurringItem`, `Category` aus `app.models.tables` ergänzen):
|
||
|
||
```python
|
||
def test_szenario_zahlung_zaehlt_immer_und_id_kollision(db):
|
||
rec = RecurringItem(name="R", amount=Decimal("-10.00"), rhythm="monthly", due_day=1)
|
||
sc = Scenario(name="S-A14", description="", include_recurring=False,
|
||
include_planned=False)
|
||
db.add_all([rec, sc])
|
||
db.flush()
|
||
sri = ScenarioRecurringItem(scenario_id=sc.id, name="SR", amount=Decimal("-100.00"),
|
||
rhythm="monthly", due_day=1)
|
||
db.add(sri)
|
||
db.flush()
|
||
# Posten-Eintrag, dessen target_id zufällig der ID der Szenario-Zahlung entspricht:
|
||
db.add(ScenarioModifier(scenario_id=sc.id, target_type="recurring",
|
||
target_id=sri.id, kind="remove", value=Decimal("0")))
|
||
db.commit()
|
||
inp = load_scenario_inputs(db, sc)
|
||
# include_recurring=False: globaler Posten fehlt, Szenario-Zahlung zählt, id=None.
|
||
assert [(r.id, r.name) for r in inp.recurring] == [(None, "SR")]
|
||
flows = build_cashflows(inp.recurring, inp.planned, [], [], inp.modifiers,
|
||
date(2030, 1, 1), date(2030, 1, 31))
|
||
assert flows == [(date(2030, 1, 1), Decimal("-100.00"))]
|
||
# System-Plan kennt keine Szenario-Zahlungen.
|
||
assert [r.name for r in load_scenario_inputs(db, None).recurring] == ["R"]
|
||
|
||
|
||
def test_zeitraum_und_kategorie_werden_geladen(db):
|
||
cat = Category(name="K-A14")
|
||
sc = Scenario(name="S-A14b", description="")
|
||
db.add_all([cat, sc])
|
||
db.flush()
|
||
db.add_all([
|
||
ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=cat.id,
|
||
kind="percent", value=Decimal("50"),
|
||
start_date=date(2030, 2, 1), end_date=date(2030, 3, 31)),
|
||
ScenarioPlannedItem(scenario_id=sc.id, name="E", amount=Decimal("-5.00"),
|
||
due=date(2030, 2, 1), category_id=cat.id),
|
||
])
|
||
db.commit()
|
||
inp = load_scenario_inputs(db, sc)
|
||
m = inp.modifiers[0]
|
||
assert (m.start_date, m.end_date) == (date(2030, 2, 1), date(2030, 3, 31))
|
||
assert [(p.name, p.category_id) for p in inp.planned] == [("E", cat.id)]
|
||
```
|
||
|
||
- [x] **Step 2: Failing Tests (API)** — in `finance/tests/test_crud_api.py`:
|
||
|
||
Bestehende Erwartungen an die neue Semantik anpassen: in `test_modifier_ende_validierung` den letzten Block ersetzen durch
|
||
|
||
```python
|
||
# andere Arten behalten end_date als „Gültig bis"
|
||
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||
"target_type": "recurring", "target_id": 1, "kind": "remove",
|
||
"end_date": "2026-08-31"})
|
||
assert r.status_code == 201 and r.json()["end_date"] == "2026-08-31"
|
||
```
|
||
|
||
und in `test_modifier_patch` den Block „zurueck auf remove" ersetzen durch
|
||
|
||
```python
|
||
# zurueck auf remove: end_date bleibt als „Gültig bis" erhalten
|
||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||
"target_type": "recurring", "target_id": 1, "kind": "remove",
|
||
"end_date": "2026-08-31"})
|
||
assert r.status_code == 200 and r.json()["end_date"] == "2026-08-31"
|
||
```
|
||
|
||
Neue Tests anhängen:
|
||
|
||
```python
|
||
def _msgs(r):
|
||
return " ".join(e["msg"] for e in r.json()["detail"])
|
||
|
||
|
||
def test_modifier_zeitraum_und_neuer_betrag(client):
|
||
sc = client.post("/api/scenarios", headers=H, json={"name": "Zeitraum"}).json()
|
||
url = f"/api/scenarios/{sc['id']}/modifiers"
|
||
r = client.post(url, headers=H, json={
|
||
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "50",
|
||
"start_date": "2026-10-16", "end_date": "2027-03-31"})
|
||
assert r.status_code == 201
|
||
assert (r.json()["start_date"], r.json()["end_date"]) == ("2026-10-16", "2027-03-31")
|
||
r = client.post(url, headers=H, json={
|
||
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "50",
|
||
"start_date": "2027-04-01", "end_date": "2027-03-31"})
|
||
assert r.status_code == 422 and "»Gültig ab« liegt nach »Gültig bis«" in _msgs(r)
|
||
r = client.post(url, headers=H, json={
|
||
"target_type": "category", "target_id": 1, "kind": "set", "value": "-80.00"})
|
||
assert r.status_code == 422 and "Art »Neuer Betrag« ist nur für Posten möglich" in _msgs(r)
|
||
r = client.post(url, headers=H, json={
|
||
"target_type": "recurring", "target_id": 1, "kind": "set", "value": "-137.50",
|
||
"start_date": "2026-10-16"})
|
||
assert r.status_code == 201
|
||
assert (r.json()["kind"], r.json()["value"], r.json()["end_date"]) == ("set", "-137.50", None)
|
||
r = client.post(url, headers=H, json={
|
||
"target_type": "recurring", "target_id": 1, "kind": "ende",
|
||
"start_date": "2026-10-01", "end_date": "2026-10-15"})
|
||
assert r.status_code == 201
|
||
assert (r.json()["start_date"], r.json()["end_date"]) == (None, "2026-10-15")
|
||
|
||
|
||
def test_modifier_patch_artwechsel_zeitraum(client):
|
||
sc = client.post("/api/scenarios", headers=H, json={"name": "Artwechsel"}).json()
|
||
m = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "50",
|
||
"start_date": "2026-10-16", "end_date": "2027-03-31"}).json()
|
||
url = f"/api/scenarios/{sc['id']}/modifiers/{m['id']}"
|
||
r = client.patch(url, headers=H, json={
|
||
"target_type": "recurring", "target_id": 1, "kind": "ende",
|
||
"start_date": "2026-10-16", "end_date": "2026-12-31"})
|
||
assert r.status_code == 200
|
||
assert (r.json()["start_date"], r.json()["end_date"]) == (None, "2026-12-31")
|
||
r = client.patch(url, headers=H, json={
|
||
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "10",
|
||
"end_date": "2026-12-31"})
|
||
assert r.status_code == 200
|
||
assert (r.json()["kind"], r.json()["start_date"], r.json()["end_date"]) == \
|
||
("percent", None, "2026-12-31")
|
||
|
||
|
||
def test_scenario_recurring_crud_und_cleanup(client, db):
|
||
from app.models.tables import Category, ScenarioRecurringItem
|
||
cat = Category(name="Wohnen-SR")
|
||
db.add(cat)
|
||
db.commit()
|
||
sc = client.post("/api/scenarios", headers=H, json={"name": "SR-CRUD"}).json()
|
||
url = f"/api/scenarios/{sc['id']}/recurring"
|
||
body = {"name": "Miete neu", "amount": "-137.50", "rhythm": "monthly",
|
||
"due_day": 14, "start_date": "2026-10-16", "category_id": cat.id}
|
||
r = client.post(url, headers=H, json=body)
|
||
assert r.status_code == 201
|
||
item = r.json()
|
||
assert (item["scenario_id"], item["amount"], item["end_date"], item["category_id"]) == \
|
||
(sc["id"], "-137.50", None, cat.id)
|
||
assert [i["name"] for i in client.get(url, headers=H).json()] == ["Miete neu"]
|
||
r = client.patch(f"{url}/{item['id']}", headers=H, json={
|
||
"name": "Miete neu", "amount": "-250.00", "rhythm": "quarterly", "due_day": 1,
|
||
"start_date": None, "end_date": "2027-12-31", "category_id": None})
|
||
assert r.status_code == 200
|
||
assert (r.json()["amount"], r.json()["rhythm"], r.json()["category_id"]) == \
|
||
("-250.00", "quarterly", None)
|
||
# Validierung wie globale Fixposten
|
||
assert client.post(url, headers=H, json={**body, "due_day": 0}).status_code == 422
|
||
assert client.post(url, headers=H, json={**body, "rhythm": "weekly"}).status_code == 422
|
||
assert client.post(url, headers=H, json={**body, "end_date": "2026-10-01"}).status_code == 422
|
||
r = client.post(url, headers=H, json={**body, "category_id": 9999})
|
||
assert r.status_code == 404 and r.json()["detail"] == "Kategorie nicht gefunden"
|
||
# fremdes/fehlendes Szenario
|
||
other = client.post("/api/scenarios", headers=H, json={"name": "SR-Other"}).json()
|
||
r = client.delete(f"/api/scenarios/{other['id']}/recurring/{item['id']}", headers=H)
|
||
assert r.status_code == 404 and r.json()["detail"] == "Wiederkehrende Zahlung nicht gefunden"
|
||
assert client.get("/api/scenarios/99999/recurring", headers=H).status_code == 404
|
||
# Szenario löschen räumt mit ab (kein FK-Fehler)
|
||
assert client.delete(f"/api/scenarios/{sc['id']}", headers=H).status_code == 204
|
||
assert db.query(ScenarioRecurringItem).count() == 0
|
||
|
||
|
||
def test_scenario_recurring_delete(client):
|
||
sc = client.post("/api/scenarios", headers=H, json={"name": "SR-Del"}).json()
|
||
url = f"/api/scenarios/{sc['id']}/recurring"
|
||
item = client.post(url, headers=H, json={
|
||
"name": "X", "amount": "-1.00", "rhythm": "monthly", "due_day": 1}).json()
|
||
assert client.delete(f"{url}/{item['id']}", headers=H).status_code == 204
|
||
assert client.get(url, headers=H).json() == []
|
||
|
||
|
||
def test_scenario_planned_mit_kategorie(client, db):
|
||
from app.models.tables import Category
|
||
cat = Category(name="Kat-SPI")
|
||
db.add(cat)
|
||
db.commit()
|
||
sc = client.post("/api/scenarios", headers=H, json={"name": "SPI-Kat"}).json()
|
||
url = f"/api/scenarios/{sc['id']}/planned"
|
||
r = client.post(url, headers=H, json={
|
||
"name": "E", "amount": "-5.00", "due": "2026-11-01", "category_id": cat.id})
|
||
assert r.status_code == 201 and r.json()["category_id"] == cat.id
|
||
assert client.post(url, headers=H, json={
|
||
"name": "E", "amount": "-5.00", "due": "2026-11-01",
|
||
"category_id": 9999}).status_code == 404
|
||
r = client.patch(f"{url}/{r.json()['id']}", headers=H, json={
|
||
"name": "E", "amount": "-5.00", "due": "2026-11-01", "category_id": None})
|
||
assert r.status_code == 200 and r.json()["category_id"] is None
|
||
```
|
||
|
||
- [x] **Step 3: Tests laufen lassen** — `.venv/bin/python -m pytest -q tests/test_scenario_inputs.py tests/test_crud_api.py` → FAIL.
|
||
|
||
- [x] **Step 4: `load_scenario_inputs`** — Import `ScenarioRecurringItem` ergänzen; im Szenario-Zweig (nach dem `if scenario is None: … return`) den Block der Szenario-Einmalzahlungen ersetzen und Szenario-Zahlungen sowie `start_date` ergänzen:
|
||
|
||
```python
|
||
# Szenario-eigene Zahlungen zählen IMMER für dieses Szenario -
|
||
# include_recurring/include_planned steuern nur die globalen Posten.
|
||
# id=None: Posten-Einträge (target_type="recurring") treffen sie nie,
|
||
# die IDs zweier Tabellen könnten sonst kollidieren.
|
||
recurring += [PlainRecurring(None, r.name, Decimal(r.amount), r.rhythm, r.due_day,
|
||
r.start_date, r.end_date, r.category_id)
|
||
for r in session.execute(select(ScenarioRecurringItem).where(
|
||
ScenarioRecurringItem.scenario_id == scenario.id)
|
||
.order_by(ScenarioRecurringItem.id)).scalars()]
|
||
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
|
||
for p in session.execute(select(ScenarioPlannedItem).where(
|
||
ScenarioPlannedItem.scenario_id == scenario.id)
|
||
.order_by(ScenarioPlannedItem.id)).scalars()]
|
||
```
|
||
|
||
und die Modifikator-Liste:
|
||
|
||
```python
|
||
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind,
|
||
Decimal(m.value), m.end_date, m.start_date)
|
||
for m in session.execute(select(ScenarioModifier).where(
|
||
ScenarioModifier.scenario_id == scenario.id)
|
||
.order_by(ScenarioModifier.id)).scalars()]
|
||
```
|
||
|
||
Docstring von `load_scenario_inputs` um „keine Szenario-Zahlungen" ergänzen: „… keine Modifikatoren, keine Szenario-Einmalposten und keine szenario-eigenen wiederkehrenden Zahlungen."
|
||
|
||
- [x] **Step 5: `routers/scenarios.py`** — Importe:
|
||
|
||
```python
|
||
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
|
||
Scenario, ScenarioLoan, ScenarioModifier,
|
||
ScenarioPlannedItem, ScenarioRecurringItem)
|
||
from app.routers.planning import RecurringIn, _check_category
|
||
```
|
||
|
||
`ModifierIn` ersetzen:
|
||
|
||
```python
|
||
class ModifierIn(BaseModel):
|
||
target_type: Literal["category", "recurring"]
|
||
target_id: int
|
||
kind: Literal["percent", "absolute", "set", "remove", "ende"]
|
||
value: Decimal = Decimal("0")
|
||
start_date: date | None = None
|
||
end_date: date | None = None
|
||
|
||
@model_validator(mode="after")
|
||
def _art_und_zeitraum(self):
|
||
# end_date = Enddatum bei "ende", sonst „Gültig bis" (A14).
|
||
if self.kind == "ende":
|
||
if self.end_date is None:
|
||
raise ValueError("Art »Ende« erfordert ein Datum")
|
||
self.start_date = None
|
||
if self.kind == "set" and self.target_type != "recurring":
|
||
raise ValueError("Art »Neuer Betrag« ist nur für Posten möglich")
|
||
if (self.start_date is not None and self.end_date is not None
|
||
and self.start_date > self.end_date):
|
||
raise ValueError("»Gültig ab« liegt nach »Gültig bis«")
|
||
return self
|
||
```
|
||
|
||
In `delete_scenario` nach dem `ScenarioPlannedItem`-Delete:
|
||
|
||
```python
|
||
session.execute(delete(ScenarioRecurringItem).where(
|
||
ScenarioRecurringItem.scenario_id == scenario_id))
|
||
```
|
||
|
||
`ScenarioPlannedIn` um `category_id: int | None = None` ergänzen; in `add_scenario_planned` und `patch_scenario_planned` direkt nach `_get_scenario(...)` jeweils `_check_category(session, data.category_id)`.
|
||
|
||
Am Dateiende anhängen:
|
||
|
||
```python
|
||
class ScenarioRecurringOut(RecurringIn):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
scenario_id: int
|
||
|
||
|
||
def _get_scenario_recurring(session: Session, scenario_id: int,
|
||
item_id: int) -> ScenarioRecurringItem:
|
||
item = session.get(ScenarioRecurringItem, item_id)
|
||
if item is None or item.scenario_id != scenario_id:
|
||
raise HTTPException(404, "Wiederkehrende Zahlung nicht gefunden")
|
||
return item
|
||
|
||
|
||
@router.get("/{scenario_id}/recurring", response_model=list[ScenarioRecurringOut])
|
||
def list_scenario_recurring(scenario_id: int, session: Session = Depends(get_session)):
|
||
_get_scenario(session, scenario_id)
|
||
return [ScenarioRecurringOut.model_validate(r) for r in session.execute(
|
||
select(ScenarioRecurringItem)
|
||
.where(ScenarioRecurringItem.scenario_id == scenario_id)
|
||
.order_by(ScenarioRecurringItem.name, ScenarioRecurringItem.id)).scalars()]
|
||
|
||
|
||
@router.post("/{scenario_id}/recurring", response_model=ScenarioRecurringOut,
|
||
status_code=201)
|
||
def add_scenario_recurring(scenario_id: int, data: RecurringIn,
|
||
session: Session = Depends(get_session)):
|
||
# Bewusst ohne Doppelzählungs-Prüfung (gilt nur für globale Fixposten).
|
||
_get_scenario(session, scenario_id)
|
||
_check_category(session, data.category_id)
|
||
item = ScenarioRecurringItem(scenario_id=scenario_id, **data.model_dump())
|
||
session.add(item)
|
||
session.commit()
|
||
session.refresh(item)
|
||
return ScenarioRecurringOut.model_validate(item)
|
||
|
||
|
||
@router.patch("/{scenario_id}/recurring/{item_id}", response_model=ScenarioRecurringOut)
|
||
def patch_scenario_recurring(scenario_id: int, item_id: int, data: RecurringIn,
|
||
session: Session = Depends(get_session)):
|
||
# Full-Body-Update, Muster patch_scenario_planned.
|
||
_get_scenario(session, scenario_id)
|
||
item = _get_scenario_recurring(session, scenario_id, item_id)
|
||
_check_category(session, data.category_id)
|
||
for key, value in data.model_dump().items():
|
||
setattr(item, key, value)
|
||
session.commit()
|
||
session.refresh(item)
|
||
return ScenarioRecurringOut.model_validate(item)
|
||
|
||
|
||
@router.delete("/{scenario_id}/recurring/{item_id}", status_code=204)
|
||
def delete_scenario_recurring(scenario_id: int, item_id: int,
|
||
session: Session = Depends(get_session)):
|
||
_get_scenario(session, scenario_id)
|
||
session.delete(_get_scenario_recurring(session, scenario_id, item_id))
|
||
session.commit()
|
||
```
|
||
|
||
Prüfen, dass `app.routers.planning` `app.routers.scenarios` nicht importiert (kein Zirkelimport): `grep -n "scenarios" finance/app/routers/planning.py` → keine Import-Zeile.
|
||
|
||
- [x] **Step 6: Tests laufen lassen** — `.venv/bin/python -m pytest -q` → alle grün.
|
||
|
||
- [x] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add finance/app/services/projection_service.py finance/app/routers/scenarios.py \
|
||
finance/tests/test_scenario_inputs.py finance/tests/test_crud_api.py
|
||
git commit -m "feat: API für Szenario-Zeitraum, Neuer Betrag und szenario-eigene wiederkehrende Zahlungen (A14-T3)"
|
||
```
|
||
|
||
- [x] **Step 8: Fable-Gate** — Live-Approximation gegen lokalen uvicorn mit Wegwerf-SQLite (`FB_DATABASE_URL=sqlite:////<scratchpad>/a14.sqlite`, `FB_API_KEY=test`, `.venv/bin/alembic upgrade head`, `.venv/bin/uvicorn app.main:app --port 8199`): per curl ein Fixposten (monatlich am 14., erfundener Betrag), Szenario mit „Prozent 50 ab <16. eines Monats>" und einer szenario-eigenen wiederkehrenden Zahlung mit Kategorie; `POST /api/scenarios/{id}/project` → Tiefpunkt/Serie gegen eigene Handrechnung; alle 422/404-Fälle aus Step 2 per curl nachstellen (deutsche Texte in `detail`); `DELETE` des Szenarios hinterlässt 0 Zeilen in `scenario_recurring_items`; danach aufräumen.
|
||
|
||
---
|
||
|
||
### Task 4: Szenarien-Seite — zwei Formulare, Einträge-Tabelle, `szenarien.js`
|
||
|
||
**Files:**
|
||
- Create: `finance/app/static/szenarien.js`, `finance/tests/js/szenarien.dom.test.js`
|
||
- Modify: `finance/app/templates/szenarien.html`, `finance/app/templates/base.html`, `finance/app/routers/gui.py` (`_scenario_rows`, `szenarien_page`), `finance/app/formats.py`
|
||
- Test: `finance/tests/test_gui.py`, `finance/tests/test_formats.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Endpunkte aus Task 3 (`/modifiers` mit `start_date`, `/planned` mit `category_id`, `/recurring`); `ScenarioRecurringItem` (Task 1).
|
||
- Produces: `window.FBSzenario = {zielTyp(select), aenderungArt(select), zahlungArt(select), init(root)}`; Template-Kontext `row.recurring_items`, `rhythms`; `DE_LABELS["absolute"] = "Kürzung (€)"`, `DE_LABELS["set"] = "Neuer Betrag"`.
|
||
|
||
- [x] **Step 1: Failing Tests (Python)** — `finance/tests/test_formats.py::test_de_label_bekannte_und_unbekannte_werte`: Zeile `absolute` → `assert de_label("absolute") == "Kürzung (€)"`, neue Zeile `assert de_label("set") == "Neuer Betrag"`, Schlüsselmenge um `"set"` ergänzen.
|
||
|
||
In `finance/tests/test_gui.py`: den bestehenden Import aus `app.models.tables` um `Category` und `ScenarioRecurringItem` ergänzen. In `test_szenario_eintraege_tabelle` die letzte Zeile ersetzen durch `assert "FBSzenario.aenderungArt" in r and "onEntryArtChange" not in r`. In `test_neuer_eintrag_formular_struktur` `assert 'class="entry-form"' in r` ersetzen durch `assert 'class="entry-form aenderung-form"' in r and 'class="entry-form zahlung-form"' in r` (beide Formulare tragen jetzt zwei Klassen). In `test_statische_dateien_mit_versionskennung` die Dateiliste um `"szenarien.js"` ergänzen. Neue Tests anhängen:
|
||
|
||
```python
|
||
def test_szenario_zwei_formulare(client, db):
|
||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||
sc = Scenario(name="Formulare-A14", description="")
|
||
db.add(sc)
|
||
db.commit()
|
||
r = client.get("/szenarien").text
|
||
assert "Neue Änderung an Bestehendem" in r and "Neue Zahlung nur in diesem Szenario" in r
|
||
assert 'class="entry-form aenderung-form"' in r and 'class="entry-form zahlung-form"' in r
|
||
assert f'hx-post="/api/scenarios/{sc.id}/modifiers"' in r
|
||
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
|
||
assert f'data-recurring-url="/api/scenarios/{sc.id}/recurring"' in r
|
||
assert 'value="set">Neuer Betrag<' in r and 'value="absolute">Kürzung (€)<' in r
|
||
for feld in ('name="start_date"', 'name="end_date"', 'name="rhythm"',
|
||
'name="due_day"', 'name="category_id"', 'name="due"'):
|
||
assert feld in r, feld
|
||
assert 'class="zahlung-art"' in r and '<option value="">(keine)</option>' in r
|
||
assert 'class="bis-label"' in r
|
||
|
||
|
||
def test_szenario_eintraege_zeitraum_und_wiederkehrend(client, db):
|
||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||
cat = Category(name="Kat-Anzeige")
|
||
sc = Scenario(name="Anzeige-A14", description="")
|
||
db.add_all([cat, sc])
|
||
db.flush()
|
||
db.add_all([
|
||
ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1,
|
||
kind="percent", value=Decimal("50"), start_date=date(2026, 10, 16)),
|
||
ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1,
|
||
kind="set", value=Decimal("-80.00"),
|
||
start_date=date(2026, 11, 1), end_date=date(2027, 3, 31)),
|
||
ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=cat.id,
|
||
kind="remove", value=Decimal("0"), end_date=date(2027, 6, 30)),
|
||
ScenarioRecurringItem(scenario_id=sc.id, name="Zahlung-W", amount=Decimal("-137.50"),
|
||
rhythm="monthly", due_day=14, start_date=date(2026, 10, 16),
|
||
category_id=cat.id),
|
||
ScenarioPlannedItem(scenario_id=sc.id, name="Zahlung-E", amount=Decimal("-5.00"),
|
||
due=date(2026, 12, 1), category_id=cat.id),
|
||
])
|
||
db.commit()
|
||
r = client.get("/szenarien").text
|
||
assert "<th>Zeitraum</th>" in r
|
||
assert "ab 16.10.2026" in r and "01.11.2026 – 31.03.2027" in r and "bis 30.06.2027" in r
|
||
assert ">Neuer Betrag<" in r and "-80,00" in r
|
||
assert "Zahlung-W" in r and "Wiederkehrend (monatlich, am 14.)" in r and "-137,50" in r
|
||
assert f'hx-patch="/api/scenarios/{sc.id}/recurring/' in r
|
||
assert f'hx-delete="/api/scenarios/{sc.id}/recurring/' in r
|
||
assert "Kat-Anzeige" in r and "Zahlung-E" in r
|
||
assert 'id="sri-row-' in r and 'id="sri-edit-' in r
|
||
```
|
||
|
||
- [x] **Step 2: Failing Tests (JS)** — `finance/tests/js/szenarien.dom.test.js`:
|
||
|
||
```js
|
||
'use strict';
|
||
const test = require('node:test');
|
||
const assert = require('node:assert');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
let JSDOM = null;
|
||
try { ({ JSDOM } = require('jsdom')); } catch (e) { /* jsdom fehlt -> skip */ }
|
||
const SRC = fs.readFileSync(path.join(__dirname, '../../app/static/szenarien.js'), 'utf8');
|
||
const TIP = 'Für diese Eintragsart nicht relevant';
|
||
|
||
const HTML = `
|
||
<form id="a" class="entry-form aenderung-form" hx-post="/api/scenarios/1/modifiers">
|
||
<select name="kind"><option value="percent">Prozent</option><option value="absolute">Kürzung (€)</option>
|
||
<option value="set">Neuer Betrag</option><option value="remove">Entfällt</option><option value="ende">Ende</option></select>
|
||
<select name="target_type"><option value="category">Kategorie</option><option value="recurring">Wiederkehrender Posten</option></select>
|
||
<span class="mod-target-category"><select name="target_id"><option value="1">K</option></select></span>
|
||
<span class="mod-target-recurring" style="display:none"><select name="target_id" disabled><option value="2">P</option></select></span>
|
||
<label><span class="value-label">Prozentsatz (%)</span><input name="value"></label>
|
||
<input type="date" name="start_date">
|
||
<label><span class="bis-label">Gültig bis</span><input type="date" name="end_date"></label>
|
||
</form>
|
||
<form id="z" class="entry-form zahlung-form" hx-post="/api/scenarios/1/planned"
|
||
data-planned-url="/api/scenarios/1/planned" data-recurring-url="/api/scenarios/1/recurring">
|
||
<select class="zahlung-art"><option value="einmal">Einmalzahlung</option><option value="wiederkehrend">Wiederkehrend</option></select>
|
||
<input name="name"><input name="amount"><select name="category_id"><option value="">(keine)</option></select>
|
||
<input type="date" name="due">
|
||
<select name="rhythm"><option value="monthly">monatlich</option></select>
|
||
<input name="due_day"><input type="date" name="start_date"><input type="date" name="end_date">
|
||
</form>`;
|
||
|
||
function load() {
|
||
const dom = new JSDOM(`<!doctype html><body>${HTML}</body>`, { runScripts: 'outside-only' });
|
||
const w = dom.window;
|
||
const processed = [];
|
||
w.htmx = { process: (el) => processed.push(el.id) };
|
||
w.eval(SRC);
|
||
w.FBSzenario.init(w.document);
|
||
const d = w.document;
|
||
return { w, a: d.getElementById('a'), z: d.getElementById('z'), processed };
|
||
}
|
||
|
||
function f(form, name) { return form.querySelector(`[name="${name}"]`); }
|
||
function art(w, form, wert) {
|
||
const sel = form.querySelector('[name="kind"]');
|
||
sel.value = wert;
|
||
w.FBSzenario.aenderungArt(sel);
|
||
}
|
||
|
||
test('Prozent: Wert und Zeitraum aktiv', { skip: !JSDOM }, () => {
|
||
const { a } = load();
|
||
assert.equal(f(a, 'value').disabled, false);
|
||
assert.equal(a.querySelector('.value-label').textContent, 'Prozentsatz (%)');
|
||
assert.equal(f(a, 'start_date').disabled, false);
|
||
assert.equal(f(a, 'end_date').disabled, false);
|
||
assert.equal(a.querySelector('.bis-label').textContent, 'Gültig bis');
|
||
});
|
||
|
||
test('Kürzung: Label Kürzung (€)', { skip: !JSDOM }, () => {
|
||
const { w, a } = load();
|
||
art(w, a, 'absolute');
|
||
assert.equal(a.querySelector('.value-label').textContent, 'Kürzung (€)');
|
||
});
|
||
|
||
test('Neuer Betrag: Ziel-Typ fest auf Posten, Kategorie gesperrt', { skip: !JSDOM }, () => {
|
||
const { w, a } = load();
|
||
art(w, a, 'set');
|
||
const typ = f(a, 'target_type');
|
||
assert.equal(typ.value, 'recurring');
|
||
assert.equal(typ.querySelector('option[value="category"]').disabled, true);
|
||
assert.equal(a.querySelector('.mod-target-recurring select').disabled, false);
|
||
assert.equal(a.querySelector('.mod-target-category select').disabled, true);
|
||
assert.equal(a.querySelector('.value-label').textContent, 'Neuer Betrag (€)');
|
||
art(w, a, 'percent');
|
||
assert.equal(typ.querySelector('option[value="category"]').disabled, false);
|
||
});
|
||
|
||
test('Ende: Gültig ab und Wert gesperrt, Label Endet am', { skip: !JSDOM }, () => {
|
||
const { w, a } = load();
|
||
art(w, a, 'ende');
|
||
assert.equal(f(a, 'start_date').disabled, true);
|
||
assert.equal(f(a, 'start_date').title, TIP);
|
||
assert.equal(f(a, 'value').disabled, true);
|
||
assert.equal(f(a, 'end_date').disabled, false);
|
||
assert.equal(a.querySelector('.bis-label').textContent, 'Endet am');
|
||
});
|
||
|
||
test('Entfällt: Wert gesperrt, Zeitraum aktiv', { skip: !JSDOM }, () => {
|
||
const { w, a } = load();
|
||
art(w, a, 'remove');
|
||
assert.equal(f(a, 'value').disabled, true);
|
||
assert.equal(a.querySelector('.value-label').textContent, 'Wert');
|
||
assert.equal(f(a, 'start_date').disabled, false);
|
||
assert.equal(a.querySelector('.bis-label').textContent, 'Gültig bis');
|
||
});
|
||
|
||
test('Zahlung: Einmal → Wiederkehrend → Einmal', { skip: !JSDOM }, () => {
|
||
const { w, z, processed } = load();
|
||
const sel = z.querySelector('.zahlung-art');
|
||
const wiederkehrend = ['rhythm', 'due_day', 'start_date', 'end_date'];
|
||
assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/planned');
|
||
assert.equal(f(z, 'due').disabled, false);
|
||
wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, true, n));
|
||
sel.value = 'wiederkehrend';
|
||
w.FBSzenario.zahlungArt(sel);
|
||
assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/recurring');
|
||
assert.equal(f(z, 'due').disabled, true);
|
||
assert.equal(f(z, 'due').title, TIP);
|
||
wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, false, n));
|
||
sel.value = 'einmal';
|
||
w.FBSzenario.zahlungArt(sel);
|
||
assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/planned');
|
||
wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, true, n));
|
||
['name', 'amount', 'category_id'].forEach((n) => assert.equal(f(z, n).disabled, false, n));
|
||
assert.ok(processed.includes('z'));
|
||
});
|
||
```
|
||
|
||
- [x] **Step 3: Tests laufen lassen** — `.venv/bin/python -m pytest -q tests/test_gui.py tests/test_formats.py` → FAIL. JS per Podman (Host hat kein node):
|
||
|
||
```bash
|
||
podman run --rm -v "$PWD":/w:Z -w /w docker.io/library/node:22-alpine \
|
||
sh -c 'npm i --silent --no-save --prefix /tmp/j jsdom >/dev/null 2>&1; NODE_PATH=/tmp/j/node_modules node --test tests/js/szenarien.dom.test.js tests/js/doppelzaehlung.test.js tests/js/doppelzaehlung.dom.test.js tests/js/sort.test.js tests/js/sort.dom.test.js'
|
||
```
|
||
|
||
Erwartet: `szenarien.dom.test.js` FAIL (Datei `szenarien.js` fehlt).
|
||
|
||
- [x] **Step 4: `formats.py`** — in `DE_LABELS` `"absolute": "Kürzung (€)"` und nach `"absolute"` neu `"set": "Neuer Betrag"`.
|
||
|
||
- [x] **Step 5: `static/szenarien.js`**
|
||
|
||
```js
|
||
/* Szenarien-Seite (Ausbaustufe 14): Umschaltlogik der Formulare
|
||
* „Neue Änderung an Bestehendem" (.aenderung-form, auch in den Edit-Zeilen
|
||
* der Einträge) und „Neue Zahlung nur in diesem Szenario" (.zahlung-form).
|
||
* UX-Regel: Felder werden nur per disabled gesperrt, nie entfernt;
|
||
* gesperrte Felder tragen den Tooltip TIP. Gesperrte Felder sendet htmx
|
||
* nicht mit.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
var TIP = 'Für diese Eintragsart nicht relevant';
|
||
var WERT_LABEL = { percent: 'Prozentsatz (%)', absolute: 'Kürzung (€)', set: 'Neuer Betrag (€)' };
|
||
|
||
function sperren(el, gesperrt) {
|
||
if (!el) { return; }
|
||
el.disabled = gesperrt;
|
||
if (gesperrt) { el.title = TIP; } else { el.removeAttribute('title'); }
|
||
}
|
||
|
||
function feld(form, name) { return form.querySelector('[name="' + name + '"]'); }
|
||
|
||
function beschriften(form, cls, text) {
|
||
var el = form.querySelector('.' + cls);
|
||
if (el) { el.textContent = text; }
|
||
}
|
||
|
||
// Ziel-Typ: passende Auswahl (Kategorie oder Posten) zeigen und freigeben.
|
||
function zielTyp(select) {
|
||
var form = select.closest('form');
|
||
var istKategorie = select.value === 'category';
|
||
var kat = form.querySelector('.mod-target-category');
|
||
var rec = form.querySelector('.mod-target-recurring');
|
||
kat.style.display = istKategorie ? '' : 'none';
|
||
rec.style.display = istKategorie ? 'none' : '';
|
||
sperren(kat.querySelector('select'), !istKategorie);
|
||
sperren(rec.querySelector('select'), istKategorie);
|
||
}
|
||
|
||
function aenderungArt(select) {
|
||
var form = select.closest('form');
|
||
var art = select.value;
|
||
var mitWert = Object.prototype.hasOwnProperty.call(WERT_LABEL, art);
|
||
sperren(feld(form, 'value'), !mitWert);
|
||
beschriften(form, 'value-label', mitWert ? WERT_LABEL[art] : 'Wert');
|
||
sperren(feld(form, 'start_date'), art === 'ende');
|
||
beschriften(form, 'bis-label', art === 'ende' ? 'Endet am' : 'Gültig bis');
|
||
var typ = feld(form, 'target_type');
|
||
var kategorieOption = typ.querySelector('option[value="category"]');
|
||
// „Neuer Betrag" gibt es nur für Posten.
|
||
if (art === 'set') { typ.value = 'recurring'; }
|
||
kategorieOption.disabled = art === 'set';
|
||
zielTyp(typ);
|
||
}
|
||
|
||
function zahlungArt(select) {
|
||
var form = select.closest('form');
|
||
var einmal = select.value === 'einmal';
|
||
sperren(feld(form, 'due'), !einmal);
|
||
['rhythm', 'due_day', 'start_date', 'end_date'].forEach(function (name) {
|
||
sperren(feld(form, name), einmal);
|
||
});
|
||
form.setAttribute('hx-post', form.getAttribute(einmal ? 'data-planned-url' : 'data-recurring-url'));
|
||
if (window.htmx) { window.htmx.process(form); }
|
||
}
|
||
|
||
function init(root) {
|
||
root.querySelectorAll('.aenderung-form select[name="kind"]').forEach(aenderungArt);
|
||
root.querySelectorAll('.zahlung-form select.zahlung-art').forEach(zahlungArt);
|
||
}
|
||
|
||
window.FBSzenario = { zielTyp: zielTyp, aenderungArt: aenderungArt, zahlungArt: zahlungArt, init: init };
|
||
})();
|
||
```
|
||
|
||
- [x] **Step 6: `base.html`** — nach der `doppelzaehlung.js`-Zeile:
|
||
|
||
```html
|
||
<script src="/static/szenarien.js?v={{ app_version }}"></script>
|
||
```
|
||
|
||
und nach `if (window.FBDoppel) { window.FBDoppel.init(document); }`:
|
||
|
||
```js
|
||
if (window.FBSzenario) { window.FBSzenario.init(document); }
|
||
```
|
||
|
||
- [x] **Step 7: `gui.py`** — Import `ScenarioRecurringItem` ergänzen. In `_scenario_rows` nach `planned_items`:
|
||
|
||
```python
|
||
recurring_items = session.execute(
|
||
select(ScenarioRecurringItem)
|
||
.where(ScenarioRecurringItem.scenario_id == sc.id)
|
||
.order_by(ScenarioRecurringItem.name, ScenarioRecurringItem.id)).scalars().all()
|
||
```
|
||
|
||
und `"recurring_items": recurring_items,` ins Row-Dict. In `szenarien_page`: `"modifier_kinds": ["percent", "absolute", "set", "remove", "ende"],` und neu `"rhythms": ["monthly", "quarterly", "yearly"],`.
|
||
|
||
- [x] **Step 8: `szenarien.html`** — direkt nach `{% block content %}` das Makro:
|
||
|
||
```jinja
|
||
{% macro zeitraum(von, bis) -%}
|
||
{%- if von and bis -%}{{ von.strftime('%d.%m.%Y') }} – {{ bis.strftime('%d.%m.%Y') }}
|
||
{%- elif von -%}ab {{ von.strftime('%d.%m.%Y') }}
|
||
{%- elif bis -%}bis {{ bis.strftime('%d.%m.%Y') }}
|
||
{%- else -%}–{%- endif -%}
|
||
{%- endmacro %}
|
||
{% set TIP = 'Für diese Eintragsart nicht relevant' %}
|
||
```
|
||
|
||
Den gesamten Bereich von `<h3>Einträge</h3>` bis vor `<hr>` ersetzen durch:
|
||
|
||
```jinja
|
||
<h3>Einträge</h3>
|
||
<table>
|
||
<thead>
|
||
<tr><th>Was</th><th>Art</th><th>Wert/Betrag</th><th>Zeitraum</th><th></th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for m in row.modifiers %}
|
||
<tr id="mod-row-{{ m.id }}">
|
||
<td>
|
||
{% if m.target_type == "category" %}Kategorie: {{ category_names.get(m.target_id, m.target_id) }}
|
||
{% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %}
|
||
</td>
|
||
<td>{{ m.kind|de_label }}</td>
|
||
<td>{% if m.kind in ('percent', 'absolute', 'set') %}{{ m.value|eur }}{% else %}–{% endif %}</td>
|
||
<td>{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}{% else %}{{ zeitraum(m.start_date, m.end_date) }}{% endif %}</td>
|
||
<td>
|
||
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, true)">Bearbeiten</button>
|
||
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||
<button type="submit">Löschen</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
<tr id="mod-edit-{{ m.id }}" hidden data-sort-follow>
|
||
<td colspan="5">
|
||
<form class="aenderung-form" hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||
<label>Art
|
||
<select name="kind" data-type="str" onchange="FBSzenario.aenderungArt(this)">
|
||
{% for k in modifier_kinds %}<option value="{{ k }}" {% if k == m.kind %}selected{% endif %}>{{ k|de_label }}</option>{% endfor %}
|
||
</select>
|
||
</label>
|
||
<label>Ziel-Typ
|
||
<select name="target_type" data-type="str" onchange="FBSzenario.zielTyp(this)">
|
||
<option value="category" {% if m.target_type == 'category' %}selected{% endif %}>Kategorie</option>
|
||
<option value="recurring" {% if m.target_type == 'recurring' %}selected{% endif %}>Wiederkehrender Posten</option>
|
||
</select>
|
||
</label>
|
||
<span class="mod-target-category" {% if m.target_type != 'category' %}style="display:none"{% endif %}>
|
||
<label>Kategorie
|
||
<select name="target_id" data-type="int" {% if m.target_type != 'category' %}disabled{% endif %}>
|
||
{% for c in categories %}<option value="{{ c.id }}" {% if m.target_type == 'category' and c.id == m.target_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
|
||
</select>
|
||
</label>
|
||
</span>
|
||
<span class="mod-target-recurring" {% if m.target_type != 'recurring' %}style="display:none"{% endif %}>
|
||
<label>Posten
|
||
<select name="target_id" data-type="int" {% if m.target_type != 'recurring' %}disabled{% endif %}>
|
||
{% for r in recurring %}<option value="{{ r.id }}" {% if m.target_type == 'recurring' and r.id == m.target_id %}selected{% endif %}>{{ r.name }}</option>{% endfor %}
|
||
</select>
|
||
</label>
|
||
</span>
|
||
<label><span class="value-label">Wert</span> <input type="text" name="value" data-type="amount" value="{{ m.value|eur }}" {% if m.kind not in ('percent', 'absolute', 'set') %}disabled title="{{ TIP }}"{% endif %}></label>
|
||
<label>Gültig ab <input type="date" name="start_date" value="{{ m.start_date.isoformat() if m.start_date else '' }}" {% if m.kind == 'ende' %}disabled title="{{ TIP }}"{% endif %}></label>
|
||
<label><span class="bis-label">{{ 'Endet am' if m.kind == 'ende' else 'Gültig bis' }}</span> <input type="date" name="end_date" value="{{ m.end_date.isoformat() if m.end_date else '' }}"></label>
|
||
<button type="submit">Speichern</button>
|
||
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, false)">Abbrechen</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
{% for ri in row.recurring_items %}
|
||
<tr id="sri-row-{{ ri.id }}">
|
||
<td>{{ ri.name }}{% if ri.category_id %} <span class="muted">({{ category_names.get(ri.category_id, '') }})</span>{% endif %}</td>
|
||
<td>Wiederkehrend ({{ ri.rhythm|de_label }}, am {{ ri.due_day }}.)</td>
|
||
<td class="{{ 'neg' if ri.amount < 0 else '' }}">{{ ri.amount|eur }} €</td>
|
||
<td>{{ zeitraum(ri.start_date, ri.end_date) }}</td>
|
||
<td>
|
||
<button type="button" onclick="toggleEdit('sri', {{ ri.id }}, true)">Bearbeiten</button>
|
||
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/recurring/{{ ri.id }}" hx-swap="none"
|
||
hx-confirm="„{{ ri.name }}“ wirklich löschen?"
|
||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||
<button type="submit">Löschen</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
<tr id="sri-edit-{{ ri.id }}" hidden data-sort-follow>
|
||
<td colspan="5">
|
||
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/recurring/{{ ri.id }}" hx-swap="none"
|
||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||
<label>Name <input type="text" name="name" value="{{ ri.name }}" required></label>
|
||
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ ri.amount|eur }}" required></label>
|
||
<label>Kategorie
|
||
<select name="category_id" data-type="int">
|
||
<option value="">(keine)</option>
|
||
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == ri.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
|
||
</select>
|
||
</label>
|
||
<label>Rhythmus
|
||
<select name="rhythm" data-type="str">
|
||
{% for rh in rhythms %}<option value="{{ rh }}" {% if rh == ri.rhythm %}selected{% endif %}>{{ rh|de_label }}</option>{% endfor %}
|
||
</select>
|
||
</label>
|
||
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" value="{{ ri.due_day }}" required></label>
|
||
<label>Start <input type="date" name="start_date" value="{{ ri.start_date.isoformat() if ri.start_date else '' }}"></label>
|
||
<label>Ende <input type="date" name="end_date" value="{{ ri.end_date.isoformat() if ri.end_date else '' }}"></label>
|
||
<button type="submit">Speichern</button>
|
||
<button type="button" onclick="toggleEdit('sri', {{ ri.id }}, false)">Abbrechen</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
{% for sp in row.planned_items %}
|
||
<tr id="spi-row-{{ sp.id }}">
|
||
<td>{{ sp.name }}{% if sp.category_id %} <span class="muted">({{ category_names.get(sp.category_id, '') }})</span>{% endif %}</td>
|
||
<td>Einmalzahlung</td>
|
||
<td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
|
||
<td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
|
||
<td>
|
||
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, true)">Bearbeiten</button>
|
||
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
|
||
hx-confirm="„{{ sp.name }}“ wirklich löschen?"
|
||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||
<button type="submit">Löschen</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
<tr id="spi-edit-{{ sp.id }}" hidden data-sort-follow>
|
||
<td colspan="5">
|
||
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
|
||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||
<label>Name <input type="text" name="name" value="{{ sp.name }}" required></label>
|
||
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ sp.amount|eur }}" required></label>
|
||
<label>Kategorie
|
||
<select name="category_id" data-type="int">
|
||
<option value="">(keine)</option>
|
||
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == sp.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
|
||
</select>
|
||
</label>
|
||
<label>Fällig am <input type="date" name="due" value="{{ sp.due.isoformat() }}" required></label>
|
||
<button type="submit">Speichern</button>
|
||
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, false)">Abbrechen</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
{% if not row.modifiers and not row.planned_items and not row.recurring_items %}
|
||
<tr><td colspan="5">Keine Einträge.</td></tr>
|
||
{% endif %}
|
||
</tbody>
|
||
</table>
|
||
|
||
<h4>Neue Änderung an Bestehendem</h4>
|
||
<form class="entry-form aenderung-form" hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none"
|
||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||
<div class="entry-art">
|
||
<label>Art
|
||
<select name="kind" data-type="str" onchange="FBSzenario.aenderungArt(this)">
|
||
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
|
||
</select>
|
||
</label>
|
||
<span class="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span>
|
||
</div>
|
||
<label>Ziel-Typ
|
||
<select name="target_type" data-type="str" onchange="FBSzenario.zielTyp(this)">
|
||
<option value="category">Kategorie</option>
|
||
<option value="recurring">Wiederkehrender Posten</option>
|
||
</select>
|
||
</label>
|
||
<span class="mod-target-category">
|
||
<label>Kategorie
|
||
<select name="target_id" data-type="int">
|
||
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
|
||
</select>
|
||
</label>
|
||
</span>
|
||
<span class="mod-target-recurring" style="display:none">
|
||
<label>Posten
|
||
<select name="target_id" data-type="int" disabled title="{{ TIP }}">
|
||
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
|
||
</select>
|
||
</label>
|
||
</span>
|
||
<label><span class="value-label">Prozentsatz (%)</span> <input type="text" name="value" data-type="amount" value="0"></label>
|
||
<label>Gültig ab <input type="date" name="start_date"></label>
|
||
<label><span class="bis-label">Gültig bis</span> <input type="date" name="end_date"></label>
|
||
<button type="submit">Hinzufügen</button>
|
||
</form>
|
||
|
||
<h4>Neue Zahlung nur in diesem Szenario</h4>
|
||
<form class="entry-form zahlung-form" hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/planned" hx-swap="none"
|
||
data-planned-url="/api/scenarios/{{ sc.id }}/planned"
|
||
data-recurring-url="/api/scenarios/{{ sc.id }}/recurring"
|
||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||
<div class="entry-art">
|
||
<label>Art
|
||
<select class="zahlung-art" onchange="FBSzenario.zahlungArt(this)">
|
||
<option value="einmal">Einmalzahlung</option>
|
||
<option value="wiederkehrend">Wiederkehrend</option>
|
||
</select>
|
||
</label>
|
||
<span class="muted">— zählt nur in diesem Szenario, auch ohne „Wiederkehrende Posten/Einmalposten einschließen“.</span>
|
||
</div>
|
||
<label>Name <input type="text" name="name" required></label>
|
||
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="-250,00" required></label>
|
||
<label>Kategorie
|
||
<select name="category_id" data-type="int">
|
||
<option value="">(keine)</option>
|
||
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
|
||
</select>
|
||
</label>
|
||
<label>Fällig am <input type="date" name="due" required></label>
|
||
<label>Rhythmus
|
||
<select name="rhythm" data-type="str" disabled title="{{ TIP }}">
|
||
{% for rh in rhythms %}<option value="{{ rh }}">{{ rh|de_label }}</option>{% endfor %}
|
||
</select>
|
||
</label>
|
||
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" required disabled title="{{ TIP }}"></label>
|
||
<label>Start <input type="date" name="start_date" disabled title="{{ TIP }}"></label>
|
||
<label>Ende <input type="date" name="end_date" disabled title="{{ TIP }}"></label>
|
||
<button type="submit">Hinzufügen</button>
|
||
</form>
|
||
```
|
||
|
||
Im `<script>`-Block am Ende von `szenarien.html` die Funktionen `onModTargetTypeChange` und `onEntryArtChange` löschen (ersetzt durch `FBSzenario`); `toggleScenarioLoan` bleibt.
|
||
|
||
- [x] **Step 9: Tests laufen lassen** — `.venv/bin/python -m pytest -q` → alle grün; `grep -rn "onEntryArtChange\|onModTargetTypeChange" finance/app` → keine Treffer. JS-Kommando aus Step 3 → alle grün, DOM-Tests nicht geskippt.
|
||
|
||
- [x] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add finance/app/static/szenarien.js finance/app/templates/szenarien.html finance/app/templates/base.html \
|
||
finance/app/routers/gui.py finance/app/formats.py \
|
||
finance/tests/test_gui.py finance/tests/test_formats.py finance/tests/js/szenarien.dom.test.js
|
||
git commit -m "feat: Szenarien-Seite mit zwei Formularen, Zeitraum-Spalte und wiederkehrenden Szenario-Zahlungen (A14-T4)"
|
||
```
|
||
|
||
- [x] **Step 11: Fable-Gate** — Browser-Test gegen lokalen uvicorn (Wegwerf-SQLite wie Task 3, `FB_GUI_PASSWORD_HASH` per `app.auth.hash_password`, synthetische Daten: 1 Kategorie, 1 Fixposten, 1 Szenario) per Playwright-Container (`podman run --rm --network host mcr.microsoft.com/playwright/python:v1.47.0-jammy …`): Login; im Formular „Neue Änderung" jede Art durchschalten und gesperrte Felder prüfen; „Neuer Betrag ab <Datum>" für den Posten anlegen → Zeile mit „ab TT.MM.JJJJ"; „Gültig ab" > „Gültig bis" → Fehlermeldung, Formular bleibt; „Neue Zahlung" → Wiederkehrend anlegen (mit Kategorie) → Zeile „Wiederkehrend (…)"; zurück auf Einmalzahlung → anlegen landet als Einmalzahlung; Bearbeiten/Speichern einer wiederkehrenden Zahlung und eines Eintrags (Art wechseln auf Ende → „Gültig ab" gesperrt); Löschen; „Durchrechnen" → 200; Screenshots bei 1200 px und 390 px ansehen; danach aufräumen.
|
||
|
||
---
|
||
|
||
### Task 5: Backend-Kleinpunkte — Tilgungsplan-Helfer, Kredit-Laufzeit
|
||
|
||
**Files:**
|
||
- Modify: `finance/app/engine/loans.py`, `finance/app/routers/planning.py`, `finance/app/services/breakdown.py`, `finance/app/services/projection_service.py`, `finance/app/services/planning_chart.py`
|
||
- Test: `finance/tests/test_loans.py`, `finance/tests/test_planning_api.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `app.engine.loans.schedule_for(loan) -> list[Installment]` für jedes Objekt mit `principal`, `annual_rate_pct`, `term_months`, `payout_date`, `repayment_type` (ORM-`Loan` oder gleichartig; Engine importiert keine Modelle).
|
||
|
||
- [x] **Step 1: Failing Tests** — an `finance/tests/test_loans.py` anhängen:
|
||
|
||
```python
|
||
def test_schedule_for_entspricht_loan_schedule():
|
||
from types import SimpleNamespace
|
||
from app.engine.loans import schedule_for
|
||
kredit = SimpleNamespace(principal="1200.00", annual_rate_pct="3.00", term_months=12,
|
||
payout_date=date(2030, 1, 31), repayment_type="annuity")
|
||
assert schedule_for(kredit) == loan_schedule(Decimal("1200.00"), Decimal("3.00"), 12,
|
||
date(2030, 1, 31), "annuity")
|
||
|
||
|
||
def test_keine_manuelle_tilgungsplan_konvertierung_mehr():
|
||
from pathlib import Path
|
||
app_dir = Path(__file__).resolve().parent.parent / "app"
|
||
engine_loans = app_dir / "engine" / "loans.py"
|
||
treffer = [str(p.relative_to(app_dir)) for p in app_dir.rglob("*.py")
|
||
if p != engine_loans and "loan_schedule(Decimal(" in p.read_text()]
|
||
assert treffer == []
|
||
```
|
||
|
||
(`date`, `Decimal`, `loan_schedule` sind in `test_loans.py` bereits importiert.)
|
||
|
||
An `finance/tests/test_planning_api.py` anhängen:
|
||
|
||
```python
|
||
KREDIT = {"name": "Laufzeit-Test", "principal": "1000.00", "annual_rate_pct": "2.00",
|
||
"payout_date": "2030-01-01", "repayment_type": "annuity"}
|
||
|
||
|
||
@pytest.mark.parametrize("laufzeit", [0, -5, 1201])
|
||
def test_kredit_laufzeit_ausserhalb_1_bis_1200(client, laufzeit):
|
||
r = client.post("/api/loans", headers=H, json={**KREDIT, "term_months": laufzeit})
|
||
assert r.status_code == 422
|
||
|
||
|
||
def test_kredit_laufzeit_grenzen_und_patch(client):
|
||
a = client.post("/api/loans", headers=H, json={**KREDIT, "term_months": 1})
|
||
b = client.post("/api/loans", headers=H, json={**KREDIT, "name": "L2", "term_months": 1200})
|
||
assert (a.status_code, b.status_code) == (201, 201)
|
||
url = f"/api/loans/{a.json()['id']}"
|
||
assert client.patch(url, headers=H, json={"term_months": 0}).status_code == 422
|
||
assert client.patch(url, headers=H, json={"term_months": 1201}).status_code == 422
|
||
r = client.patch(url, headers=H, json={"term_months": 1200})
|
||
assert r.status_code == 200 and r.json()["term_months"] == 1200
|
||
```
|
||
|
||
(`H` ist in `test_planning_api.py` definiert; `import pytest` oben ergänzen.)
|
||
|
||
- [x] **Step 2: Tests laufen lassen** — `.venv/bin/python -m pytest -q tests/test_loans.py tests/test_planning_api.py` → FAIL.
|
||
|
||
- [x] **Step 3: `engine/loans.py`** — am Dateiende:
|
||
|
||
```python
|
||
def schedule_for(loan) -> list[Installment]:
|
||
"""Tilgungsplan eines Kredit-Objekts (ORM-Loan oder gleichartig mit
|
||
principal, annual_rate_pct, term_months, payout_date, repayment_type).
|
||
Ersetzt die wiederholte Feld-für-Feld-Übergabe an loan_schedule (A14)."""
|
||
return loan_schedule(Decimal(loan.principal), Decimal(loan.annual_rate_pct),
|
||
loan.term_months, loan.payout_date, loan.repayment_type)
|
||
```
|
||
|
||
- [x] **Step 4: Aufrufstellen umstellen** (Import jeweils `from app.engine.loans import schedule_for`; `loan_schedule`-Import entfernen, wo er danach unbenutzt ist):
|
||
- `routers/planning.py::get_loan_schedule`: `plan = schedule_for(loan)`
|
||
- `services/breakdown.py::compute_breakdown`: `loans = [PlainLoan(l.id, l.name, schedule_for(l)) for l in inputs.loans]`
|
||
- `services/projection_service.py::run_projection`: `schedules = [schedule_for(l) for l in inputs.loans]`
|
||
- `services/planning_chart.py::planning_monthly_totals`: `for inst in schedule_for(loan):`
|
||
|
||
- [x] **Step 5: Laufzeit-Validierung** — `routers/planning.py`: in `LoanIn` `term_months: int = Field(ge=1, le=1200)`; in `LoanPatch` `term_months: int | None = Field(None, ge=1, le=1200)`.
|
||
|
||
- [x] **Step 6: Tests laufen lassen** — `.venv/bin/python -m pytest -q` → alle grün.
|
||
|
||
- [x] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add finance/app/engine/loans.py finance/app/routers/planning.py finance/app/services/breakdown.py \
|
||
finance/app/services/projection_service.py finance/app/services/planning_chart.py \
|
||
finance/tests/test_loans.py finance/tests/test_planning_api.py
|
||
git commit -m "refactor: Tilgungsplan-Helfer schedule_for, Kredit-Laufzeit 1–1200 Monate (A14-T5)"
|
||
```
|
||
|
||
- [x] **Step 8: Fable-Gate** — Verhaltensgleichheit: Projektion, Aufteilung (ein Monat mit Kreditrate) und `planning_monthly_totals` für ein synthetisches Setup mit Annuitäten- und endfälligem Kredit vor/nach dem Commit identisch (zwei Checkouts in einem Wegwerf-Verzeichnis); `grep -rn "loan_schedule" finance/app` zeigt nur noch `engine/loans.py` (keine unbenutzten Importe in `routers/planning.py`, `services/*`); 422-Antworten der Laufzeit-Grenzen haben deutschen Kontext in der GUI (Kredit-Formular auf `/planung` zeigt Fehler per Alert, Formular bleibt).
|
||
|
||
---
|
||
|
||
### Task 6: GUI-Kleinpunkte — Navigation 390 px, gewähltes Tortensegment oben, `.legende-zusatz`
|
||
|
||
**Files:**
|
||
- Modify: `finance/app/static/style.css`, `finance/app/templates/_torte_macros.html`
|
||
- Test: `finance/tests/test_aufteilung_gui.py`
|
||
|
||
- [x] **Step 1: Failing Tests** — an `finance/tests/test_aufteilung_gui.py` anhängen:
|
||
|
||
```python
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
|
||
CSS = (Path(__file__).resolve().parent.parent / "app" / "static" / "style.css").read_text()
|
||
|
||
|
||
def test_nav_bricht_um():
|
||
nav = re.search(r"(?m)^nav\s*\{([^}]*)\}", CSS).group(1)
|
||
assert re.search(r"flex-wrap:\s*wrap", nav)
|
||
|
||
|
||
def test_legende_zusatz_nicht_fett_und_figcaption_bleibt_fett():
|
||
assert re.search(r"\.legende-zusatz\s*\{[^}]*font-weight:\s*normal", CSS)
|
||
# Nutzerwunsch A14: Bildunterschrift der Torte bleibt fett.
|
||
assert re.search(r"\.torte figcaption\s*\{[^}]*font-weight:\s*bold", CSS)
|
||
|
||
|
||
def test_gewaehltes_segment_zuletzt_gezeichnet():
|
||
from app.routers.gui import templates
|
||
|
||
def seg(label, gewaehlt):
|
||
s = SimpleNamespace(path=f"M0 0 L{label}", color="s1", label=label, merged=False, members=[],
|
||
value=Decimal("1.00"), share=Decimal("50.0"))
|
||
return {"s": s, "q": None, "gewaehlt": gewaehlt, "zusatz": None}
|
||
|
||
torte = templates.env.get_template("_torte_macros.html").module.torte
|
||
html = str(torte([seg("Alpha", True), seg("Beta", False)], "Titel"))
|
||
svg = html.split("<svg", 1)[1].split("</svg>", 1)[0]
|
||
assert svg.index("M0 0 LBeta") < svg.index("M0 0 LAlpha")
|
||
legende = html.split('<ul class="legende">', 1)[1]
|
||
assert legende.index("Alpha") < legende.index("Beta") # Legende behält Reihenfolge
|
||
```
|
||
|
||
(`re` und `Decimal` sind in `test_aufteilung_gui.py` bereits importiert; `Path`/`SimpleNamespace` oben bei den Imports einsortieren.)
|
||
|
||
- [x] **Step 2: Tests laufen lassen** — `.venv/bin/python -m pytest -q tests/test_aufteilung_gui.py` → 3 FAIL (figcaption-Teil besteht bereits).
|
||
|
||
- [x] **Step 3: `style.css`** — im `nav { … }`-Block nach `gap: 1rem;`:
|
||
|
||
```css
|
||
flex-wrap: wrap;
|
||
row-gap: 0.4rem;
|
||
```
|
||
|
||
Nach `.legende li.gewaehlt { font-weight: bold; }`:
|
||
|
||
```css
|
||
.legende-zusatz { font-weight: normal; color: #666; }
|
||
```
|
||
|
||
`.torte figcaption { font-weight: bold; … }` **nicht** ändern.
|
||
|
||
- [x] **Step 4: `_torte_macros.html`** — im Makro `torte` die SVG-Schleife ersetzen (Legende unverändert):
|
||
|
||
```jinja
|
||
{# Gewähltes Segment zuletzt zeichnen, damit sein Umriss nicht von
|
||
Nachbarsegmenten überdeckt wird (A14). #}
|
||
{% for seg in (segmente|rejectattr('gewaehlt')|list) + (segmente|selectattr('gewaehlt')|list) %}{% set s = seg.s %}
|
||
```
|
||
|
||
- [x] **Step 5: Tests laufen lassen** — `.venv/bin/python -m pytest -q` → alle grün.
|
||
|
||
- [x] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add finance/app/static/style.css finance/app/templates/_torte_macros.html finance/tests/test_aufteilung_gui.py
|
||
git commit -m "fix: Navigation bricht auf schmalen Bildschirmen um, gewähltes Tortensegment oben, Legendenzusatz nicht fett (A14-T6)"
|
||
```
|
||
|
||
- [x] **Step 7: Fable-Gate** — Playwright gegen lokalen uvicorn (Wegwerf-SQLite, synthetische Daten mit ≥ 4 Kategorien): bei 390 px auf `/`, `/buchungen`, `/planung`, `/szenarien`, `/aufteilung`, `/admin`, `/hilfe` gilt `document.documentElement.scrollWidth <= 390` und alle Nav-Links sind sichtbar; in `/aufteilung` ein mittleres Segment klicken → Screenshot vergrößert ansehen: dunkler Umriss vollständig sichtbar; Legendenzeile der „Übrige"-Gruppe gewählt → Zusatz in Klammern nicht fett, Bildunterschrift fett.
|
||
|
||
---
|
||
|
||
### Task 7: Doku, Beratungsumgebung, Release-Commit v0.14.0
|
||
|
||
**Files:**
|
||
- Modify: `finance/app/templates/hilfe.html`, `docs/ARCHITEKTUR.md`, `finance/VERSION`
|
||
- Modify (anderes Repo): `/home/wlfb/fb/.claude/skills/finanz-api/SKILL.md`, `/home/wlfb/fb/.claude/skills/finanzberatung/SKILL.md`
|
||
|
||
- [x] **Step 1: `hilfe.html`** — im `<li>` „Szenarien":
|
||
- „Modifikatoren (Kategorie oder Posten prozentual/absolut kürzen oder ganz streichen)" ersetzen durch „Einträgen (Kategorie oder Posten prozentual oder um einen Betrag kürzen, auf einen neuen Betrag setzen oder ganz streichen – auch nur für einen Zeitraum)".
|
||
- Den Text von „Varianten wie „Best Case“ pflegst du …" bis „… direkt änderbar." ersetzen durch:
|
||
|
||
```html
|
||
Varianten wie „Best Case“ pflegst du in der Tabelle „Einträge“ des
|
||
Szenarios. Das Formular „Neue Änderung an Bestehendem“ ändert
|
||
vorhandene Posten oder Kategorien: „Prozent“ und „Kürzung (€)“ kürzen
|
||
Beträge, „Neuer Betrag“ ersetzt den Betrag eines Postens (mit
|
||
Vorzeichen, z. B. -250,00), „Entfällt“ blendet ihn aus, „Ende“ lässt
|
||
einen wiederkehrenden Posten zu einem Datum enden. Mit „Gültig ab“ und
|
||
„Gültig bis“ wirken Prozent, Kürzung, Neuer Betrag und Entfällt nur in
|
||
diesem Zeitraum (Grenzen einschließlich, leer = unbegrenzt). Beispiel:
|
||
ein Posten wird mit „Prozent 50 ab 16.10.“ halbiert – ist er am 14.
|
||
fällig, zählt er im Oktober noch voll, ab November zur Hälfte. Das
|
||
Formular „Neue Zahlung nur in diesem Szenario“ ergänzt eine
|
||
Einmalzahlung oder eine wiederkehrende Zahlung (Rhythmus,
|
||
Fälligkeitstag, Start/Ende, optional mit Kategorie), die nur in diesem
|
||
Szenario zählt – auch wenn „Wiederkehrende Posten einschließen“ bzw.
|
||
„Einmalposten einschließen“ aus ist. Jeder Eintrag ist über
|
||
„Bearbeiten“ direkt änderbar.
|
||
```
|
||
|
||
- [x] **Step 2: `docs/ARCHITEKTUR.md`** —
|
||
- Komponenten-Diagramm: `M_tables`-Label um `ScenarioRecurringItem` ergänzen; `M_alembic`-Label um `e7f1a9c3b214_szenario_zeitraum`; `R_scenarios`-Label um `add_scenario_recurring`; `E_scenario`-Label zu `build_cashflows, modified_amount<br/>(Stichtag on, gilt_am)`; `E_loans`-Label um `schedule_for`.
|
||
- Abschnitt „Szenario-Engine ist rein": `load_scenario_inputs` lädt zusätzlich `ScenarioRecurringItem` (als `PlainRecurring` mit `id=None`) — Liste der geladenen Modelle ergänzen.
|
||
- Neuer Konzeptabsatz **„Szenario-Einträge mit Zeitraum (Ausbaustufe 14)."**: `ScenarioModifier.start_date`/`end_date` = „Gültig ab/bis" (inklusiv; bei `ende` nur `end_date`), Art `set` („Neuer Betrag", nur Posten), `engine.scenario.modified_amount(..., on=)` mit Stichtag je Fälligkeit (`build_cashflows`) bzw. `recurrence.due_in_month` (`breakdown_rows`), Anlage-Reihenfolge; `scenario_recurring_items` + `/api/scenarios/{id}/recurring`, zählt unabhängig von `include_recurring`, von Posten-Einträgen nie getroffen (`id=None`); `ScenarioPlannedItem.category_id`; Szenarien-Seite mit `static/szenarien.js` (`FBSzenario`).
|
||
- Nur Namen verwenden, die im Code existieren.
|
||
|
||
- [x] **Step 3: Version** — `finance/VERSION` → `0.14.0`; `.venv/bin/python -m pytest -q` grün.
|
||
|
||
- [x] **Step 4: Commit (Repo `~/bin`)**
|
||
|
||
```bash
|
||
git add finance/app/templates/hilfe.html docs/ARCHITEKTUR.md finance/VERSION
|
||
git commit -m "chore: Release v0.14.0 (Szenario-Zeitraum, Neuer Betrag, Szenario-Zahlungen)"
|
||
```
|
||
|
||
- [x] **Step 5: Beratungsumgebung** —
|
||
- `/home/wlfb/fb/.claude/skills/finanz-api/SKILL.md`, Abschnitt „Szenarien (`/api/scenarios`)": den Satz ab „Modifikator hinzufügen:" ersetzen durch: „Eintrag (Modifikator) hinzufügen: `POST /{id}/modifiers` mit `target_type` (`category`|`recurring`), `target_id`, `kind` (`percent`|`absolute` = Kürzung in €|`set` = neuer Betrag mit Vorzeichen, nur `recurring`|`remove`|`ende`), `value`, optional `start_date`/`end_date` (Gültig ab/bis, inklusiv; bei `ende` ist `end_date` Pflicht und das Enddatum); ändern per `PATCH` (voller Body), löschen über `DELETE /{id}/modifiers/{mod_id}`. Szenario-eigene Einmalzahlungen: `/{id}/planned` (`name`, `amount`, `due`, optional `category_id`). Szenario-eigene wiederkehrende Zahlungen: `/{id}/recurring` (`name`, `amount`, `rhythm`, `due_day`, optional `start_date`, `end_date`, `category_id`) mit `GET`/`POST`/`PATCH`/`DELETE`; zählen immer, unabhängig von `include_recurring`."
|
||
- `/home/wlfb/fb/.claude/skills/finanzberatung/SKILL.md`, beim Punkt „Kürzungspotenzial": ergänzen „Änderungen ab einem Stichtag (z. B. Rate halbiert ab Oktober) als Eintrag mit `start_date` abbilden, nicht als Ende + neuer Posten; neue laufende Zahlungen nur für eine Variante über `/api/scenarios/{id}/recurring`."
|
||
- Commit im Repo `/home/wlfb/fb`: `git -C /home/wlfb/fb add .claude/skills/finanz-api/SKILL.md .claude/skills/finanzberatung/SKILL.md && git -C /home/wlfb/fb commit -m "docs: Szenario-Zeitraum, Neuer Betrag, Szenario-Zahlungen (Tool v0.14.0)"` (mit Co-Authored-By-Zeile). Nicht pushen.
|
||
|
||
- [x] **Step 6: Fable-Gate** — Faktencheck aller Doku-Aussagen gegen Code (Feldnamen, Arten, Fehlertexte, Endpunkte, Stichtag-Regel, Funktionsnamen), Mermaid valide (z. B. per `mermaid-cli`-Container rendern), Hilfe-Text stimmt mit dem Verhalten überein; keine echten Namen/Beträge in Doku.
|
||
|
||
---
|
||
|
||
### Task 8: Backup, Migration, Redeploy, Live-Smoke (nach Whole-Branch-Review)
|
||
|
||
- [x] **Step 1: Backup** — `./backup_finance_pod.sh` (vor Redeploy).
|
||
- [x] **Step 2: Redeploy** — `alembic current` vorher notieren (`podman exec finance-api_ctr alembic current` → `d5e8b3c7a410`); `./create_pod_finance.sh` (die Migration läuft automatisch: `finance/entrypoint.sh` führt beim Container-Start `alembic upgrade head` aus); `GET /api/version` (mit Bearer) → `0.14.0`; `podman exec finance-api_ctr alembic current` → `e7f1a9c3b214 (head)`.
|
||
- [x] **Step 3: Live-Smoke** (curl mit Session-Cookie gegen `127.0.0.1:8096`; nur Statuscodes, Zählwerte, Booleans — DATENSCHUTZ):
|
||
- Seiten `/`, `/buchungen`, `/salden`, `/import`, `/planung`, `/szenarien`, `/aufteilung`, `/admin`, `/hilfe` → 200.
|
||
- `/szenarien` enthält „Neue Änderung an Bestehendem", „Neue Zahlung nur in diesem Szenario", `szenarien.js?v=0.14.0` (Booleans).
|
||
- `GET /api/scenarios/{id}/recurring` für jedes Szenario → 200, leere Liste.
|
||
- Bestandszahlen (Szenarien, Einträge je Art, Einmalzahlungen, Buchungen, Kategorien, Fixposten, Kredite) vor = nach; vorhandene `ende`-Einträge behalten ihr Datum (Anzahl mit `end_date` vor = nach); `start_date` überall NULL.
|
||
- Projektionen aller durchgerechneten Szenarien: Tiefpunkt-Datum und -Betrag vor = nach (per SQL verglichen, nur Boolean ausgeben) — die Rechenänderung darf Bestandsszenarien ohne Zeitraum nicht verändern.
|
||
- Das Live-Szenario des Nutzers **nicht** ändern.
|
||
- [x] **Step 4: Live-Klicktest** — Playwright-Container gegen `https://fbwl.creature-go.com/` (Secure-Cookie aktiv; Passwort im Skript aus `~/.local/share/finance_pod/.env` lesen, nie ausgeben): `/szenarien` öffnen, in „Neue Änderung" Art „Ende" wählen ⇒ Boolean „Gültig ab gesperrt"; in „Neue Zahlung" „Wiederkehrend" wählen ⇒ Boolean „Rhythmus aktiv, Fällig am gesperrt". Nichts absenden.
|
||
- [x] **Step 5: Fable-Release-Gate** (read-only Live-Verifikation) + Ledger-Eintrag „AUSBAUSTUFE 14 ABGESCHLOSSEN" in `.superpowers/sdd/progress.md` (inkl. A15-Kandidaten aus den Reviews); Plan-Haken; Commit `docs: A14 Plan-Haken`; beide Repos pushen erst nach Rückfrage beim Nutzer.
|