feat: Vollstaendiges Datenmodell als Prisma-Schema

Alle Fachentitaeten der Betreuung in einem Schema - bewusst komplett,
damit spaetere Etappen keine Migration am Fundament brauchen.

Kernentscheidungen:
- Geldbetraege durchgaengig als BigInt in Cent. Eine Rechnungslegung nach
  § 1865 BGB darf keine Rundungsfehler erben.
- Felder mit Suffix Verschluesselt tragen einen AES-256-GCM-Umschlag.
  Wo trotzdem gesucht werden muss (IBAN), steht ein HMAC-Blindindex daneben.
- Fristen und Stichtage sind @db.Date, nicht DateTime - sonst verschiebt
  die Zeitzone eine Frist um einen Tag.
- Frist.systemSchluessel macht die aus dem Bestellungsdatum abgeleiteten
  Pflichtfristen idempotent.
- Genehmigung.beschlussDokumentId ist die technische Voraussetzung dafuer,
  dass ein Vorgang ohne Beschluss nicht auf VOLLZOGEN gesetzt werden kann.
- DokumentVerknuepfung mit echten Fremdschluesseln statt loser Typ/ID-
  Spalte: eine Akte muss referenzsicher sein.
- Wertstand und Saldostand sind Historien - Werte werden nie ueberschrieben.

Dazu zwei SQL-Skripte, die Prisma nicht abbilden kann:
- volltextsuche.sql: tsvector-Trigger (deutsch) mit Gewichtung A/B/C,
  plus Trigram-Indizes fuer unscharfe Absendersuche.
- audit-schutz.sql: Trigger, die UPDATE und DELETE auf audit_log
  verweigern. Das Log ist Nachweis gegenueber Gericht und Miterben,
  also nur anhaengend - auch gegen psql.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 01:36:36 +02:00
parent cc72f5efd7
commit 6c1915ab9c
5 changed files with 1867 additions and 2 deletions
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env node
/**
* Bauprotokoll aus der Git-Historie erzeugen.
*
* Aufruf: npm run protokoll -> Tabelle auf der Konsole
* npm run protokoll -- --md -> Markdown fuer docs/BAUPROTOKOLL.md
*
* Grundlage sind die Commit-Trailer "Etappe:" und "Phase:". Jeder Commit
* traegt sie, damit sich im Nachhinein nachvollziehen laesst, wie lange
* welche Etappe gedauert hat - fuer die Kostenrechnung und als Nachweis,
* was wann entstanden ist.
*
* Die Dauer einer Etappe wird gemessen vom Ende der vorherigen Etappe
* (bzw. vom Baubeginn) bis zum letzten Commit der Etappe. Pausen zwischen
* zwei Commits, die laenger als PAUSE_SCHWELLE_MIN dauern, werden als
* Pause gewertet und nicht als Arbeitszeit gezaehlt.
*/
import { execSync } from 'node:child_process'
/** Laengere Luecken gelten als Pause und zaehlen nicht als Arbeitszeit. */
const PAUSE_SCHWELLE_MIN = 30
const TRENNER = '' // ASCII Unit Separator - kommt in Commit-Texten nicht vor
const SATZ_TRENNER = '' // Record Separator
function commitsLesen() {
const roh = execSync(
`git log --reverse --format="%H${TRENNER}%at${TRENNER}%s${TRENNER}%b${SATZ_TRENNER}"`,
{ encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 },
)
return roh
.split(SATZ_TRENNER)
.map((eintrag) => eintrag.trim())
.filter(Boolean)
.map((eintrag) => {
const [hash, zeitstempel, betreff, koerper = ''] = eintrag.split(TRENNER)
const etappe = koerper.match(/^Etappe:\s*(.+)$/m)?.[1]?.trim() ?? 'ohne'
const phase = koerper.match(/^Phase:\s*(.+)$/m)?.[1]?.trim() ?? ''
return {
hash: hash.slice(0, 8),
zeit: new Date(Number(zeitstempel) * 1000),
betreff,
etappe,
phase,
}
})
}
/**
* Arbeitszeit als Summe der Abstaende zwischen aufeinanderfolgenden
* Commits, wobei zu grosse Abstaende auf die Pausenschwelle gedeckelt
* werden. Der allererste Commit bekommt die Schwelle als Anlaufzeit
* gutgeschrieben - vorher wurde ja auch schon gearbeitet.
*/
function arbeitsminuten(commits) {
if (commits.length === 0) return 0
let summe = PAUSE_SCHWELLE_MIN
for (let i = 1; i < commits.length; i++) {
const diff = (commits[i].zeit - commits[i - 1].zeit) / 60000
summe += Math.min(diff, PAUSE_SCHWELLE_MIN)
}
return summe
}
function dauerText(minuten) {
const m = Math.round(minuten)
const std = Math.floor(m / 60)
const rest = m % 60
if (std === 0) return `${rest} min`
return `${std} h ${String(rest).padStart(2, '0')} min`
}
function formatZeit(datum) {
return datum.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const commits = commitsLesen()
if (commits.length === 0) {
console.error('Keine Commits gefunden.')
process.exit(1)
}
// Nach Etappe gruppieren, Reihenfolge des ersten Auftretens beibehalten.
const gruppen = new Map()
for (const commit of commits) {
if (!gruppen.has(commit.etappe)) gruppen.set(commit.etappe, [])
gruppen.get(commit.etappe).push(commit)
}
const zeilen = [...gruppen.entries()].map(([etappe, eintraege]) => ({
etappe,
phase: eintraege.find((e) => e.phase)?.phase ?? '',
commits: eintraege.length,
von: eintraege[0].zeit,
bis: eintraege[eintraege.length - 1].zeit,
minuten: arbeitsminuten(eintraege),
}))
const gesamtMinuten = zeilen.reduce((s, z) => s + z.minuten, 0)
const gesamtCommits = commits.length
const spanneMinuten = (commits[commits.length - 1].zeit - commits[0].zeit) / 60000
const alsMarkdown = process.argv.includes('--md')
if (alsMarkdown) {
const out = []
out.push('# Bauprotokoll')
out.push('')
out.push('Automatisch aus der Git-Historie erzeugt: `npm run protokoll -- --md`.')
out.push('Grundlage sind die Commit-Trailer `Etappe:` und `Phase:`.')
out.push('')
out.push(
`Reine Arbeitszeit wird als Summe der Commit-Abstaende gemessen, gedeckelt auf ` +
`${PAUSE_SCHWELLE_MIN} Minuten je Abstand. Laengere Luecken gelten als Pause.`,
)
out.push('')
out.push('| Etappe | Phase | Commits | Beginn | Ende | Arbeitszeit |')
out.push('|---|---|---:|---|---|---:|')
for (const z of zeilen) {
out.push(
`| ${z.etappe} | ${z.phase} | ${z.commits} | ${formatZeit(z.von)} | ` +
`${formatZeit(z.bis)} | ${dauerText(z.minuten)} |`,
)
}
out.push(
`| **Gesamt** | | **${gesamtCommits}** | ${formatZeit(commits[0].zeit)} | ` +
`${formatZeit(commits[commits.length - 1].zeit)} | **${dauerText(gesamtMinuten)}** |`,
)
out.push('')
out.push(`Kalendarische Spanne vom ersten bis zum letzten Commit: ${dauerText(spanneMinuten)}.`)
out.push('')
out.push('## Commits im Einzelnen')
out.push('')
out.push('| Zeitpunkt | Etappe | Commit | Betreff |')
out.push('|---|---|---|---|')
for (const c of commits) {
out.push(`| ${formatZeit(c.zeit)} | ${c.etappe} | \`${c.hash}\` | ${c.betreff} |`)
}
console.log(out.join('\n'))
} else {
console.log('')
console.log('Bauprotokoll')
console.log('='.repeat(74))
for (const z of zeilen) {
console.log(
`Etappe ${z.etappe.padEnd(4)} ${z.phase.padEnd(16)} ` +
`${String(z.commits).padStart(3)} Commits ${dauerText(z.minuten).padStart(10)}`,
)
}
console.log('-'.repeat(74))
console.log(
`Gesamt${' '.repeat(17)}${String(gesamtCommits).padStart(3)} Commits ` +
`${dauerText(gesamtMinuten).padStart(10)}`,
)
console.log(`Kalendarische Spanne: ${dauerText(spanneMinuten)}`)
console.log('')
}