KI-AGENT: Skript zum Löschen von Rechnungsbereichen ergänzen

This commit is contained in:
2026-07-03 20:56:15 +02:00
parent 3b3a879b02
commit d67da7e02a
2 changed files with 360 additions and 0 deletions

View File

@@ -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"

View File

@@ -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 <id> Pflicht: Tenant-ID
--from <nummer> Erste Rechnungsnummer im Bereich
--to <nummer> Letzte Rechnungsnummer im Bereich
--from-id <id> Erste Beleg-ID im Bereich
--to-id <id> Letzte Beleg-ID im Bereich
--type <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<TargetDocument>(
`
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<TargetDocument>(
`
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)
})