271 lines
8.8 KiB
TypeScript
271 lines
8.8 KiB
TypeScript
import fs from "node:fs"
|
|
import path from "node:path"
|
|
import { and, eq } from "drizzle-orm"
|
|
|
|
import { db, pool } from "../db"
|
|
import { customers, entitybankaccounts } from "../db/schema"
|
|
import { decrypt, encrypt } from "../src/utils/crypt"
|
|
import { loadSecrets, secrets } from "../src/utils/secrets"
|
|
|
|
type CsvMemberRow = {
|
|
number: string
|
|
lastname: string
|
|
firstname: string
|
|
street: string
|
|
zip: string
|
|
city: string
|
|
birthdate: string
|
|
mobile: string
|
|
email: string
|
|
bankInstitute: string
|
|
iban: string
|
|
bic: string
|
|
date: string
|
|
memberStatus: string
|
|
}
|
|
|
|
const TENANT_ID = 38
|
|
const DEFAULT_CSV_PATH = "/Users/florianfederspiel/Downloads/Mitglieder Übersicht 2026_1.csv"
|
|
|
|
const args = process.argv.slice(2)
|
|
const dryRun = args.includes("--dry-run")
|
|
const csvArg = args.find((arg) => !arg.startsWith("--"))
|
|
const csvPath = csvArg || DEFAULT_CSV_PATH
|
|
|
|
function normalizeIban(value: string) {
|
|
return String(value || "").replace(/\s+/g, "").toUpperCase()
|
|
}
|
|
|
|
function parseGermanDate(value: string): string | null {
|
|
const v = String(value || "").trim()
|
|
if (!v) return null
|
|
|
|
const m = v.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/)
|
|
if (!m) return null
|
|
|
|
const day = m[1].padStart(2, "0")
|
|
const month = m[2].padStart(2, "0")
|
|
const yy = m[3]
|
|
const year = yy.length === 4 ? yy : (Number(yy) >= 70 ? `19${yy}` : `20${yy}`)
|
|
|
|
return `${year}-${month}-${day}`
|
|
}
|
|
|
|
function parseBoolFromStatus(value: string) {
|
|
const normalized = String(value || "").trim().toLowerCase()
|
|
return normalized !== "inaktiv"
|
|
}
|
|
|
|
function parseCsv(content: string): CsvMemberRow[] {
|
|
const lines = content
|
|
.split(/\r?\n/)
|
|
.map((l) => l.trim())
|
|
.filter((l) => l.length > 0)
|
|
|
|
if (!lines.length) return []
|
|
|
|
// Header:
|
|
// Nr;Name;Vorname;Straße, Hausnr.;PLZ;Wohnort;Geburtsdatum;Mobilfunknummer;Private Mail-Adresse;Kreditinstitut;IBAN;BIC;Datum;Mitgliedsstatus
|
|
const rows: CsvMemberRow[] = []
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const cols = lines[i].split(";").map((v) => v.trim())
|
|
if (cols.length < 14) continue
|
|
|
|
const number = cols[0]
|
|
const lastname = cols[1]
|
|
const firstname = cols[2]
|
|
if (!number || !lastname || !firstname) continue
|
|
|
|
rows.push({
|
|
number,
|
|
lastname,
|
|
firstname,
|
|
street: cols[3] || "",
|
|
zip: cols[4] || "",
|
|
city: cols[5] || "",
|
|
birthdate: cols[6] || "",
|
|
mobile: cols[7] || "",
|
|
email: cols[8] || "",
|
|
bankInstitute: cols[9] || "",
|
|
iban: cols[10] || "",
|
|
bic: cols[11] || "",
|
|
date: cols[12] || "",
|
|
memberStatus: cols[13] || "",
|
|
})
|
|
}
|
|
|
|
return rows
|
|
}
|
|
|
|
async function loadBankAccountByIban(tenantId: number) {
|
|
const rows = await db
|
|
.select({
|
|
id: entitybankaccounts.id,
|
|
ibanEncrypted: entitybankaccounts.ibanEncrypted,
|
|
})
|
|
.from(entitybankaccounts)
|
|
.where(eq(entitybankaccounts.tenant, tenantId))
|
|
|
|
const map = new Map<string, number>()
|
|
for (const row of rows) {
|
|
try {
|
|
const iban = normalizeIban(decrypt(row.ibanEncrypted as any))
|
|
if (iban) map.set(iban, Number(row.id))
|
|
} catch {
|
|
// skip broken ciphertext rows
|
|
}
|
|
}
|
|
return map
|
|
}
|
|
|
|
async function main() {
|
|
if (!secrets.ENCRYPTION_KEY && process.env.ENCRYPTION_KEY) {
|
|
secrets.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY
|
|
}
|
|
|
|
if (!secrets.ENCRYPTION_KEY && process.env.INFISICAL_CLIENT_ID && process.env.INFISICAL_CLIENT_SECRET) {
|
|
await loadSecrets()
|
|
}
|
|
|
|
if (!secrets.ENCRYPTION_KEY) {
|
|
throw new Error("ENCRYPTION_KEY fehlt. Bitte ENCRYPTION_KEY setzen oder Infisical-Zugang (INFISICAL_CLIENT_ID/INFISICAL_CLIENT_SECRET) bereitstellen.")
|
|
}
|
|
|
|
const absoluteCsvPath = path.resolve(csvPath)
|
|
if (!fs.existsSync(absoluteCsvPath)) {
|
|
throw new Error(`CSV nicht gefunden: ${absoluteCsvPath}`)
|
|
}
|
|
|
|
const raw = fs.readFileSync(absoluteCsvPath, "utf8")
|
|
const csvRows = parseCsv(raw)
|
|
if (!csvRows.length) {
|
|
throw new Error("Keine importierbaren Zeilen gefunden.")
|
|
}
|
|
|
|
const existingMembers = await db
|
|
.select()
|
|
.from(customers)
|
|
.where(and(eq(customers.tenant, TENANT_ID), eq(customers.type, "Mitglied")))
|
|
|
|
const memberByNumber = new Map(existingMembers.map((m) => [String(m.customerNumber), m]))
|
|
const bankAccountByIban = await loadBankAccountByIban(TENANT_ID)
|
|
|
|
let createdMembers = 0
|
|
let updatedMembers = 0
|
|
let createdBankAccounts = 0
|
|
let skippedNoIban = 0
|
|
|
|
for (const row of csvRows) {
|
|
const iban = normalizeIban(row.iban)
|
|
if (!iban) {
|
|
skippedNoIban += 1
|
|
continue
|
|
}
|
|
|
|
|
|
const fullName = `${row.firstname} ${row.lastname}`.trim()
|
|
const birthdate = parseGermanDate(row.birthdate)
|
|
const sepaSignedAt = parseGermanDate(row.date)
|
|
const active = parseBoolFromStatus(row.memberStatus)
|
|
|
|
let bankAccountId = bankAccountByIban.get(iban) || null
|
|
|
|
if (!bankAccountId) {
|
|
if (!dryRun) {
|
|
const [created] = await db
|
|
.insert(entitybankaccounts)
|
|
.values({
|
|
tenant: TENANT_ID,
|
|
ibanEncrypted: encrypt(iban),
|
|
bicEncrypted: encrypt(row.bic || "UNBEKANNT"),
|
|
bankNameEncrypted: encrypt(row.bankInstitute || "Unbekannt"),
|
|
description: "Import Mitglieder Uebersicht 2026_1",
|
|
})
|
|
.returning({ id: entitybankaccounts.id })
|
|
bankAccountId = created?.id || null
|
|
} else {
|
|
bankAccountId = -1
|
|
}
|
|
if (bankAccountId) {
|
|
bankAccountByIban.set(iban, bankAccountId)
|
|
createdBankAccounts += 1
|
|
}
|
|
}
|
|
|
|
const existing = memberByNumber.get(String(row.number))
|
|
const existingInfo = (existing?.infoData && typeof existing.infoData === "object")
|
|
? { ...(existing.infoData as Record<string, any>) }
|
|
: {}
|
|
|
|
const existingIds = Array.isArray(existingInfo.bankAccountIds) ? existingInfo.bankAccountIds : []
|
|
const mergedBankAccountIds = bankAccountId && !existingIds.includes(bankAccountId)
|
|
? [...existingIds, bankAccountId]
|
|
: existingIds
|
|
|
|
const infoData = {
|
|
...existingInfo,
|
|
street: row.street || existingInfo.street || "",
|
|
zip: row.zip || existingInfo.zip || "",
|
|
city: row.city || existingInfo.city || "",
|
|
phone: row.mobile || existingInfo.phone || "",
|
|
email: row.email || existingInfo.email || "",
|
|
birthdate: birthdate || existingInfo.birthdate || null,
|
|
hasSEPA: Boolean(sepaSignedAt || existingInfo.sepaSignedAt || existingInfo.hasSEPA),
|
|
sepaSignedAt: sepaSignedAt || existingInfo.sepaSignedAt || null,
|
|
bankAccountIds: mergedBankAccountIds,
|
|
}
|
|
|
|
const payload = {
|
|
tenant: TENANT_ID,
|
|
customerNumber: String(row.number),
|
|
type: "Mitglied",
|
|
isCompany: false,
|
|
firstname: row.firstname,
|
|
lastname: row.lastname,
|
|
name: fullName,
|
|
active,
|
|
infoData,
|
|
archived: false,
|
|
}
|
|
|
|
if (!existing) {
|
|
if (!dryRun) {
|
|
const [created] = await db.insert(customers).values(payload).returning()
|
|
if (created) memberByNumber.set(String(row.number), created)
|
|
}
|
|
createdMembers += 1
|
|
} else {
|
|
if (!dryRun) {
|
|
await db
|
|
.update(customers)
|
|
.set({
|
|
...payload,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(eq(customers.id, existing.id), eq(customers.tenant, TENANT_ID)))
|
|
}
|
|
updatedMembers += 1
|
|
}
|
|
}
|
|
|
|
console.log("")
|
|
console.log(`[IMPORT MEMBERS] Tenant: ${TENANT_ID}`)
|
|
console.log(`[IMPORT MEMBERS] CSV: ${absoluteCsvPath}`)
|
|
console.log(`[IMPORT MEMBERS] Dry-Run: ${dryRun ? "JA" : "NEIN"}`)
|
|
console.log(`[IMPORT MEMBERS] Zeilen: ${csvRows.length}`)
|
|
console.log(`[IMPORT MEMBERS] Mitglieder erstellt: ${createdMembers}`)
|
|
console.log(`[IMPORT MEMBERS] Mitglieder aktualisiert: ${updatedMembers}`)
|
|
console.log(`[IMPORT MEMBERS] Bankkonten erstellt: ${createdBankAccounts}`)
|
|
console.log(`[IMPORT MEMBERS] Ohne IBAN übersprungen: ${skippedNoIban}`)
|
|
console.log("")
|
|
}
|
|
|
|
main()
|
|
.catch((err) => {
|
|
console.error("[IMPORT MEMBERS] Fehler:", err)
|
|
process.exitCode = 1
|
|
})
|
|
.finally(async () => {
|
|
await pool.end()
|
|
})
|