Der Demofall brachte Konten und Kontostaende mit, aber keine einzige Buchung. Kassenbuch, Belegpflicht, Dauerzahlungen und die Erkennung wiederkehrender Zahlungen liefen darin also leer - wer die Anwendung zum ersten Mal ansah, musste erst eine CSV importieren. Neu sind 107 Buchungen ueber zwoelf Monate, erzeugt aus einem Muster statt einzeln abgetippt: Rente, Betriebsrente und Pflegekasse als Einnahmen, Heimentgelt, Barbetrag, Sterbegeldbeitrag, Telefon und Kontofuehrung monatlich, dazu Rundfunkbeitrag und Grundsteuer vierteljaehrlich sowie eine Handvoll Einzelposten. Was der Fall damit zeigt: - Das Heimentgelt steht in drei Schreibweisen auf dem Auszug und wird trotzdem als eine monatliche Zahlung gefuehrt. - Genau eine Buchung hat weder Quittung noch Begruendung. Eine, nicht zwanzig: Die Liste soll etwas zu zeigen haben und nicht rot leuchten. - Die Sterbegeld-Lastschriften bleiben unzugeordnet, damit sich der Handgriff "zwoelf Zahlungen als belegt zuordnen" ausprobieren laesst. Der Beitrag steigt im Mai von 18,40 auf 19,80 - damit auch die Abweichungspruefung sichtbar wird. - Der Kontostand zum 31.07. wird aus Anfangsstand und Buchungen gerechnet, nicht behauptet. Ein Beispielfall, der sich selbst widerspricht, waere schlimmer als einer ohne Buchungen. Zwei Funde beim Bauen: Der Unique-Index auf (kontoId, importReferenz) hat einen echten Fehler in den Daten gefangen: Zwei Renten am Monatsersten ergaben dieselbe Referenz. Genau dafuer ist er da. "gGmbH" fehlte in der Rechtsformliste. Dadurch galten "Seniorenzentrum St. Katharina" und "... gGmbH" als zwei Empfaenger - und die Gruppe zerfiel ausgerechnet in dem Fachgebiet, in dem fast jeder Traeger eine gGmbH ist. Ergaenzt, mit Test. Geprueft: ein Einheitstest fuer die Rechtsform, vier End-to-End-Tests am Beispielfall - Umfang und Zeitraum, Kontostand gegen Buchungssumme, die erkannten Gruppen und das Kassenbuch. Refs #22
551 lines
19 KiB
TypeScript
551 lines
19 KiB
TypeScript
import { summe } from '@/lib/finanzen/betrag'
|
|
import { nachTagSortieren, tageZwischen } from '@/lib/finanzen/datum'
|
|
|
|
/**
|
|
* Wiederkehrende Zahlungen aufspueren - ohne Datenbank, damit sich jede
|
|
* Entscheidung einzeln nachrechnen laesst.
|
|
*
|
|
* Der Anlass: Am Anfang einer Betreuung weiss niemand, welche Vertraege es
|
|
* gibt. Die Antwort steht aber im Kontoauszug - wer regelmaessig abbucht,
|
|
* hat einen Vertrag. Diese Datei macht aus hunderten Buchungszeilen eine
|
|
* Arbeitsliste.
|
|
*
|
|
* Zwei Haltungen liegen allem zugrunde:
|
|
*
|
|
* 1. **Im Zweifel getrennt lassen.** Zwei Zeilen zu viel sind harmlos - man
|
|
* sieht hin und hakt ab. Eine falsch zusammengefasste Gruppe verbirgt
|
|
* dagegen einen Vertrag, und der fehlt dann im Vermoegensverzeichnis.
|
|
* Deshalb wird nur zusammengefasst, was sicher dasselbe ist.
|
|
*
|
|
* 2. **Nichts behaupten, was nicht belegt ist.** Die Erkennung nennt immer
|
|
* mit, worauf sie sich stuetzt ("4 Zahlungen, etwa alle 3 Monate").
|
|
* Eine Einordnung ohne nachvollziehbare Grundlage waere geraten, und
|
|
* geratene Zahlen landen sonst im Bericht ans Gericht.
|
|
*/
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Eingabe und Ergebnis
|
|
// -----------------------------------------------------------------------------
|
|
|
|
export type ZahlungEingabe = {
|
|
id: string
|
|
datum: Date
|
|
betragCent: bigint
|
|
gegenstelle: string | null
|
|
/** Haengt die Buchung schon an einem Vertrag? (Issue #11) */
|
|
vertragId?: string | null
|
|
}
|
|
|
|
/** Ein hinterlegter Vertrag - zum Abgleich, ob die Zahlung schon erklaert ist. */
|
|
export type VertragAbgleich = {
|
|
id: string
|
|
bezeichnung: string
|
|
gesellschaft: string | null
|
|
}
|
|
|
|
export type Rhythmus =
|
|
| 'MONATLICH'
|
|
| 'ZWEIMONATLICH'
|
|
| 'VIERTELJAEHRLICH'
|
|
| 'HALBJAEHRLICH'
|
|
| 'JAEHRLICH'
|
|
| 'UNREGELMAESSIG'
|
|
|
|
export const RHYTHMUS_BESCHRIFTUNG: Record<Rhythmus, string> = {
|
|
MONATLICH: 'monatlich',
|
|
ZWEIMONATLICH: 'zweimonatlich',
|
|
VIERTELJAEHRLICH: 'vierteljährlich',
|
|
HALBJAEHRLICH: 'halbjährlich',
|
|
JAEHRLICH: 'jährlich',
|
|
UNREGELMAESSIG: 'unregelmäßig',
|
|
}
|
|
|
|
/** Wie oft der Rhythmus im Jahr zuschlaegt. Fuer die Hochrechnung. */
|
|
const ZAHLUNGEN_JE_JAHR: Record<Rhythmus, number> = {
|
|
MONATLICH: 12,
|
|
ZWEIMONATLICH: 6,
|
|
VIERTELJAEHRLICH: 4,
|
|
HALBJAEHRLICH: 2,
|
|
JAEHRLICH: 1,
|
|
UNREGELMAESSIG: 0,
|
|
}
|
|
|
|
export type Zahlungsgruppe = {
|
|
/** Normalisierter Name - der Schluessel, unter dem abgehakt wird. */
|
|
schluessel: string
|
|
/** Die laengste vorgefundene Schreibweise, fuer die Anzeige. */
|
|
anzeigename: string
|
|
/** Alle vorgefundenen Schreibweisen, falls mehrere zusammengefasst wurden. */
|
|
schreibweisen: string[]
|
|
anzahl: number
|
|
ersteZahlung: Date
|
|
letzteZahlung: Date
|
|
/** Median der Betraege - unempfindlicher gegen einen Ausreisser als der Schnitt. */
|
|
betragCent: bigint
|
|
kleinsterBetragCent: bigint
|
|
groessterBetragCent: bigint
|
|
/** Schwanken die Betraege stark? Dann ist es eher keine feste Praemie. */
|
|
betragSchwankt: boolean
|
|
rhythmus: Rhythmus
|
|
/** Medianabstand in Tagen - die Grundlage der Rhythmuserkennung. */
|
|
abstandTage: number | null
|
|
/** Hochgerechnet auf ein Jahr. Bei unregelmaessigem Rhythmus: das
|
|
* tatsaechlich Gezahlte, nicht hochgerechnet. */
|
|
jahresbetragCent: bigint
|
|
/** Passt der Name zu einem hinterlegten Vertrag? */
|
|
vertragId: string | null
|
|
vertragBezeichnung: string | null
|
|
buchungIds: string[]
|
|
/**
|
|
* Wie viele Buchungen der Gruppe haengen bereits an einem Vertrag?
|
|
* Grundlage fuer die Sammelzuordnung: Nur der Rest muss noch verknuepft
|
|
* werden.
|
|
*/
|
|
bereitsZugeordnet: number
|
|
/** Buchungen der Gruppe ohne Vertragsbezug. */
|
|
offeneBuchungIds: string[]
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Namen vergleichbar machen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Rechtsformen und Zusaetze, die keinen Unterschied machen.
|
|
*
|
|
* Bewusst kurz gehalten. Jeder Eintrag hier ist eine Behauptung "das darf
|
|
* weg" - und jede falsche Behauptung wirft zwei verschiedene Empfaenger
|
|
* zusammen. "Versicherung" steht deshalb NICHT in der Liste: Zwischen
|
|
* "Allianz Versicherung" und "Allianz Lebensversicherung" liegt ein
|
|
* Unterschied, der im Vermoegensverzeichnis zaehlt.
|
|
*/
|
|
const RECHTSFORMEN = [
|
|
'gmbh & co kg',
|
|
'gmbh und co kg',
|
|
// Die haeufigste Rechtsform in diesem Fachgebiet ueberhaupt: Heime,
|
|
// Sozialdienste und Traeger sind fast durchweg gGmbH. Fehlte sie,
|
|
// zerfiele die Zusammenfassung genau dort, wo sie gebraucht wird.
|
|
'ggmbh',
|
|
'gmbh',
|
|
'mbh',
|
|
'ohg',
|
|
'kgaa',
|
|
'kg',
|
|
'ag',
|
|
'se',
|
|
'ev',
|
|
'vvag',
|
|
'ag',
|
|
'aoer',
|
|
'koer',
|
|
]
|
|
|
|
/** Ersetzt Umlaute, damit "Muenchener" und "Münchener" zusammenfallen. */
|
|
function umlauteAufloesen(text: string): string {
|
|
return text
|
|
.replace(/ä/g, 'ae')
|
|
.replace(/ö/g, 'oe')
|
|
.replace(/ü/g, 'ue')
|
|
.replace(/ß/g, 'ss')
|
|
}
|
|
|
|
/**
|
|
* Macht zwei Schreibweisen desselben Empfaengers vergleichbar.
|
|
*
|
|
* Was hier zusammenfaellt, wird spaeter in einer Zeile angezeigt. Deshalb
|
|
* nur das Unstrittige: Gross- und Kleinschreibung, Satzzeichen, doppelte
|
|
* Leerzeichen, Umlaute und die Rechtsform am Ende.
|
|
*
|
|
* Was ausdruecklich NICHT passiert: Aehnlichkeitssuche ueber Abstandsmasse.
|
|
* "Stadtwerke Bamberg" und "Stadtwerke Bayreuth" unterscheiden sich in drei
|
|
* Zeichen und sind zwei voellig verschiedene Rechnungssteller.
|
|
*/
|
|
export function normalisieren(gegenstelle: string | null | undefined): string {
|
|
if (!gegenstelle) return ''
|
|
|
|
let text = umlauteAufloesen(gegenstelle.toLowerCase())
|
|
|
|
// Satzzeichen zu Leerzeichen, damit "vers.ag" zu "vers ag" wird und die
|
|
// Rechtsform anschliessend als eigenes Wort erkennbar ist.
|
|
text = text.replace(/[.,;:/\\()[\]{}"'`*+_-]/g, ' ')
|
|
text = text.replace(/\s+/g, ' ').trim()
|
|
|
|
// Rechtsform nur am ENDE entfernen. Mitten im Namen kann dieselbe
|
|
// Zeichenfolge etwas anderes sein - "ag" steckt in "Agentur".
|
|
let geaendert = true
|
|
while (geaendert) {
|
|
geaendert = false
|
|
for (const form of RECHTSFORMEN) {
|
|
if (text.endsWith(` ${form}`)) {
|
|
text = text.slice(0, -(form.length + 1)).trim()
|
|
geaendert = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return text
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Rhythmus
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function median(werte: number[]): number {
|
|
if (werte.length === 0) return 0
|
|
const sortiert = [...werte].sort((a, b) => a - b)
|
|
const mitte = Math.floor(sortiert.length / 2)
|
|
return sortiert.length % 2 === 1
|
|
? sortiert[mitte]!
|
|
: Math.round((sortiert[mitte - 1]! + sortiert[mitte]!) / 2)
|
|
}
|
|
|
|
function medianBigInt(werte: bigint[]): bigint {
|
|
if (werte.length === 0) return 0n
|
|
const sortiert = [...werte].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
const mitte = Math.floor(sortiert.length / 2)
|
|
return sortiert.length % 2 === 1
|
|
? sortiert[mitte]!
|
|
: (sortiert[mitte - 1]! + sortiert[mitte]!) / 2n
|
|
}
|
|
|
|
/**
|
|
* Ordnet einen Medianabstand einem Rhythmus zu.
|
|
*
|
|
* Die Spannen sind bewusst weit: Lastschriften kommen am ersten Werktag,
|
|
* und der wandert. Zwischen dem 1. Februar und dem 1. Maerz liegen 28 Tage,
|
|
* zwischen dem 1. Juli und dem 1. August 31 - beides ist monatlich.
|
|
*/
|
|
export function rhythmusAusAbstand(tage: number): Rhythmus {
|
|
if (tage >= 24 && tage <= 38) return 'MONATLICH'
|
|
if (tage >= 50 && tage <= 70) return 'ZWEIMONATLICH'
|
|
if (tage >= 80 && tage <= 100) return 'VIERTELJAEHRLICH'
|
|
if (tage >= 160 && tage <= 200) return 'HALBJAEHRLICH'
|
|
if (tage >= 330 && tage <= 400) return 'JAEHRLICH'
|
|
return 'UNREGELMAESSIG'
|
|
}
|
|
|
|
/** Der Medianabstand zwischen aufeinanderfolgenden Zahlungen, in Tagen. */
|
|
export function abstandInTagen(daten: Date[]): number | null {
|
|
if (daten.length < 2) return null
|
|
const sortiert = nachTagSortieren(daten, (d) => d)
|
|
const abstaende: number[] = []
|
|
for (let i = 1; i < sortiert.length; i++) {
|
|
abstaende.push(Math.abs(tageZwischen(sortiert[i - 1]!, sortiert[i]!)))
|
|
}
|
|
return median(abstaende)
|
|
}
|
|
|
|
/**
|
|
* Schwanken die Betraege so stark, dass es keine feste Zahlung mehr ist?
|
|
*
|
|
* Ein Fuenftel Abweichung vom Median ist grosszuegig - Beitragsanpassungen
|
|
* und Stromabschlaege bewegen sich in dieser Groessenordnung. Wer darueber
|
|
* liegt, zahlt eher nach Verbrauch als nach Vertrag.
|
|
*/
|
|
export function betraegeSchwanken(betraege: bigint[]): boolean {
|
|
if (betraege.length < 2) return false
|
|
const mitte = medianBigInt(betraege)
|
|
if (mitte === 0n) return true
|
|
|
|
return betraege.some((betrag) => {
|
|
const abweichung = betrag > mitte ? betrag - mitte : mitte - betrag
|
|
// Ganzzahlig gerechnet: |x - m| * 100 > m * 20
|
|
return abweichung * 100n > mitte * 20n
|
|
})
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Gruppieren
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Ab wann gilt eine Zahlung als wiederkehrend?
|
|
*
|
|
* Zwei ist das Minimum, das ueberhaupt einen Abstand ergibt. Damit bleibt
|
|
* eine Luecke, die man kennen muss: Eine JAEHRLICH abgebuchte Praemie
|
|
* erscheint bei nur einem importierten Jahr genau einmal und wird hier
|
|
* nicht gefunden. Deshalb gibt es daneben die Liste der einzelnen groesseren
|
|
* Zahlungen - siehe einzelneZahlungen().
|
|
*/
|
|
export const MINDESTANZAHL = 2
|
|
|
|
export type GruppierungsOptionen = {
|
|
/** Schon abgehakte Schluessel - "das ist kein Vertrag". */
|
|
abgehakt?: ReadonlySet<string>
|
|
/** Hinterlegte Vertraege, zum Abgleich. */
|
|
vertraege?: VertragAbgleich[]
|
|
}
|
|
|
|
/** Ordnet Vertraege ihrem normalisierten Gesellschaftsnamen zu. */
|
|
function vertragsIndex(vertraege: VertragAbgleich[]): Map<string, VertragAbgleich> {
|
|
const index = new Map<string, VertragAbgleich>()
|
|
for (const vertrag of vertraege) {
|
|
const schluessel = normalisieren(vertrag.gesellschaft)
|
|
// Erster Treffer gewinnt. Zwei Vertraege bei derselben Gesellschaft sind
|
|
// moeglich (Hausrat und Haftpflicht bei der Allianz); fuer die Frage
|
|
// "ist das schon erklaert?" genuegt einer.
|
|
if (schluessel && !index.has(schluessel)) index.set(schluessel, vertrag)
|
|
}
|
|
return index
|
|
}
|
|
|
|
/**
|
|
* Waehlt aus mehreren Schreibweisen die, die man jemandem zeigen kann.
|
|
*
|
|
* Banken liefern denselben Empfaenger mal als "Allianz Lebensversicherung
|
|
* AG", mal als "ALLIANZ LEBENSVERS AG". Die laengste zu nehmen waere
|
|
* naheliegend - und faellt regelmaessig auf die geschriene Fassung herein,
|
|
* weil Versalien nicht kuerzer sind.
|
|
*
|
|
* Das ist mehr als Kosmetik: Dieser Name wird beim "Vertrag anlegen" in das
|
|
* Feld Gesellschaft uebernommen. Steht dort alles in Grossbuchstaben, tippt
|
|
* man es von Hand neu - und dann war die Vorbelegung wertlos.
|
|
*
|
|
* Also: erst die Schreibweisen mit Klein- und Grossbuchstaben, unter denen
|
|
* die laengste. Gibt es nur geschriene, nimmt man eben die laengste davon.
|
|
*/
|
|
export function besteSchreibweise(schreibweisen: string[]): string {
|
|
if (schreibweisen.length === 0) return ''
|
|
|
|
const gemischt = schreibweisen.filter((name) => name !== name.toUpperCase())
|
|
const auswahl = gemischt.length > 0 ? gemischt : schreibweisen
|
|
|
|
return auswahl.reduce((a, b) => (b.length > a.length ? b : a), auswahl[0]!)
|
|
}
|
|
|
|
/**
|
|
* Fasst Zahlungen zu Gruppen zusammen und bewertet sie.
|
|
*
|
|
* Erwartet werden ausschliesslich Ausgaben, die die betreute Person
|
|
* betreffen - die Auswahl trifft der Aufrufer, weil sie von der Datenbank
|
|
* kommt. Umbuchungen zwischen eigenen Konten gehoeren nicht hierher: Wer
|
|
* monatlich aufs Sparbuch legt, hat deswegen keinen Vertrag.
|
|
*/
|
|
export function gruppieren(
|
|
zahlungen: ZahlungEingabe[],
|
|
optionen: GruppierungsOptionen = {},
|
|
): Zahlungsgruppe[] {
|
|
const abgehakt = optionen.abgehakt ?? new Set<string>()
|
|
const index = vertragsIndex(optionen.vertraege ?? [])
|
|
|
|
const nachSchluessel = new Map<string, ZahlungEingabe[]>()
|
|
for (const zahlung of zahlungen) {
|
|
const schluessel = normalisieren(zahlung.gegenstelle)
|
|
// Ohne Gegenstelle gibt es nichts zu gruppieren. Solche Buchungen sind
|
|
// Bargeldabhebungen oder handerfasste Zeilen - kein Vertragshinweis.
|
|
if (!schluessel) continue
|
|
if (abgehakt.has(schluessel)) continue
|
|
|
|
const bisher = nachSchluessel.get(schluessel)
|
|
if (bisher) bisher.push(zahlung)
|
|
else nachSchluessel.set(schluessel, [zahlung])
|
|
}
|
|
|
|
const gruppen: Zahlungsgruppe[] = []
|
|
|
|
for (const [schluessel, posten] of nachSchluessel) {
|
|
if (posten.length < MINDESTANZAHL) continue
|
|
|
|
const sortiert = nachTagSortieren(posten, (p) => p.datum)
|
|
const betraege = posten.map((p) => p.betragCent)
|
|
const abstandTage = abstandInTagen(posten.map((p) => p.datum))
|
|
const rhythmus = abstandTage === null ? 'UNREGELMAESSIG' : rhythmusAusAbstand(abstandTage)
|
|
const betragCent = medianBigInt(betraege)
|
|
|
|
const jeJahr = ZAHLUNGEN_JE_JAHR[rhythmus]
|
|
const jahresbetragCent =
|
|
jeJahr > 0 ? betragCent * BigInt(jeJahr) : summe(betraege)
|
|
|
|
const schreibweisen = [
|
|
...new Set(posten.map((p) => (p.gegenstelle ?? '').trim()).filter(Boolean)),
|
|
].sort()
|
|
|
|
const vertrag = index.get(schluessel) ?? null
|
|
|
|
gruppen.push({
|
|
schluessel,
|
|
anzeigename: besteSchreibweise(schreibweisen) || schluessel,
|
|
schreibweisen,
|
|
anzahl: posten.length,
|
|
ersteZahlung: sortiert[0]!.datum,
|
|
letzteZahlung: sortiert[sortiert.length - 1]!.datum,
|
|
betragCent,
|
|
kleinsterBetragCent: betraege.reduce((a, b) => (b < a ? b : a), betraege[0]!),
|
|
groessterBetragCent: betraege.reduce((a, b) => (b > a ? b : a), betraege[0]!),
|
|
betragSchwankt: betraegeSchwanken(betraege),
|
|
rhythmus,
|
|
abstandTage,
|
|
jahresbetragCent,
|
|
vertragId: vertrag?.id ?? null,
|
|
vertragBezeichnung: vertrag?.bezeichnung ?? null,
|
|
buchungIds: sortiert.map((p) => p.id),
|
|
bereitsZugeordnet: posten.filter((p) => Boolean(p.vertragId)).length,
|
|
offeneBuchungIds: sortiert.filter((p) => !p.vertragId).map((p) => p.id),
|
|
})
|
|
}
|
|
|
|
// Absteigend nach Jahresbetrag: Was am meisten kostet, will man zuerst
|
|
// geklaert haben.
|
|
return gruppen.sort((a, b) =>
|
|
a.jahresbetragCent > b.jahresbetragCent
|
|
? -1
|
|
: a.jahresbetragCent < b.jahresbetragCent
|
|
? 1
|
|
: a.anzeigename.localeCompare(b.anzeigename, 'de'),
|
|
)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Die Luecke: einmalige Zahlungen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Ab welchem Betrag eine einzelne Zahlung erwaehnenswert ist.
|
|
*
|
|
* 150 EUR ist die Grenze, ab der sich das Nachsehen lohnt. Darunter stehen
|
|
* Einkaeufe und Gebuehren, darueber faengt es an, nach Jahresbeitrag
|
|
* auszusehen.
|
|
*/
|
|
export const EINZELZAHLUNG_AB_CENT = 15_000n
|
|
|
|
export type Einzelzahlung = {
|
|
schluessel: string
|
|
anzeigename: string
|
|
buchungId: string
|
|
datum: Date
|
|
betragCent: bigint
|
|
vertragId: string | null
|
|
vertragBezeichnung: string | null
|
|
}
|
|
|
|
/**
|
|
* Einzelne groessere Zahlungen ohne hinterlegten Vertrag.
|
|
*
|
|
* Der Grund fuer diese zweite Liste: Die Gruppierung braucht mindestens
|
|
* zwei Zahlungen. Eine jaehrlich abgebuchte Versicherungspraemie erscheint
|
|
* bei einem importierten Jahr aber genau einmal - und ausgerechnet
|
|
* Versicherungen werden oft jaehrlich gezahlt. Ohne diese Liste waere die
|
|
* Suche genau dort blind, wo sie am meisten bringen soll.
|
|
*
|
|
* Sie ist ausdruecklich kein Vertragsverdacht, sondern eine Merkliste. In
|
|
* der Oberflaeche steht sie deshalb getrennt und mit eigener Ueberschrift.
|
|
*/
|
|
export function einzelneZahlungen(
|
|
zahlungen: ZahlungEingabe[],
|
|
optionen: GruppierungsOptionen = {},
|
|
): Einzelzahlung[] {
|
|
const abgehakt = optionen.abgehakt ?? new Set<string>()
|
|
const index = vertragsIndex(optionen.vertraege ?? [])
|
|
|
|
const anzahlJeSchluessel = new Map<string, number>()
|
|
for (const zahlung of zahlungen) {
|
|
const schluessel = normalisieren(zahlung.gegenstelle)
|
|
if (!schluessel) continue
|
|
anzahlJeSchluessel.set(schluessel, (anzahlJeSchluessel.get(schluessel) ?? 0) + 1)
|
|
}
|
|
|
|
const treffer: Einzelzahlung[] = []
|
|
|
|
for (const zahlung of zahlungen) {
|
|
const schluessel = normalisieren(zahlung.gegenstelle)
|
|
if (!schluessel) continue
|
|
if (abgehakt.has(schluessel)) continue
|
|
// Alles, was mehrfach vorkommt, steht bereits in der Gruppenliste.
|
|
if ((anzahlJeSchluessel.get(schluessel) ?? 0) >= MINDESTANZAHL) continue
|
|
if (zahlung.betragCent < EINZELZAHLUNG_AB_CENT) continue
|
|
|
|
const vertrag = index.get(schluessel) ?? null
|
|
|
|
treffer.push({
|
|
schluessel,
|
|
anzeigename: (zahlung.gegenstelle ?? '').trim() || schluessel,
|
|
buchungId: zahlung.id,
|
|
datum: zahlung.datum,
|
|
betragCent: zahlung.betragCent,
|
|
vertragId: vertrag?.id ?? null,
|
|
vertragBezeichnung: vertrag?.bezeichnung ?? null,
|
|
})
|
|
}
|
|
|
|
return treffer.sort((a, b) =>
|
|
a.betragCent > b.betragCent ? -1 : a.betragCent < b.betragCent ? 1 : 0,
|
|
)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Fuer die Anzeige
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Der Satz, der unter einer Gruppe steht.
|
|
*
|
|
* Er nennt die Grundlage der Einordnung, statt nur das Ergebnis zu
|
|
* behaupten - damit man widersprechen kann, wenn es nicht stimmt.
|
|
*/
|
|
export function rhythmusErklaerung(gruppe: Zahlungsgruppe): string {
|
|
const zahlungen = `${gruppe.anzahl} ${gruppe.anzahl === 1 ? 'Zahlung' : 'Zahlungen'}`
|
|
|
|
if (gruppe.rhythmus === 'UNREGELMAESSIG' || gruppe.abstandTage === null) {
|
|
return `${zahlungen}, ohne erkennbaren Abstand`
|
|
}
|
|
|
|
const tage = gruppe.abstandTage
|
|
const monate = Math.round(tage / 30)
|
|
const abstand =
|
|
monate <= 1 ? 'etwa monatlich' : `etwa alle ${monate} Monate`
|
|
|
|
return `${zahlungen}, ${abstand}`
|
|
}
|
|
|
|
/**
|
|
* Uebersetzt den erkannten Rhythmus in den Zahlrhythmus des Vertrags.
|
|
*
|
|
* Zwei Faelle haben dort keine Entsprechung: ZWEIMONATLICH kennt das
|
|
* Vertragsmodell nicht, und UNREGELMAESSIG ist keine Aussage, sondern das
|
|
* Eingestaendnis, keine zu haben. Beide liefern null - dann bleibt im
|
|
* Formular die Vorgabe stehen, statt etwas Falsches vorzublenden. Eine
|
|
* vorbelegte falsche Angabe wird uebersehen; ein leeres Feld nicht.
|
|
*/
|
|
export function alsZahlRhythmus(rhythmus: Rhythmus): string | null {
|
|
switch (rhythmus) {
|
|
case 'MONATLICH':
|
|
return 'MONATLICH'
|
|
case 'VIERTELJAEHRLICH':
|
|
return 'VIERTELJAEHRLICH'
|
|
case 'HALBJAEHRLICH':
|
|
return 'HALBJAEHRLICH'
|
|
case 'JAEHRLICH':
|
|
return 'JAEHRLICH'
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sucht den Vertrag, zu dem eine Gegenstelle EINDEUTIG passt.
|
|
*
|
|
* Gedacht fuer die automatische Zuordnung beim Import. Die Betonung liegt
|
|
* auf eindeutig: Passen zwei Vertraege (Hausrat und Haftpflicht bei
|
|
* derselben Gesellschaft), wird nichts zugeordnet. Eine falsche Zuordnung
|
|
* waere schlimmer als gar keine - sie behauptet einen Beleg, den es nicht
|
|
* gibt, und niemand sieht noch einmal hin.
|
|
*
|
|
* Gibt null zurueck, wenn nichts oder mehr als eines passt.
|
|
*/
|
|
export function eindeutigerVertrag(
|
|
gegenstelle: string | null | undefined,
|
|
vertraege: VertragAbgleich[],
|
|
): VertragAbgleich | null {
|
|
const schluessel = normalisieren(gegenstelle)
|
|
if (!schluessel) return null
|
|
|
|
const treffer = vertraege.filter((vertrag) => normalisieren(vertrag.gesellschaft) === schluessel)
|
|
return treffer.length === 1 ? treffer[0]! : null
|
|
}
|
|
|
|
/** Wie viele Gruppen warten noch auf eine Entscheidung? Fuer das Dashboard. */
|
|
export function offeneKandidaten(gruppen: Zahlungsgruppe[]): number {
|
|
return gruppen.filter((gruppe) => gruppe.vertragId === null).length
|
|
}
|