feat: Warnfenster und Planungs-Hinweis bei möglicher Doppelzählung (A12-T4)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 07:35:16 +02:00
co-authored by Claude Opus 5.5
parent 9e856ec655
commit e665180b1f
8 changed files with 264 additions and 2 deletions
+8
View File
@@ -27,6 +27,7 @@ from app.routers.planning import recurring_suggestions as _recurring_suggestions
from app.routers.scenarios import list_scenarios as _list_scenarios
from app.routers.transactions import count_transactions, list_transactions, normalize_sort
from app.services.balances import account_balance, total_balance
from app.services.double_count import all_conflicts
from app.services.planning_chart import refresh_planning_chart
from app.services.projection_service import refresh_stale_projections
from app.version import get_version
@@ -337,6 +338,11 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
recurring = _list_recurring(session=session)
planned = _list_planned(session=session)
loans = _list_loans(session=session)
doppelt_posten: dict[int, list[str]] = {}
doppelt_kredit: dict[int, list[str]] = {}
for k in all_conflicts(session):
doppelt_posten.setdefault(k.posten_id, []).append(k.kredit_name)
doppelt_kredit.setdefault(k.kredit_id, []).append(k.posten_name)
return templates.TemplateResponse(request, "planning.html", {
"categories": categories,
"category_names": {c.id: c.name for c in categories},
@@ -347,6 +353,8 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
"loans": loans,
"rhythms": ["monthly", "quarterly", "yearly"],
"repayment_types": ["annuity", "bullet"],
"doppelt_posten": doppelt_posten,
"doppelt_kredit": doppelt_kredit,
})
+89
View File
@@ -0,0 +1,89 @@
/* Warnfenster bei möglicher Doppelzählung Kredit ↔ Fixposten (Ausbaustufe 12).
*
* Die API antwortet mit 409 und detail.code "doppelzaehlung_kredit", wenn ein
* Posten vermutlich einer Kreditrate entspricht. handle() fängt genau diesen
* Fall im globalen htmx:responseError-Handler ab (alle anderen Fehler laufen
* weiter über den alert) und zeigt den <dialog>. „Trotzdem speichern" markiert
* das auslösende Formular (data-doppelzaehlung-bestaetigt="1") und sendet es
* erneut; die json-form-Extension ergänzt dann doppelzaehlung_bestaetigt=true.
* Nach jedem Request wird die Markierung entfernt, damit ein späteres
* Speichern die Warnung nicht stillschweigend umgeht.
*/
(function () {
'use strict';
var CODE = 'doppelzaehlung_kredit';
var ATTR = 'data-doppelzaehlung-bestaetigt';
var fmt = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
function parseDetail(xhr) {
if (!xhr || xhr.status !== 409) { return null; }
var body;
try { body = JSON.parse(xhr.responseText); } catch (e) { return null; }
var d = body && body.detail;
return d && d.code === CODE ? d : null;
}
function eur(s) { return fmt.format(parseFloat(s)) + ' €'; }
function konfliktText(k) {
return 'Posten „' + k.posten + '“ (' + eur(k.posten_betrag) + ') ↔ Kredit „'
+ k.kredit + '“ (Rate ' + eur(k.kredit_rate) + ')';
}
function handle(evt) {
var d = parseDetail(evt && evt.detail && evt.detail.xhr);
var elt = evt && evt.detail && evt.detail.elt;
var form = elt && elt.closest ? elt.closest('form') : null;
if (!d || !form) { return false; }
var doc = form.ownerDocument;
var dlg = doc.getElementById('doppelzaehlung-dialog');
if (!dlg) { return false; }
dlg.querySelector('.dz-meldung').textContent = d.meldung;
var list = dlg.querySelector('.dz-konflikte');
list.textContent = '';
(d.konflikte || []).forEach(function (k) {
var li = doc.createElement('li');
li.textContent = konfliktText(k);
list.appendChild(li);
});
dlg.fbForm = form;
dlg.showModal();
return true;
}
function abbrechen(dlg) {
dlg.fbForm = null;
dlg.close();
}
function bestaetigen(dlg) {
var form = dlg.fbForm;
dlg.fbForm = null;
dlg.close();
if (!form) { return; }
form.setAttribute(ATTR, '1');
form.ownerDocument.defaultView.htmx.trigger(form, 'submit');
}
function init(doc) {
var dlg = doc.getElementById('doppelzaehlung-dialog');
if (!dlg) { return; }
doc.getElementById('dz-abbrechen').addEventListener('click', function () { abbrechen(dlg); });
doc.getElementById('dz-bestaetigen').addEventListener('click', function () { bestaetigen(dlg); });
doc.body.addEventListener('htmx:afterRequest', function (e) {
var elt = e.detail && e.detail.elt;
var form = elt && elt.closest ? elt.closest('form') : null;
if (form && form.getAttribute(ATTR) === '1') {
// Erst nach dem Request mit Flag entfernen: der 409-Request selbst
// hat die Markierung nie gesetzt.
form.removeAttribute(ATTR);
}
});
}
var api = { parseDetail: parseDetail, konfliktText: konfliktText, handle: handle,
abbrechen: abbrechen, bestaetigen: bestaetigen, init: init };
if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
if (typeof window !== 'undefined') { window.FBDoppel = api; }
})();
+10
View File
@@ -226,3 +226,13 @@ label:has(input:disabled), label:has(select:disabled) {
color: inherit;
font-weight: bold;
}
/* Doppelzählung Kredit/Fixposten (Ausbaustufe 12). */
.warn-hint {
display: block;
font-size: 0.8em;
color: #b45309;
}
#doppelzaehlung-dialog {
max-width: 32rem;
}
+17
View File
@@ -7,6 +7,7 @@
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js"></script>
<script src="/static/sort.js"></script>
<script src="/static/doppelzaehlung.js"></script>
</head>
<body>
<nav>
@@ -26,8 +27,18 @@
<main>
{% block content %}{% endblock %}
</main>
<dialog id="doppelzaehlung-dialog">
<h2>Mögliche Doppelzählung</h2>
<p class="dz-meldung"></p>
<ul class="dz-konflikte"></ul>
<p>Trotzdem speichern?</p>
<button type="button" id="dz-abbrechen" autofocus>Abbrechen</button>
<button type="button" id="dz-bestaetigen">Trotzdem speichern</button>
</dialog>
<script>
document.body.addEventListener('htmx:responseError', function (e) {
// Mögliche Doppelzählung Kredit/Fixposten: eigenes Warnfenster statt alert.
if (window.FBDoppel && window.FBDoppel.handle(e)) { return; }
alert('Fehler: ' + (e.detail.xhr.responseText || e.detail.xhr.status));
});
@@ -72,6 +83,10 @@
out[field.name] = field.checked;
});
}
// Bestätigte Doppelzählung (Warnfenster „Trotzdem speichern"):
if (elt.getAttribute && elt.getAttribute('data-doppelzaehlung-bestaetigt') === '1') {
out.doppelzaehlung_bestaetigt = true;
}
xhr.overrideMimeType('text/json');
return JSON.stringify(out);
}
@@ -82,6 +97,8 @@
document.getElementById(prefix + '-row-' + id).hidden = editing;
document.getElementById(prefix + '-edit-' + id).hidden = !editing;
}
if (window.FBDoppel) { window.FBDoppel.init(document); }
</script>
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
</body>
+2 -2
View File
@@ -12,7 +12,7 @@
<tbody>
{% for r in recurring %}
<tr id="rec-row-{{ r.id }}">
<td>{{ r.name }}</td>
<td data-sort="{{ r.name }}">{{ r.name }}{% for kn in doppelt_posten.get(r.id, []) %}<span class="warn-hint">⚠ zählt evtl. doppelt mit Kredit „{{ kn }}“</span>{% endfor %}</td>
<td class="{{ 'neg' if r.amount < 0 else '' }}" data-sort="{{ r.amount }}">{{ r.amount|eur }} €</td>
<td>{{ r.rhythm|de_label }}</td>
<td>{{ r.due_day }}</td>
@@ -197,7 +197,7 @@
<tbody>
{% for l in loans %}
<tr id="loan-row-{{ l.id }}">
<td>{{ l.name }}</td>
<td data-sort="{{ l.name }}">{{ l.name }}{% for pn in doppelt_kredit.get(l.id, []) %}<span class="warn-hint">⚠ zählt evtl. doppelt mit Posten „{{ pn }}“</span>{% endfor %}</td>
<td data-sort="{{ l.principal }}">{{ l.principal|eur }} €</td>
<td>{{ l.annual_rate_pct|eur }} %</td>
<td data-sort="{{ l.term_months }}">{{ l.term_months }} Monate</td>
@@ -0,0 +1,75 @@
'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/doppelzaehlung.js'), 'utf8');
const HTML = `
<form id="f" hx-post="/api/recurring"><input name="name" value="Rate Auto"></form>
<dialog id="doppelzaehlung-dialog">
<p class="dz-meldung"></p><ul class="dz-konflikte"></ul>
<button type="button" id="dz-abbrechen">Abbrechen</button>
<button type="button" id="dz-bestaetigen">Trotzdem speichern</button>
</dialog>`;
const BODY = JSON.stringify({ detail: { code: 'doppelzaehlung_kredit', meldung: 'Erklärung',
konflikte: [{ posten: 'Rate Auto', posten_betrag: '-100.00', kredit: 'Auto', kredit_rate: '100.00' }] } });
function load() {
const dom = new JSDOM(`<!doctype html><body>${HTML}</body>`, { runScripts: 'outside-only' });
const w = dom.window;
const calls = [];
w.htmx = { trigger: (el, name) => calls.push([el.id, name]) };
const dlg = w.document.getElementById('doppelzaehlung-dialog');
dlg.showModal = function () { this.setAttribute('open', ''); };
dlg.close = function () { this.removeAttribute('open'); };
w.eval(SRC);
w.FBDoppel.init(w.document);
const form = w.document.getElementById('f');
const evt = { detail: { xhr: { status: 409, responseText: BODY }, elt: form } };
return { w, dlg, form, evt, calls };
}
test('409 öffnet Dialog mit Erklärung und Konfliktliste', { skip: !JSDOM }, () => {
const { w, dlg, evt } = load();
assert.equal(w.FBDoppel.handle(evt), true);
assert.ok(dlg.hasAttribute('open'));
assert.equal(dlg.querySelector('.dz-meldung').textContent, 'Erklärung');
assert.equal(dlg.querySelectorAll('.dz-konflikte li').length, 1);
});
test('andere Fehler werden nicht abgefangen', { skip: !JSDOM }, () => {
const { w, form } = load();
assert.equal(w.FBDoppel.handle({ detail: { xhr: { status: 422, responseText: '{}' }, elt: form } }), false);
});
test('Abbrechen speichert nicht', { skip: !JSDOM }, () => {
const { w, dlg, form, evt, calls } = load();
w.FBDoppel.handle(evt);
w.document.getElementById('dz-abbrechen').click();
assert.ok(!dlg.hasAttribute('open'));
assert.equal(calls.length, 0);
assert.ok(!form.hasAttribute('data-doppelzaehlung-bestaetigt'));
assert.equal(form.querySelector('input').value, 'Rate Auto');
});
test('Trotzdem speichern sendet erneut mit Flag', { skip: !JSDOM }, () => {
const { w, dlg, form, evt, calls } = load();
w.FBDoppel.handle(evt);
w.document.getElementById('dz-bestaetigen').click();
assert.ok(!dlg.hasAttribute('open'));
assert.equal(form.getAttribute('data-doppelzaehlung-bestaetigt'), '1');
assert.deepEqual(calls, [['f', 'submit']]);
});
test('Flag nach Request entfernt', { skip: !JSDOM }, () => {
const { w, form, evt } = load();
w.FBDoppel.handle(evt);
w.document.getElementById('dz-bestaetigen').click();
const e = new w.CustomEvent('htmx:afterRequest', { bubbles: true, detail: { elt: form } });
form.dispatchEvent(e);
assert.ok(!form.hasAttribute('data-doppelzaehlung-bestaetigt'));
});
+20
View File
@@ -0,0 +1,20 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const D = require('../../app/static/doppelzaehlung.js');
const body = { detail: { code: 'doppelzaehlung_kredit', meldung: 'M',
konflikte: [{ posten: 'Rate Auto', posten_betrag: '-1234.50', kredit: 'Auto', kredit_rate: '1234.50' }] } };
test('parseDetail erkennt nur 409 mit Code', () => {
assert.equal(D.parseDetail({ status: 409, responseText: JSON.stringify(body) }).code, 'doppelzaehlung_kredit');
assert.equal(D.parseDetail({ status: 409, responseText: '{"detail":"Kategorie existiert bereits"}' }), null);
assert.equal(D.parseDetail({ status: 422, responseText: JSON.stringify(body) }), null);
assert.equal(D.parseDetail({ status: 409, responseText: 'kein json' }), null);
assert.equal(D.parseDetail(null), null);
});
test('konfliktText deutsch formatiert', () => {
assert.equal(D.konfliktText(body.detail.konflikte[0]),
'Posten „Rate Auto“ (-1.234,50 €) ↔ Kredit „Auto“ (Rate 1.234,50 €)');
});
+43
View File
@@ -588,3 +588,46 @@ def test_planungs_refresh_fehler_bricht_seiten_nicht(client, monkeypatch):
client.post("/login", data={"username": "admin", "password": "geheim"})
for path in ("/", "/planung"):
assert client.get(path).status_code == 200, path
def test_base_hat_doppelzaehlungs_dialog(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
html = client.get("/").text
assert '<script src="/static/doppelzaehlung.js"></script>' in html
assert '<dialog id="doppelzaehlung-dialog">' in html
assert 'id="dz-abbrechen" autofocus' in html
assert ">Trotzdem speichern</button>" in html
assert "FBDoppel.handle(e)" in html
assert "doppelzaehlung_bestaetigt = true" in html
def test_planung_zeigt_doppelzaehlungs_hinweis(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add_all([
Loan(name="Auto", principal=Decimal("1200.00"), annual_rate_pct=Decimal("0"),
term_months=12, payout_date=date(2026, 1, 15), repayment_type="annuity"),
RecurringItem(name="Rate Auto", amount=Decimal("-100.00"), rhythm="monthly", due_day=15),
RecurringItem(name="Miete", amount=Decimal("-600.00"), rhythm="monthly", due_day=1),
])
db.commit()
html = client.get("/planung").text
assert "⚠ zählt evtl. doppelt mit Kredit „Auto“" in html
assert "⚠ zählt evtl. doppelt mit Posten „Rate Auto“" in html
assert html.count("⚠ zählt evtl. doppelt") == 2
assert '<td data-sort="Rate Auto">' in html
assert '<td data-sort="Auto">' in html
def test_planung_ohne_konflikt_kein_hinweis(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(RecurringItem(name="Miete", amount=Decimal("-600.00"), rhythm="monthly", due_day=1))
db.commit()
assert "zählt evtl. doppelt" not in client.get("/planung").text
def test_vorschlag_formular_nutzt_json_form_post(client):
# Review Focus 5: „Vorschlag übernehmen" läuft über denselben Warnweg.
import re
from pathlib import Path
src = Path("app/templates/planning.html").read_text(encoding="utf-8")
assert re.search(r'class="inline-form" hx-ext="json-form" hx-post="/api/recurring"', src)