Merge pull request 'Fix #51: Adressverwaltung in der Verwaltung, Typeahead im Zahlungsverkehr' (#53) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/53
This commit is contained in:
fds2610 2026-09-01 18:20:25 +02:00
commit 2f524c72da
12 changed files with 349 additions and 4 deletions

View file

@ -1,5 +1,23 @@
# Änderungen
## Adressverwaltung in der Verwaltung, Typeahead im Zahlungsverkehr (31.08.2026)
Das Adressbuch (bisher nur über die Verträge-Kachel erreichbar) bekommt in der Verwaltung
eine eigene Kachel „Adressverwaltung“ (Feature #51) – es speist auch den Zahlungsverkehr,
der mit Verträgen nichts zu tun hat. Die Adressliste unter `/vertraege/adressen` bleibt die
gleiche Seite; zu jeder Adresse klappt jetzt ein `<details>`-Element ihre letzten Buchungen
auf. Da die Buchung kein Fremdschlüsselfeld auf die Adresse führt, sucht
`Buchung::letzteFuerEmpfaenger()` kontenübergreifend nach dem exakten Empfängernamen – die
eindeutige Kombination aus `benutzer_id` und `name` in `adressen` (Migration 008) macht das
zuverlässig.
Die Empfängerauswahl im Zahlungsverkehr-Formular ist jetzt ein Typeahead: `kombobox.js`
(schon für die Kategorie im Kontoblatt im Einsatz) baut das `<select>` in ein Eingabefeld mit
gefilterter Liste um, ohne dass App- oder Formular-Code etwas davon merken – Wert und
Ändern-Ereignis bleiben identisch. Geprüft wurde außerdem, dass „Empfänger ins Adressbuch
übernehmen“ beim Erfassen einer Überweisung tatsächlich eine Adresse anlegt
(`ZahlungsverkehrController::anlegen()` → `Adresse::anlegen()`).
## Zahlungsverkehr: automatische Bankauswahl (31.08.2026)
Beim Erfassen einer Überweisung musste bisher neben dem Auftragskonto auch die

View file

@ -62,6 +62,24 @@ Das Adressbuch speist die Empfängerauswahl im Zahlungsverkehr. **IBANs werden g
Gerechnet wird stückweise mit `%` statt mit `bcmod`: Die BC-Erweiterung ist keine
Standardausstattung, und ein Modulo über sieben Ziffern lange Blöcke kommt ohne sie aus.
**Adressverwaltung.** Eine eigene Kachel in der Verwaltung führt zur Adressliste
(`/vertraege/adressen`, weiter über `VertraegeController` gepflegt) – neben, nicht nur
innerhalb der Verträge-Kachel: Das Adressbuch bedient auch den Zahlungsverkehr, der mit
Verträgen nichts zu tun hat. Zu jeder Adresse klappt ein `<details>`-Element ihre letzten
Buchungen auf, kontenübergreifend nach dem exakten Empfängernamen gesucht
(`Buchung::letzteFuerEmpfaenger()`) – die Buchung führt kein Fremdschlüsselfeld auf die
Adresse, nur den Freitext-Empfänger.
**Typeahead im Zahlungsverkehr.** Die Empfängerauswahl (`<select data-adressauswahl>`) trägt
zusätzlich `data-kombobox` und wird wie die Kategorie im Kontoblatt (`kombobox.js`, siehe
`docs/`-Test 35) zu einem Eingabefeld mit gefilterter Liste. Die Auswahl füllt Name, IBAN und
BIC; geändert werden darf danach alles.
**Der Empfänger landet wirklich im Adressbuch**, wenn beim Erfassen einer Überweisung
„Empfänger ins Adressbuch übernehmen“ angekreuzt ist – `ZahlungsverkehrController::anlegen()`
ruft dafür `Adresse::anlegen()` auf, nachdem der Zahlungsauftrag erfolgreich angelegt wurde;
gibt es die Adresse schon, bleibt der Fehler von `Adresse::anlegen()` (Duplikat) ohne Wirkung.
## Verträge
Der Nutzen liegt in der **Kündigungsfrist**: Die Übersicht zeigt, bis wann gekündigt sein

View file

@ -1133,6 +1133,9 @@ tr.zugeklappt { display: none; }
.filterleiste .kombobox { max-width: 15rem; }
.filterleiste .kombobox-eingabe { padding: .3rem .45rem; font-size: .9rem; }
.formular .kombobox { width: 100%; }
.formular .kombobox-eingabe { padding: .5rem .6rem; }
.zuordnungsauswahl .kombobox { width: 100%; max-width: 13rem; }
.zuordnungsauswahl .kombobox-eingabe {
height: 1.35rem;

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Adresse;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Kredit;
@ -97,10 +98,17 @@ class VertraegeController extends BaseController
$this->verlangeAnmeldung();
$suche = $this->query('suche');
$adressen = Adresse::alle($suche);
// Auf einen Blick, was zuletzt mit dem Empfänger gebucht wurde – ohne
// eigene Seite, denn die Liste bleibt kurz genug für den Direktabruf.
foreach ($adressen as $nummer => $adresse) {
$adressen[$nummer]['buchungen'] = Buchung::letzteFuerEmpfaenger((string)$adresse['name']);
}
$this->render('vertraege/adressen.php', [
'titel' => 'Adressen',
'adressen' => Adresse::alle($suche),
'adressen' => $adressen,
'suche' => $suche,
]);
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Adresse;
use Finanzmanager\Models\Bankinstitut;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Erinnerung;
@ -44,6 +45,7 @@ class VerwaltungController extends BaseController
'vertraege' => count(Vertrag::alle(true)),
'fristen' => count(Vertrag::fristenLaufenAb(90)),
'bargeld' => count(Kategorie::bargeldAuswahl()),
'adressen' => Adresse::anzahl(),
],
'bargeldAktiv' => BargeldAbgleich::eingerichtet(),
]);

View file

@ -487,6 +487,32 @@ class Buchung
return $anzahlen;
}
/**
* Die jüngsten Buchungen zu einem Empfängernamen, kontenübergreifend – für
* die Adressverwaltung: Wer sich eine Adresse ansieht, will wissen, was
* zuletzt mit ihr gebucht wurde. Der Empfänger ist ein Freitextfeld, daher
* zählt der exakte Name; die Kollation der Spalte macht das unabhängig von
* Groß-/Kleinschreibung.
*
* @return list<array<string,mixed>>
*/
public static function letzteFuerEmpfaenger(string $empfaenger, int $limit = 5): array
{
if ($empfaenger === '') {
return [];
}
return Database::fetchAll(
'SELECT b.id, b.datum, b.betrag, b.buchungstext, k.name AS kontoname
FROM buchungen b
JOIN konten k ON k.id = b.konto_id
WHERE b.benutzer_id = ? AND b.empfaenger = ?
ORDER BY b.datum DESC, b.id DESC
LIMIT ' . max(1, $limit),
[Mandant::id(), $empfaenger]
);
}
/** Wie viele Buchungen hat das Konto (beide Sichten)? */
public static function anzahlFuerKonto(int $kontoId): int
{

View file

@ -90,6 +90,37 @@ include __DIR__ . '/../layout/kopf.php';
class="knopf klein gefahr">Löschen</button>
</td>
</tr>
<tr>
<td colspan="6">
<details>
<summary>
Letzte Buchungen
<?= $adresse['buchungen'] !== [] ? '(' . count($adresse['buchungen']) . ')' : '' ?>
</summary>
<?php if ($adresse['buchungen'] === []): ?>
<p class="platzhalter">Noch keine Buchung mit diesem Empfänger.</p>
<?php else: ?>
<table class="liste">
<thead>
<tr><th>Datum</th><th>Konto</th><th>Buchungstext</th><th class="rechts">Betrag</th></tr>
</thead>
<tbody>
<?php foreach ($adresse['buchungen'] as $buchung): ?>
<tr>
<td><?= View::datum($buchung['datum']) ?></td>
<td><?= View::h($buchung['kontoname']) ?></td>
<td><?= View::h($buchung['buchungstext']) ?></td>
<td class="rechts <?= View::betragKlasse($buchung['betrag']) ?>">
<?= View::betrag($buchung['betrag']) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</details>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>

View file

@ -101,6 +101,18 @@ include __DIR__ . '/../layout/kopf.php';
</div>
</div>
<div class="kachel">
<header><h2>Adressverwaltung</h2></header>
<p class="kennzahl"><?= (int)$zahlen['adressen'] ?></p>
<p class="platzhalter">
Das Adressbuch der Zahlungsempfänger – speist die Empfängerauswahl im
Zahlungsverkehr und zeigt zu jeder Adresse die letzten Buchungen.
</p>
<div class="knopfleiste">
<a href="/vertraege/adressen" class="knopf klein primaer">Adressen verwalten</a>
</div>
</div>
<div class="kachel">
<header><h2>Termine</h2></header>
<p class="kennzahl"><?= (int)$zahlen['reihen'] ?></p>

View file

@ -65,8 +65,11 @@ include __DIR__ . '/../layout/kopf.php';
<h3>Empfänger</h3>
<?php if ($adressen !== []): ?>
<label for="adresse">Aus dem Adressbuch</label>
<select id="adresse" name="adresse" data-adressauswahl>
<?php /* Kein „for“ auf dem Label: kombobox.js legt vor das <select> ein
sichtbares Eingabefeld ohne eigene id, das aria-label trägt die
Beschriftung für die Vorleser weiter. */ ?>
<label>Aus dem Adressbuch</label>
<select id="adresse" name="adresse" aria-label="Aus dem Adressbuch" data-adressauswahl data-kombobox>
<option value="">– von Hand eingeben –</option>
<?php foreach ($adressen as $adresse): ?>
<option value="<?= (int)$adresse['id'] ?>"
@ -156,4 +159,8 @@ include __DIR__ . '/../layout/kopf.php';
</form>
<?php endif; ?>
<?php if ($adressen !== []): ?>
<script src="/static/kombobox.js" defer></script>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -15,6 +15,7 @@ class NavigationTest extends TestCase
'kategorien' => 0, 'einnahmen' => 0, 'ausgaben' => 0, 'klassen' => 0,
'konten' => 0, 'kontengruppen' => 0, 'bankinstitute' => 0, 'bargeld' => 0,
'reihen' => 3, 'erinnerungen' => 2, 'vertraege' => 7, 'fristen' => 1,
'adressen' => 4,
];
public function run(): void
@ -23,6 +24,7 @@ class NavigationTest extends TestCase
$this->verwaltungLeuchtetFuerBeide();
$this->verwaltungsseiteFuehrtZuDenTerminen();
$this->verwaltungsseiteFuehrtZuDenVertraegen();
$this->verwaltungsseiteFuehrtZurAdressverwaltung();
$this->alleKnoepfeGleichUndFussbuendig();
}
@ -169,6 +171,32 @@ class NavigationTest extends TestCase
}
}
/**
* Fix #51: Eine eigene Kachel für die Adressverwaltung – nicht nur der
* kleine Knopf innerhalb der Verträge-Kachel, denn das Adressbuch bedient
* auch den Zahlungsverkehr, der nichts mit Verträgen zu tun hat.
*/
private function verwaltungsseiteFuehrtZurAdressverwaltung(): void
{
$kacheln = $this->kacheln();
$this->assert(str_contains($kacheln, '<h2>Adressverwaltung</h2>'), 'Die Kachel heißt Adressverwaltung');
$this->assert(str_contains($kacheln, '>4<'), 'Die Kennzahl zeigt die Zahl der Adressen');
$this->assert(
(bool)preg_match('#href="/vertraege/adressen" class="knopf klein#', $kacheln),
'Der Knopf zu den Adressen ist klein'
);
$this->assert(
strrpos($kacheln, '<h2>Adressverwaltung</h2>') > (int)strrpos($kacheln, '<h2>Online-Banking</h2>'),
'Die Adressverwaltung steht hinter dem Online-Banking'
);
$this->assert(
strrpos($kacheln, '<h2>Termine</h2>') > (int)strrpos($kacheln, '<h2>Adressverwaltung</h2>'),
'Die Termine stehen hinter der Adressverwaltung'
);
}
private function stilblatt(): string
{
return (string)file_get_contents(dirname(__DIR__) . '/public/static/app.css');

View file

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Adresse;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Konto;
/**
* Fix #51: Adressverwaltung.
*
* Drei Teile: die letzten Buchungen zu einer Adresse (kontenübergreifend, nach
* dem Empfängernamen), die entsprechende Anzeige auf der Adressseite, und der
* Typeahead im Zahlungsverkehr, der aus dem Adressbuch heraus Name, IBAN und
* BIC übernimmt – umgebaut wie schon die Kategorie im Kontoblatt (Test 35),
* mit demselben kombobox.js.
*/
class AdressverwaltungTest extends TestCase
{
protected string $testName = 'Test 38: Adressverwaltung';
private int $girokonto = 0;
private int $sparkonto = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->letzteBuchungenNachEmpfaengerKontenuebergreifend();
$this->limitUndReihenfolge();
$this->ohneTrefferBleibtDieListeLeer();
$this->adressenseiteZeigtDieLetztenBuchungen();
$this->zahlungsempfaengerIstEinTypeahead();
}
private function stammdaten(): void
{
$this->girokonto = Konto::anlegen([
'name' => 'Adressen-Giro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->sparkonto = Konto::anlegen([
'name' => 'Adressen-Sparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
}
private function letzteBuchungenNachEmpfaengerKontenuebergreifend(): void
{
Buchung::anlegen([
'konto_id' => $this->girokonto, 'datum' => '2026-01-10', 'betrag' => -20.0,
'empfaenger' => 'Stadtwerke Musterstadt', 'buchungstext' => 'Strom Januar',
]);
Buchung::anlegen([
'konto_id' => $this->sparkonto, 'datum' => '2026-02-10', 'betrag' => -21.0,
'empfaenger' => 'Stadtwerke Musterstadt', 'buchungstext' => 'Strom Februar',
]);
Buchung::anlegen([
'konto_id' => $this->girokonto, 'datum' => '2026-01-15', 'betrag' => -5.0,
'empfaenger' => 'Anderer Empfänger', 'buchungstext' => 'Nichts damit zu tun',
]);
$treffer = Buchung::letzteFuerEmpfaenger('Stadtwerke Musterstadt');
$this->assertEquals(2, count($treffer), 'Beide Konten liefern ihre Buchung zum Empfänger');
$this->assertEquals('Adressen-Sparbuch', $treffer[0]['kontoname'], 'Das Sparbuch führt (jüngstes Datum zuerst)');
$this->assertEquals('Adressen-Giro', $treffer[1]['kontoname'], 'Das Girokonto folgt');
}
private function limitUndReihenfolge(): void
{
for ($i = 1; $i <= 7; $i++) {
Buchung::anlegen([
'konto_id' => $this->girokonto, 'datum' => sprintf('2026-03-%02d', $i), 'betrag' => -1.0 * $i,
'empfaenger' => 'Vielbucher GmbH', 'buchungstext' => 'Rechnung ' . $i,
]);
}
$treffer = Buchung::letzteFuerEmpfaenger('Vielbucher GmbH', 3);
$this->assertEquals(3, count($treffer), 'Das Limit wird eingehalten');
$this->assertEquals('2026-03-07', $treffer[0]['datum'], 'Die jüngste Buchung steht vorn');
$this->assertEquals('2026-03-05', $treffer[2]['datum'], 'Danach bricht die Liste beim Limit ab');
}
private function ohneTrefferBleibtDieListeLeer(): void
{
$this->assertEquals([], Buchung::letzteFuerEmpfaenger('Niemand hat je an diesen Namen gebucht'), 'Keine Treffer, keine Zeilen');
$this->assertEquals([], Buchung::letzteFuerEmpfaenger(''), 'Ein leerer Empfänger liefert keine Zeilen statt aller Buchungen');
}
private function adressenseiteZeigtDieLetztenBuchungen(): void
{
Adresse::anlegen(['name' => 'Stadtwerke Musterstadt', 'iban' => 'DE02120300000000202051']);
Adresse::anlegen(['name' => 'Ganz ohne Buchung']);
$adressen = Adresse::alle();
foreach ($adressen as $nummer => $adresse) {
$adressen[$nummer]['buchungen'] = Buchung::letzteFuerEmpfaenger((string)$adresse['name']);
}
$html = $this->adressenHtml($adressen);
$this->assert(str_contains($html, 'Strom Februar'), 'Die letzte Buchung zur Adresse erscheint');
$this->assert(str_contains($html, 'Strom Januar'), 'Auch die ältere Buchung erscheint');
$this->assert(str_contains($html, 'Noch keine Buchung mit diesem Empfänger.'), 'Eine Adresse ohne Buchung sagt das auch so');
$this->assert(str_contains($html, '<details>'), 'Die Buchungen stehen hinter einem Klappelement');
}
private function zahlungsempfaengerIstEinTypeahead(): void
{
$html = $this->zahlungsverkehrFormularHtml();
$this->assert(
(bool)preg_match(
'#<select id="adresse" name="adresse" aria-label="Aus dem Adressbuch" data-adressauswahl data-kombobox>#',
$html
),
'Die Adressauswahl ist als Kombobox (Typeahead) markiert'
);
$this->assert(str_contains($html, '/static/kombobox.js'), 'kombobox.js wird eingebunden');
}
/** @param list<array<string,mixed>> $adressen */
private function adressenHtml(array $adressen): string
{
return $this->rendere('vertraege/adressen.php', [
'adressen' => $adressen,
'suche' => '',
]);
}
private function zahlungsverkehrFormularHtml(): string
{
return $this->rendere('zahlungsverkehr/formular.php', [
'auftrag' => null,
'konten' => [],
'adressen' => Adresse::alle(),
'kategorien' => [],
'klassen' => [],
]);
}
/** @param array<string,mixed> $daten */
private function rendere(string $view, array $daten): string
{
$daten += ['titel' => 'Test', 'flash' => [], 'benutzer' => null, 'csrfToken' => 'x', 'aktuellerPfad' => '/'];
extract($daten, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/src/Views/' . $view;
return (string)ob_get_clean();
}
}

View file

@ -309,4 +309,42 @@ test.describe('Module der Phasen 4 bis 7', () => {
// werden, bevor eine eingerichtet ist. Geprüft wird sie erst beim Senden.
await expect(page.locator('.meldung-fehler')).toContainText('IBAN')
})
test('Eine Überweisung übernimmt den Empfänger auf Wunsch ins Adressbuch', async ({ page }) => {
const empfaenger = `E2E-Adressbuch ${Date.now()}`
await page.goto('/zahlungsverkehr/neu')
await page.fill('#empfaenger', empfaenger)
await page.fill('#iban', 'DE02120300000000202051')
await page.fill('#betrag', '12,34')
await page.check('input[name="adresse_merken"]')
await page.click('button:has-text("Erfassen")')
await expect(page.locator('.meldung-erfolg')).toContainText('erfasst')
// Die Adresse landet wirklich im Adressbuch – nicht nur der Auftrag.
await page.goto('/vertraege/adressen')
await expect(page.locator('table.liste')).toContainText(empfaenger)
})
test('Die Empfängerauswahl im Zahlungsverkehr ist ein Typeahead aus dem Adressbuch', async ({ page }) => {
const empfaenger = `E2E-Typeahead ${Date.now()}`
// Erst eine Adresse anlegen, damit die Auswahl etwas zu bieten hat.
await page.goto('/vertraege/adressen')
await page.fill('#name', empfaenger)
await page.fill('#iban', 'DE02120300000000202051')
await page.click('button:has-text("Adresse anlegen")')
await expect(page.locator('.meldung-erfolg')).toContainText('angelegt')
await page.goto('/zahlungsverkehr/neu')
// kombobox.js ersetzt das <select> optisch durch ein Eingabefeld.
const eingabe = page.locator('.kombobox-eingabe')
await eingabe.click()
await eingabe.fill(empfaenger)
await page.locator('.kombobox-option', { hasText: empfaenger }).click()
await expect(page.locator('#iban')).toHaveValue('DE02120300000000202051')
})
})