Fix #51: Adressverwaltung in der Verwaltung, Typeahead im Zahlungsverkehr
Das Adressbuch bekommt in der Verwaltung eine eigene Kachel statt nur über die Verträge erreichbar zu sein, denn es speist auch den Zahlungsverkehr. Auf der Adressliste klappt zu jeder Adresse ein <details>-Element ihre letzten Buchungen auf (Buchung::letzteFuerEmpfaenger, kontenübergreifend nach dem exakten Empfängernamen – die Buchung führt kein Fremdschlüsselfeld auf die Adresse). Die Empfängerauswahl im Zahlungsverkehr-Formular ist jetzt ein Typeahead: kombobox.js (bereits für die Kategorie im Kontoblatt im Einsatz) baut das <select> in ein gefiltertes Eingabefeld um. Geprüft (PHP- und E2E-Tests), dass "Empfänger ins Adressbuch übernehmen" beim Erfassen einer Überweisung tatsächlich eine Adresse anlegt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
89e16d6da2
commit
85518ad850
12 changed files with 349 additions and 4 deletions
18
Changes.md
18
Changes.md
|
|
@ -1,5 +1,23 @@
|
||||||
# Änderungen
|
# Ä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)
|
## Zahlungsverkehr: automatische Bankauswahl (31.08.2026)
|
||||||
|
|
||||||
Beim Erfassen einer Überweisung musste bisher neben dem Auftragskonto auch die
|
Beim Erfassen einer Überweisung musste bisher neben dem Auftragskonto auch die
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
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
|
## Verträge
|
||||||
|
|
||||||
Der Nutzen liegt in der **Kündigungsfrist**: Die Übersicht zeigt, bis wann gekündigt sein
|
Der Nutzen liegt in der **Kündigungsfrist**: Die Übersicht zeigt, bis wann gekündigt sein
|
||||||
|
|
|
||||||
|
|
@ -1133,6 +1133,9 @@ tr.zugeklappt { display: none; }
|
||||||
.filterleiste .kombobox { max-width: 15rem; }
|
.filterleiste .kombobox { max-width: 15rem; }
|
||||||
.filterleiste .kombobox-eingabe { padding: .3rem .45rem; font-size: .9rem; }
|
.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 { width: 100%; max-width: 13rem; }
|
||||||
.zuordnungsauswahl .kombobox-eingabe {
|
.zuordnungsauswahl .kombobox-eingabe {
|
||||||
height: 1.35rem;
|
height: 1.35rem;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Finanzmanager\Controllers;
|
namespace Finanzmanager\Controllers;
|
||||||
|
|
||||||
use Finanzmanager\Models\Adresse;
|
use Finanzmanager\Models\Adresse;
|
||||||
|
use Finanzmanager\Models\Buchung;
|
||||||
use Finanzmanager\Models\Kategorie;
|
use Finanzmanager\Models\Kategorie;
|
||||||
use Finanzmanager\Models\Konto;
|
use Finanzmanager\Models\Konto;
|
||||||
use Finanzmanager\Models\Kredit;
|
use Finanzmanager\Models\Kredit;
|
||||||
|
|
@ -97,10 +98,17 @@ class VertraegeController extends BaseController
|
||||||
$this->verlangeAnmeldung();
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
$suche = $this->query('suche');
|
$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', [
|
$this->render('vertraege/adressen.php', [
|
||||||
'titel' => 'Adressen',
|
'titel' => 'Adressen',
|
||||||
'adressen' => Adresse::alle($suche),
|
'adressen' => $adressen,
|
||||||
'suche' => $suche,
|
'suche' => $suche,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Finanzmanager\Controllers;
|
namespace Finanzmanager\Controllers;
|
||||||
|
|
||||||
|
use Finanzmanager\Models\Adresse;
|
||||||
use Finanzmanager\Models\Bankinstitut;
|
use Finanzmanager\Models\Bankinstitut;
|
||||||
use Finanzmanager\Models\Einstellung;
|
use Finanzmanager\Models\Einstellung;
|
||||||
use Finanzmanager\Models\Erinnerung;
|
use Finanzmanager\Models\Erinnerung;
|
||||||
|
|
@ -44,6 +45,7 @@ class VerwaltungController extends BaseController
|
||||||
'vertraege' => count(Vertrag::alle(true)),
|
'vertraege' => count(Vertrag::alle(true)),
|
||||||
'fristen' => count(Vertrag::fristenLaufenAb(90)),
|
'fristen' => count(Vertrag::fristenLaufenAb(90)),
|
||||||
'bargeld' => count(Kategorie::bargeldAuswahl()),
|
'bargeld' => count(Kategorie::bargeldAuswahl()),
|
||||||
|
'adressen' => Adresse::anzahl(),
|
||||||
],
|
],
|
||||||
'bargeldAktiv' => BargeldAbgleich::eingerichtet(),
|
'bargeldAktiv' => BargeldAbgleich::eingerichtet(),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -487,6 +487,32 @@ class Buchung
|
||||||
return $anzahlen;
|
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)? */
|
/** Wie viele Buchungen hat das Konto (beide Sichten)? */
|
||||||
public static function anzahlFuerKonto(int $kontoId): int
|
public static function anzahlFuerKonto(int $kontoId): int
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,37 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
class="knopf klein gefahr">Löschen</button>
|
class="knopf klein gefahr">Löschen</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,18 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="kachel">
|
||||||
<header><h2>Termine</h2></header>
|
<header><h2>Termine</h2></header>
|
||||||
<p class="kennzahl"><?= (int)$zahlen['reihen'] ?></p>
|
<p class="kennzahl"><?= (int)$zahlen['reihen'] ?></p>
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,11 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<h3>Empfänger</h3>
|
<h3>Empfänger</h3>
|
||||||
|
|
||||||
<?php if ($adressen !== []): ?>
|
<?php if ($adressen !== []): ?>
|
||||||
<label for="adresse">Aus dem Adressbuch</label>
|
<?php /* Kein „for“ auf dem Label: kombobox.js legt vor das <select> ein
|
||||||
<select id="adresse" name="adresse" data-adressauswahl>
|
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>
|
<option value="">– von Hand eingeben –</option>
|
||||||
<?php foreach ($adressen as $adresse): ?>
|
<?php foreach ($adressen as $adresse): ?>
|
||||||
<option value="<?= (int)$adresse['id'] ?>"
|
<option value="<?= (int)$adresse['id'] ?>"
|
||||||
|
|
@ -156,4 +159,8 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($adressen !== []): ?>
|
||||||
|
<script src="/static/kombobox.js" defer></script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ class NavigationTest extends TestCase
|
||||||
'kategorien' => 0, 'einnahmen' => 0, 'ausgaben' => 0, 'klassen' => 0,
|
'kategorien' => 0, 'einnahmen' => 0, 'ausgaben' => 0, 'klassen' => 0,
|
||||||
'konten' => 0, 'kontengruppen' => 0, 'bankinstitute' => 0, 'bargeld' => 0,
|
'konten' => 0, 'kontengruppen' => 0, 'bankinstitute' => 0, 'bargeld' => 0,
|
||||||
'reihen' => 3, 'erinnerungen' => 2, 'vertraege' => 7, 'fristen' => 1,
|
'reihen' => 3, 'erinnerungen' => 2, 'vertraege' => 7, 'fristen' => 1,
|
||||||
|
'adressen' => 4,
|
||||||
];
|
];
|
||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
|
|
@ -23,6 +24,7 @@ class NavigationTest extends TestCase
|
||||||
$this->verwaltungLeuchtetFuerBeide();
|
$this->verwaltungLeuchtetFuerBeide();
|
||||||
$this->verwaltungsseiteFuehrtZuDenTerminen();
|
$this->verwaltungsseiteFuehrtZuDenTerminen();
|
||||||
$this->verwaltungsseiteFuehrtZuDenVertraegen();
|
$this->verwaltungsseiteFuehrtZuDenVertraegen();
|
||||||
|
$this->verwaltungsseiteFuehrtZurAdressverwaltung();
|
||||||
$this->alleKnoepfeGleichUndFussbuendig();
|
$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
|
private function stilblatt(): string
|
||||||
{
|
{
|
||||||
return (string)file_get_contents(dirname(__DIR__) . '/public/static/app.css');
|
return (string)file_get_contents(dirname(__DIR__) . '/public/static/app.css');
|
||||||
|
|
|
||||||
154
tests/38_AdressverwaltungTest.php
Normal file
154
tests/38_AdressverwaltungTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -309,4 +309,42 @@ test.describe('Module der Phasen 4 bis 7', () => {
|
||||||
// werden, bevor eine eingerichtet ist. Geprüft wird sie erst beim Senden.
|
// werden, bevor eine eingerichtet ist. Geprüft wird sie erst beim Senden.
|
||||||
await expect(page.locator('.meldung-fehler')).toContainText('IBAN')
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue