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) })