Rechtliches: Fristen- und Versionslogik mit Tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 10:17:13 +00:00
1 parent 701ff1a8e8
commit 0105179e21
3 files changed
+253 -1

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
// Reine Logik fuer den Bereich Rechtliches: Kuendigungsfristen der Anbieter-Vertraege
// und die Versionsgeschichte der Rechtstexte. Datumswerte sind immer 'YYYY-MM-DD'.
const DAY = 24 * 60 * 60 * 1000
function toUtc(date) {
const [y, m, d] = date.split('-').map(Number)
return Date.UTC(y, m - 1, d)
}
function fromUtc(ms) {
return new Date(ms).toISOString().slice(0, 10)
}
function addDays(date, n) {
return fromUtc(toUtc(date) + n * DAY)
}
function daysBetween(from, to) {
return Math.round((toUtc(to) - toUtc(from)) / DAY)
}
function isSet(value) {
return value !== null && value !== undefined && value !== ''
}
/** Monate addieren; der Tag wird auf das Monatsende geklemmt (31.01. + 1 = 28./29.02.). */
export function addMonthsClamped(date, n) {
const [y, m, d] = date.split('-').map(Number)
const index = y * 12 + (m - 1) + n
const year = Math.floor(index / 12)
const month = index - year * 12
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
return fromUtc(Date.UTC(year, month, Math.min(d, lastDay)))
}
function noticeDate(periodEnd, contract) {
const value = Number(contract.noticeValue)
return contract.noticeUnit === 'days'
? addDays(periodEnd, -value)
: addMonthsClamped(periodEnd, -value)
}
/**
* Naechster Kuendigungstermin eines Anbieter-Vertrags.
* state: active (noticeBy = spaetester Kuendigungstag), anytime (jederzeit kuendbar),
* ends (laeuft ohne Verlaengerung aus), ended, cancelled, incomplete (Angaben fehlen).
*/
export function nextCancellationDate(contract, today) {
if (isSet(contract.cancelledAt)) {
return { state: 'cancelled', periodEnd: contract.endsAt || null, noticeBy: null }
}
if (!isSet(contract.startDate) || !isSet(contract.termMonths)) {
return { state: 'incomplete', periodEnd: null, noticeBy: null }
}
const term = Number(contract.termMonths)
if (term === 0) return { state: 'anytime', periodEnd: null, noticeBy: null }
const renewal = Number(contract.renewalMonths) || 0
let periodEnd = addMonthsClamped(contract.startDate, term)
if (renewal === 0) {
return { state: periodEnd < today ? 'ended' : 'ends', periodEnd, noticeBy: null }
}
if (!isSet(contract.noticeValue) || !isSet(contract.noticeUnit)) {
return { state: 'incomplete', periodEnd: null, noticeBy: null }
}
while (noticeDate(periodEnd, contract) < today) {
periodEnd = addMonthsClamped(periodEnd, renewal)
}
return { state: 'active', periodEnd, noticeBy: noticeDate(periodEnd, contract) }
}
/** Farbe in der Vertragsliste: rot <= 30 Tage, orange <= 90 Tage bis zum Kuendigungstermin. */
export function deadlineTone(result, today) {
if (result.state === 'cancelled' || result.state === 'ended') return 'grey'
if (result.state === 'incomplete') return 'missing'
if (result.state !== 'active' || !result.noticeBy) return 'normal'
const days = daysBetween(today, result.noticeBy)
if (days <= 30) return 'red'
if (days <= 90) return 'orange'
return 'normal'
}
/** Aktuell gueltige Version; bei mehreren (z. B. halb gescheiterte Abloesung) die juengste. */
export function currentVersion(versions) {
return versions
.filter((v) => v.status === 'current')
.reduce((best, v) => (!best || v.validFrom > best.validFrom ? v : best), null)
}
/**
* Was beim Anlegen einer Version zu tun ist: das neue Dokument und die Aenderungen an
* bestehenden Versionen. Eine juengere gueltige Version loest die aktuelle ab; eine
* nachgetragene aeltere Fassung wird direkt als abgeloest einsortiert.
*/
export function planNewVersion(versions, input) {
const siblings = versions.filter(
(v) => v.documentKey === input.documentKey && v.$id !== input.$id
)
if (input.status !== 'current') {
return { create: { ...input, validTo: null }, updates: [] }
}
const current = currentVersion(siblings)
if (!current || input.validFrom >= current.validFrom) {
const updates = current
? [{ id: current.$id, patch: { status: 'superseded', validTo: addDays(input.validFrom, -1) } }]
: []
return { create: { ...input, validTo: null }, updates }
}
const nextFrom = siblings
.filter((v) => v.status !== 'draft' && v.validFrom > input.validFrom)
.map((v) => v.validFrom)
.sort()[0]
return {
create: { ...input, status: 'superseded', validTo: addDays(nextFrom, -1) },
updates: [],
}
}
/** 'YYYY-MM-DD' -> 'dd.mm.yyyy' */
export function formatDate(date) {
if (!date) return '–'
const [y, m, d] = date.slice(0, 10).split('-')
return `${d}.${m}.${y}`
}