syncova-policies/scripts/import-csv.js
Weapie 88a3eb1dcc
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 1m54s
Erklärungen aus der DSE-Gesamtliste neu aufbauen
Führende Quelle ist jetzt der Excel-Export des Amtes statt der einzelnen
Merkblatt-PDFs: 120 Zeilen, davon 4 bisher nicht erfasste Sozialamts-
Leistungen. Zuordnung über den Bezeichner, nicht den Titel – die aus PDF
gelesenen Titel unterscheiden sich in neun Fällen nur in Leerzeichen um
Schrägstriche und hätten Dubletten erzeugt.

Zwei Angaben stehen nicht in der Liste und werden ergänzt: der in allen
Merkblättern enthaltene Empfängersatz zur Finanzbuchhaltung (ohne ihn
hätten 27 Erklärungen gar keine Empfängerangabe) und die Bezeichnung der
Verarbeitungstätigkeit aus den Organisationsspalten.

Die zehn Bausteine folgen durchgehend den zentralen Standardtexten;
gespeichert wird nur die eine echte Abweichung: die gemeinsame
Verantwortlichkeit mit dem ZIT SH beim Online-Wohngeldantrag.

Alle 121 Erklärungen bleiben unveröffentlicht – drei Standardtexte
(Widerspruchsrecht, Drittlandübermittlung, automatisierte Entscheidungen)
sind noch nicht gepflegt und ohne sie ist keine vollständig.

Die Quellliste selbst bleibt unversioniert: Sie führt Namen von
Beschäftigten, die nicht in die Erklärungen übernommen werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:12:26 +02:00

310 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Importiert die DSE-Gesamtliste des Amtes Leezen (Excel-Export, CSV).
*
* node scripts/import-csv.js --dry-run nur prüfen und berichten
* node scripts/import-csv.js JSON schreiben und importieren
* node scripts/import-csv.js --prune zusätzlich verwaiste Datensätze löschen
*
* Die Liste ist führend für die Angaben je Verarbeitungstätigkeit. Die für alle
* Erklärungen gleichen Bausteine kommen aus den zentralen Standardtexten und
* werden hier bewusst NICHT je Erklärung dupliziert – gespeichert wird nur eine
* echte Abweichung (siehe scripts/lib/policy-defaults.js).
*
* Zuordnung bestehender Datensätze über den Bezeichner (slug), nicht über den
* Titel: Die alten, aus PDF gelesenen Titel unterscheiden sich teils nur in
* Leerzeichen um Schrägstriche ("Verträge/Vereinbarungen" gegen
* "Verträge /Vereinbarungen") und ergäben sonst Dubletten.
*/
const fs = require("fs")
const path = require("path")
const { PrismaClient } = require("@prisma/client")
const { readPolicyCsv } = require("./lib/csv-policy")
const { readDefaults, foldIntoDefaults } = require("./lib/policy-defaults")
const { slugify, uniqueSlug } = require("./lib/slug")
const prisma = new PrismaClient()
const CSV = path.join(process.cwd(), "data", "source", "DSE_Gesamtliste_Amt_Leezen.CSV")
const JSON_DIR = path.join(process.cwd(), "data", "policies")
const C = {
bereich: "Fachbereich",
dienst: "Fachdienst",
aufgabe: "Aufgabenbereich",
zufish: "ZuFiSH-Dienstleistung",
titel: "Formular-Bezeichnung",
nummer: "Formularnummer",
zweck: "Verarbeitungszweck",
rechtsgrundlage: "Rechtsgrundlage f.d. Datenerhebung",
pflicht: "Pflicht zur Bereitstellung der Daten",
folgen: "Folgen, wenn Daten nicht angeben werden",
empfaenger: "Empfänger f. d. beabsichtigte Weitergabe der Daten",
quelle: "Datenquelle (bei Dritterhebung)",
speicherdauer: "Dauer der Speicherung (Löschfristen)",
ozgVerantwortlich: "OZG Verantwortlicher",
ozgDsb: "OZG Datenschutzbeauftragter",
ozgEmpfaenger: "OZG Empfänger f. d. beabsichtigte Weitergabe der Daten",
}
/**
* Satz aus dem Merkblatt, der in allen 117 Vorlagen steht, in der Liste aber
* nicht wiederholt wird. Ohne ihn verlören 27 Erklärungen jede Empfängerangabe.
*/
const EMPFAENGER_STANDARD = "Bei Ein- und Auszahlungen: Finanzbuchhaltung"
/** Einleitung der gedruckten Merkblätter, wortgleich in allen Vorlagen. */
const INTRO =
"Wir verarbeiten Ihre notwendigen personenbezogenen Daten zur Erfüllung unserer Aufgaben stets im Einklang mit den jeweils anwendbaren gesetzlichen Datenschutzanforderungen zu den nachfolgend aufgeführten Zwecken. Personenbezogene Daten sind sämtliche Informationen, die einen Bezug zu einer Person aufweisen."
/** Werte, die „keine Angabe" bedeuten und nicht als Inhalt zählen dürfen. */
const LEERWERTE = new Set(["entfällt", "entfaellt", "keine", "-", "--", "n/a", "nein"])
/** Mehrfache Leerzeichen aus dem Excel-Export zusammenziehen. */
function clean(value) {
return String(value ?? "").replace(/[ \t]+/g, " ").replace(/ *\n */g, "\n").trim()
}
/** Wie clean(), liefert aber null statt Leerstring und filtert Platzhalter. */
function textOrNull(value) {
const text = clean(value)
if (!text) return null
return LEERWERTE.has(text.toLowerCase()) ? null : text
}
/** Baut aus den Organisationsspalten die Bezeichnung der Verarbeitungstätigkeit. */
function buildDescription(record, titel) {
const einordnung = [
record[C.bereich] && `Fachbereich: ${clean(record[C.bereich])}`,
record[C.dienst] && `Fachdienst: ${clean(record[C.dienst])}`,
record[C.aufgabe] && `Aufgabenbereich: ${clean(record[C.aufgabe])}`,
record[C.zufish] && `Dienstleistung: ${clean(record[C.zufish])}`,
record[C.nummer] && `Formularnummer: ${clean(record[C.nummer])}`,
].filter(Boolean)
return `**${titel}**\n\n${einordnung.join("\n")}\n\n${INTRO}`
}
/** Empfängerangabe: Standardsatz, Angaben der Liste, dann der OZG-Zusatz. */
function buildRecipients(record) {
const teile = [EMPFAENGER_STANDARD]
const eigene = textOrNull(record[C.empfaenger])
if (eigene) teile.push(eigene)
const ozg = textOrNull(record[C.ozgEmpfaenger])
if (ozg) {
teile.push(
`Im Rahmen des Online-Verfahrens werden die Daten zusätzlich weitergegeben an: ${ozg}`
)
}
return teile.join("\n")
}
/** Adressblock aus einer mit Semikolon getrennten Zeile lesbar umbrechen. */
function addressBlock(value) {
return clean(value)
.split(";")
.map((part) => part.trim())
.filter(Boolean)
.join("\n")
}
async function main() {
const dryRun = process.argv.includes("--dry-run")
const prune = process.argv.includes("--prune")
if (!fs.existsSync(CSV)) {
console.error(`Liste nicht gefunden: ${CSV}`)
process.exitCode = 1
return
}
const { records } = readPolicyCsv(CSV)
const defaults = await readDefaults(prisma)
// Titel eindeutig machen – bei Gleichstand entscheidet der Aufgabenbereich
const nachTitel = new Map()
for (const r of records) {
const t = clean(r[C.titel])
if (!nachTitel.has(t)) nachTitel.set(t, [])
nachTitel.get(t).push(r)
}
const umbenannt = []
const titelVon = new Map()
for (const [titel, gruppe] of nachTitel) {
if (gruppe.length === 1) {
titelVon.set(gruppe[0][C.nummer], titel)
continue
}
gruppe.forEach((r, i) => {
const zusatz = clean(r[C.aufgabe]) || clean(r[C.zweck]) || String(i + 1)
const neu = `${titel} (${zusatz})`
titelVon.set(r[C.nummer], neu)
umbenannt.push(`${r[C.nummer]}: „${titel}" → „${neu}"`)
})
}
// Bestehende Datensätze für die Zuordnung über den Bezeichner
const vorhandene = await prisma.privacyPolicy.findMany({
select: { id: true, slug: true, title: true },
})
const nachSlug = new Map(vorhandene.map((p) => [p.slug, p]))
const belegt = new Set(vorhandene.map((p) => p.slug))
const angefasst = new Set()
const aufbereitet = []
const hinweise = { ohneZweck: [], ohneRecht: [], ohneDauer: [], platzhalter: [], art14: [] }
for (const record of records) {
const nummer = clean(record[C.nummer])
const titel = titelVon.get(record[C.nummer])
const quelle = textOrNull(record[C.quelle])
const speicherdauer = textOrNull(record[C.speicherdauer])
const zweck = textOrNull(record[C.zweck])
const rechtsgrundlage = textOrNull(record[C.rechtsgrundlage])
const daten = {
title: titel,
processingDescription: buildDescription(record, titel),
processingPurposes: zweck,
legalBasis: rechtsgrundlage,
legitimateInterests: null,
recipients: buildRecipients(record),
storageDuration: speicherdauer,
dataSource: quelle,
dataCategories: null,
// Bausteine: leer heißt „folgt dem Standard"
responsible: null,
contactDPO: null,
dataSubjectsRights: null,
complaintRight: null,
withdrawalRight: null,
objectionRight: null,
thirdCountryTransfer: null,
automatedDecision: null,
provisionObligation: textOrNull(record[C.pflicht]),
provisionConsequences: textOrNull(record[C.folgen]),
isPublic: false,
}
// Gemeinsame Verantwortlichkeit im Online-Verfahren: bewusste Abweichung
const ozgV = textOrNull(record[C.ozgVerantwortlich])
if (ozgV) {
daten.responsible =
`${defaults.responsible}\n\nVerantwortlich für das Online-Verfahren ist:\n${addressBlock(ozgV)}`
}
const ozgD = textOrNull(record[C.ozgDsb])
if (ozgD) {
daten.contactDPO =
`${defaults.contactDPO}\n\nDatenschutzbeauftragte/r für das Online-Verfahren:\n${addressBlock(ozgD)}`
}
if (!zweck) hinweise.ohneZweck.push(`${nummer} – ${titel}`)
if (!rechtsgrundlage) hinweise.ohneRecht.push(`${nummer} – ${titel}`)
if (!speicherdauer) hinweise.ohneDauer.push(`${nummer} – ${titel}`)
if (speicherdauer && /\bx\b/i.test(speicherdauer)) {
hinweise.platzhalter.push(`${nummer} – „${speicherdauer}"`)
}
if (quelle && !daten.dataCategories) hinweise.art14.push(`${nummer} – ${titel}`)
const overrides = foldIntoDefaults(daten, defaults)
// Bezeichner: bestehender bleibt erhalten, sonst aus dem Titel ableiten
const basis = slugify(titel)
const bestehend = nachSlug.get(basis)
if (bestehend) {
daten.slug = bestehend.slug
angefasst.add(bestehend.slug)
} else {
daten.slug = uniqueSlug(titel, belegt)
belegt.add(daten.slug)
}
aufbereitet.push({ nummer, daten, overrides, bestehend })
}
// Bericht
console.log(`Zeilen der Liste: ${records.length}`)
console.log(`Datensätze aufbereitet: ${aufbereitet.length}`)
console.log(`Standardtexte gepflegt: ${Object.entries(defaults).filter(([, v]) => String(v).trim()).map(([k]) => k).join(", ") || "keine"}`)
console.log("")
if (umbenannt.length) {
console.log(`=== Titel eindeutig gemacht (${umbenannt.length}) ===`)
umbenannt.forEach((z) => console.log(" " + z))
console.log("")
}
const mitAbweichung = aufbereitet.filter((a) => a.overrides.length)
console.log(`=== Abweichungen vom Standardtext (${mitAbweichung.length}) ===`)
mitAbweichung.forEach((a) => console.log(` ${a.nummer}: ${a.overrides.join(", ")}`))
console.log("")
const zeige = (titel, liste) => {
console.log(`=== ${titel} (${liste.length}) ===`)
liste.forEach((z) => console.log(" " + z))
console.log("")
}
zeige("Ohne Zweck – Art. 13 Abs. 1 lit. c", hinweise.ohneZweck)
zeige("Ohne Rechtsgrundlage – Art. 13 Abs. 1 lit. c", hinweise.ohneRecht)
zeige("Ohne Speicherdauer – Art. 13 Abs. 2 lit. a", hinweise.ohneDauer)
zeige("Platzhalter statt Frist", hinweise.platzhalter)
zeige("Herkunft angegeben, Datenkategorien fehlen – Art. 14 Abs. 1 lit. d", hinweise.art14)
const verwaist = vorhandene.filter((p) => !angefasst.has(p.slug))
console.log(`=== Nicht in der Liste enthalten (${verwaist.length}) ===`)
verwaist.forEach((p) => console.log(` ${p.slug} – „${p.title}"`))
console.log("")
if (dryRun) {
console.log("--dry-run: nichts geschrieben.")
return
}
fs.mkdirSync(JSON_DIR, { recursive: true })
let neu = 0
let aktualisiert = 0
for (const { nummer, daten, overrides, bestehend } of aufbereitet) {
fs.writeFileSync(
path.join(JSON_DIR, `${daten.slug}.json`),
JSON.stringify({ _quelle: `DSE_Gesamtliste_Amt_Leezen.CSV · ${nummer}`, _abweichungen: overrides, ...daten }, null, 2),
"utf8"
)
if (bestehend) {
await prisma.privacyPolicy.update({ where: { id: bestehend.id }, data: daten })
aktualisiert++
} else {
await prisma.privacyPolicy.create({ data: daten })
neu++
}
}
console.log(`Neu angelegt: ${neu}`)
console.log(`Aktualisiert: ${aktualisiert}`)
if (prune && verwaist.length) {
for (const p of verwaist) {
await prisma.privacyPolicy.delete({ where: { id: p.id } })
}
console.log(`Gelöscht: ${verwaist.length}`)
} else if (verwaist.length) {
console.log(`Unberührt: ${verwaist.length} (mit --prune entfernen)`)
}
console.log(`JSON-Dateien: data/policies/`)
}
main()
.catch((error) => {
console.error("Fehler beim Import:", error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})