diff --git a/backend/package.json b/backend/package.json index fc83be6..fce10d1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,7 @@ "start": "node dist/src/index.js", "schema:index": "ts-node scripts/generate-schema-index.ts", "bankcodes:update": "tsx scripts/generate-de-bank-codes.ts", + "invoices:delete-range": "tsx scripts/delete-createddocument-range.ts", "members:import:csv": "tsx scripts/import-members-csv.ts", "profiles:import:mitarbeiterliste": "tsx scripts/import-mitarbeiterliste.ts", "accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts" diff --git a/backend/scripts/delete-createddocument-range.ts b/backend/scripts/delete-createddocument-range.ts new file mode 100644 index 0000000..6f32af3 --- /dev/null +++ b/backend/scripts/delete-createddocument-range.ts @@ -0,0 +1,359 @@ +import "dotenv/config" +import { Pool, type PoolClient } from "pg" + +import { loadSecrets, secrets } from "../src/utils/secrets" + +type Args = { + tenantId?: number + fromNumber?: string + toNumber?: string + fromId?: number + toId?: number + types: string[] + execute: boolean + unlinkRelated: boolean +} + +type TargetDocument = { + id: number + documentNumber: string | null + type: string + title: string | null + state: string +} + +const DEFAULT_TYPES = ["invoices"] + +function printUsage() { + console.log(` +Löscht einen Bereich von Ausgangsbelegen und deren direkt verknüpfte DB-Dokumente. + +Standardmäßig läuft das Skript als Dry-Run. Erst --execute löscht wirklich. + +Beispiele: + npm run invoices:delete-range -- --tenant 41 --from RE-2026-001 --to RE-2026-010 + npm run invoices:delete-range -- --tenant 41 --from-id 1200 --to-id 1210 --execute + npm run invoices:delete-range -- --tenant 41 --from RE-2026-001 --to RE-2026-010 --type invoices --type advanceInvoices --execute + +Optionen: + --tenant Pflicht: Tenant-ID + --from Erste Rechnungsnummer im Bereich + --to Letzte Rechnungsnummer im Bereich + --from-id Erste Beleg-ID im Bereich + --to-id Letzte Beleg-ID im Bereich + --type Belegtyp, mehrfach möglich. Standard: invoices + --execute Wirklich löschen. Ohne diese Option nur Vorschau. + --unlink-related Externe Belege, die auf Zielbelege zeigen, werden entkoppelt. + --help Hilfe anzeigen +`) +} + +function readValue(args: string[], index: number, flag: string) { + const value = args[index + 1] + if (!value || value.startsWith("--")) { + throw new Error(`Für ${flag} fehlt ein Wert.`) + } + return value +} + +function readNumber(args: string[], index: number, flag: string) { + const value = Number(readValue(args, index, flag)) + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${flag} muss eine positive Ganzzahl sein.`) + } + return value +} + +function parseArgs(argv: string[]): Args { + const parsed: Args = { + types: [], + execute: false, + unlinkRelated: false, + } + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + + if (arg === "--help" || arg === "-h") { + printUsage() + process.exit(0) + } + + if (arg === "--tenant") { + parsed.tenantId = readNumber(argv, i, arg) + i += 1 + continue + } + + if (arg === "--from") { + parsed.fromNumber = readValue(argv, i, arg) + i += 1 + continue + } + + if (arg === "--to") { + parsed.toNumber = readValue(argv, i, arg) + i += 1 + continue + } + + if (arg === "--from-id") { + parsed.fromId = readNumber(argv, i, arg) + i += 1 + continue + } + + if (arg === "--to-id") { + parsed.toId = readNumber(argv, i, arg) + i += 1 + continue + } + + if (arg === "--type") { + parsed.types.push(readValue(argv, i, arg)) + i += 1 + continue + } + + if (arg === "--execute") { + parsed.execute = true + continue + } + + if (arg === "--unlink-related") { + parsed.unlinkRelated = true + continue + } + + throw new Error(`Unbekannte Option: ${arg}`) + } + + if (!parsed.tenantId) throw new Error("--tenant ist Pflicht.") + + const hasNumberRange = parsed.fromNumber !== undefined || parsed.toNumber !== undefined + const hasIdRange = parsed.fromId !== undefined || parsed.toId !== undefined + + if (hasNumberRange && hasIdRange) { + throw new Error("Bitte entweder Rechnungsnummernbereich oder ID-Bereich verwenden, nicht beides.") + } + + if (hasNumberRange && (!parsed.fromNumber || !parsed.toNumber)) { + throw new Error("Für einen Rechnungsnummernbereich sind --from und --to Pflicht.") + } + + if (hasIdRange && (!parsed.fromId || !parsed.toId)) { + throw new Error("Für einen ID-Bereich sind --from-id und --to-id Pflicht.") + } + + if (!hasNumberRange && !hasIdRange) { + throw new Error("Bitte einen Bereich mit --from/--to oder --from-id/--to-id angeben.") + } + + if (parsed.fromNumber && parsed.toNumber && parsed.fromNumber > parsed.toNumber) { + throw new Error("--from darf bei Textvergleich nicht größer als --to sein.") + } + + if (parsed.fromId && parsed.toId && parsed.fromId > parsed.toId) { + throw new Error("--from-id darf nicht größer als --to-id sein.") + } + + if (parsed.types.length === 0) parsed.types = DEFAULT_TYPES + + return parsed +} + +async function loadConnectionString() { + if (process.env.DATABASE_URL) return process.env.DATABASE_URL + + await loadSecrets() + if (secrets.DATABASE_URL) return secrets.DATABASE_URL + + throw new Error("DATABASE_URL ist nicht konfiguriert.") +} + +function buildTargetWhere(args: Args, startParam = 1) { + const values: unknown[] = [args.tenantId, args.types] + const clauses = [ + `"tenant" = $${startParam}`, + `"type" = ANY($${startParam + 1}::text[])`, + ] + + if (args.fromNumber && args.toNumber) { + values.push(args.fromNumber, args.toNumber) + clauses.push(`"documentNumber" >= $${startParam + 2}`) + clauses.push(`"documentNumber" <= $${startParam + 3}`) + } else { + values.push(args.fromId, args.toId) + clauses.push(`"id" >= $${startParam + 2}`) + clauses.push(`"id" <= $${startParam + 3}`) + } + + return { + values, + whereSql: clauses.join(" AND "), + } +} + +async function selectTargetDocuments(client: PoolClient, args: Args) { + const target = buildTargetWhere(args) + const result = await client.query( + ` + SELECT "id", "documentNumber", "type", "title", "state" + FROM "createddocuments" + WHERE ${target.whereSql} + ORDER BY "id" + `, + target.values + ) + + return result.rows +} + +async function countByTarget(client: PoolClient, table: string, column: string, targetIds: number[]) { + const result = await client.query<{ count: string }>( + `SELECT COUNT(*)::int AS "count" FROM "${table}" WHERE "${column}" = ANY($1::bigint[])`, + [targetIds] + ) + + return Number(result.rows[0]?.count || 0) +} + +async function findExternalLinkedDocuments(client: PoolClient, targetIds: number[]) { + const result = await client.query( + ` + SELECT "id", "documentNumber", "type", "title", "state" + FROM "createddocuments" + WHERE "linkedDocument" = ANY($1::bigint[]) + AND NOT ("id" = ANY($1::bigint[])) + ORDER BY "id" + `, + [targetIds] + ) + + return result.rows +} + +async function deleteByTarget(client: PoolClient, table: string, column: string, targetIds: number[]) { + const result = await client.query( + `DELETE FROM "${table}" WHERE "${column}" = ANY($1::bigint[])`, + [targetIds] + ) + + return result.rowCount || 0 +} + +function printTargets(targets: TargetDocument[]) { + console.table( + targets.map((item) => ({ + id: item.id, + nummer: item.documentNumber, + typ: item.type, + status: item.state, + titel: item.title, + })) + ) +} + +async function run() { + const args = parseArgs(process.argv.slice(2)) + const connectionString = await loadConnectionString() + const pool = new Pool({ connectionString, max: 1 }) + const client = await pool.connect() + + try { + await client.query("BEGIN") + + const targets = await selectTargetDocuments(client, args) + if (targets.length === 0) { + console.log("Keine passenden Rechnungen gefunden.") + await client.query("ROLLBACK") + return + } + + const targetIds = targets.map((item) => item.id) + const [fileCount, historyCount, allocationCount, externalLinks] = await Promise.all([ + countByTarget(client, "files", "createddocument", targetIds), + countByTarget(client, "historyitems", "createddocument", targetIds), + countByTarget(client, "statementallocations", "cd_id", targetIds), + findExternalLinkedDocuments(client, targetIds), + ]) + + console.log(`Tenant: ${args.tenantId}`) + console.log(`Belegtypen: ${args.types.join(", ")}`) + console.log(`Modus: ${args.execute ? "Löschen" : "Dry-Run"}`) + console.log("") + printTargets(targets) + console.log("") + console.log("Betroffene verknüpfte DB-Zeilen:") + console.log(`- Dateien: ${fileCount}`) + console.log(`- Historie: ${historyCount}`) + console.log(`- Bank-Zuordnungen: ${allocationCount}`) + + if (externalLinks.length > 0 && !args.unlinkRelated) { + console.log("") + console.log("Abbruch: Andere Belege verweisen auf Zielbelege.") + console.log("Nutze --unlink-related, wenn diese Verknüpfungen vor dem Löschen entfernt werden sollen.") + printTargets(externalLinks) + await client.query("ROLLBACK") + process.exitCode = 1 + return + } + + if (!args.execute) { + console.log("") + console.log("Dry-Run abgeschlossen. Es wurde nichts gelöscht. Zum Löschen --execute ergänzen.") + await client.query("ROLLBACK") + return + } + + if (externalLinks.length > 0) { + await client.query( + ` + UPDATE "createddocuments" + SET "linkedDocument" = NULL + WHERE "linkedDocument" = ANY($1::bigint[]) + AND NOT ("id" = ANY($1::bigint[])) + `, + [targetIds] + ) + } + + await client.query( + `UPDATE "createddocuments" SET "linkedDocument" = NULL WHERE "id" = ANY($1::bigint[])`, + [targetIds] + ) + + const deletedFiles = await deleteByTarget(client, "files", "createddocument", targetIds) + const deletedHistory = await deleteByTarget(client, "historyitems", "createddocument", targetIds) + const deletedAllocations = await deleteByTarget(client, "statementallocations", "cd_id", targetIds) + + const deletedDocuments = await client.query( + `DELETE FROM "createddocuments" WHERE "id" = ANY($1::bigint[])`, + [targetIds] + ) + + await client.query("COMMIT") + + console.log("") + console.log("Löschung abgeschlossen:") + console.log(`- Rechnungen: ${deletedDocuments.rowCount || 0}`) + console.log(`- Dateien: ${deletedFiles}`) + console.log(`- Historie: ${deletedHistory}`) + console.log(`- Bank-Zuordnungen: ${deletedAllocations}`) + if (externalLinks.length > 0) { + console.log(`- Entkoppelte externe Belege: ${externalLinks.length}`) + } + } catch (error) { + await client.query("ROLLBACK") + throw error + } finally { + client.release() + await pool.end() + } +} + +run().catch((error) => { + console.error("Fehler beim Löschen der Rechnungen:") + console.error(error) + process.exit(1) +})