diff --git a/backend/package.json b/backend/package.json index 55b5409..1fa4d0f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "schema:index": "ts-node scripts/generate-schema-index.ts", "bankcodes:update": "tsx scripts/generate-de-bank-codes.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" }, "repository": { diff --git a/backend/scripts/import-mitarbeiterliste.ts b/backend/scripts/import-mitarbeiterliste.ts new file mode 100644 index 0000000..f64573c --- /dev/null +++ b/backend/scripts/import-mitarbeiterliste.ts @@ -0,0 +1,470 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { execFileSync } from "node:child_process" +import { and, eq } from "drizzle-orm" + +import { + authProfileBranches, + authProfiles, + branches, + tenants, +} from "../db/schema" + +type ImportRow = { + rowNumber: number + mitarbeiter: string + betrieb: string + anstellung: string + position: string + bereich: string + stundenMonat: number | null + urlaub: number | null +} + +type CliOptions = { + workbookPath: string + tenantId: number + dryRun: boolean + defaultBranchId: number | null + branchMap: Record +} + +function printHelp() { + console.log(` +Importiert die Excel-Datei "Mitarbeiterliste.xlsm" in auth_profiles. + +Verwendung: + npm run profiles:import:mitarbeiterliste -- --tenant-id=12 --branch-map-file=./branch-map.json /pfad/zur/Mitarbeiterliste.xlsm + +Optionen: + --tenant-id=ID Pflicht. Tenant-ID für den Import. + --branch-map='{"Name":1}' Optional. JSON-Mapping Betrieb -> branchId. + --branch-map-file=DATEI Optional. JSON-Datei mit Betrieb -> branchId. + --default-branch-id=ID Optional. Fallback-Branch-ID für nicht gemappte Betriebe. + --dry-run Führt keine Schreiboperationen aus. + --help Zeigt diese Hilfe an. + +Beispiel branch-map.json: +{ + "Strandcafé": 10, + "1848 Pütt": 11, + "Oceans11": 12, + "Winnys": 13 +} +`.trim()) +} + +function parseArgs(argv: string[]): CliOptions | null { + const options: CliOptions = { + workbookPath: "/Users/florianfederspiel/Downloads/Mitarbeiterliste.xlsm", + tenantId: Number.NaN, + dryRun: false, + defaultBranchId: null, + branchMap: {}, + } + + for (const arg of argv) { + if (arg === "--help" || arg === "-h") { + return null + } + + if (arg === "--dry-run") { + options.dryRun = true + continue + } + + if (arg.startsWith("--tenant-id=")) { + options.tenantId = Number(arg.slice("--tenant-id=".length)) + continue + } + + if (arg.startsWith("--default-branch-id=")) { + options.defaultBranchId = Number(arg.slice("--default-branch-id=".length)) + continue + } + + if (arg.startsWith("--branch-map=")) { + options.branchMap = parseBranchMap(arg.slice("--branch-map=".length), "CLI") + continue + } + + if (arg.startsWith("--branch-map-file=")) { + const branchMapPath = path.resolve(arg.slice("--branch-map-file=".length)) + options.branchMap = parseBranchMap(fs.readFileSync(branchMapPath, "utf8"), branchMapPath) + continue + } + + if (!arg.startsWith("--")) { + options.workbookPath = path.resolve(arg) + continue + } + + throw new Error(`Unbekanntes Argument: ${arg}`) + } + + if (!Number.isFinite(options.tenantId)) { + throw new Error("Bitte --tenant-id=... angeben.") + } + + if (options.defaultBranchId != null && !Number.isFinite(options.defaultBranchId)) { + throw new Error("--default-branch-id muss numerisch sein.") + } + + return options +} + +function parseBranchMap(raw: string, sourceLabel: string): Record { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (error) { + throw new Error(`Branch-Mapping aus ${sourceLabel} konnte nicht gelesen werden: ${(error as Error).message}`) + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Branch-Mapping aus ${sourceLabel} muss ein JSON-Objekt sein.`) + } + + const normalizedEntries = Object.entries(parsed).map(([key, value]) => { + const branchId = Number(value) + if (!Number.isFinite(branchId)) { + throw new Error(`Ungültige Branch-ID für "${key}" in ${sourceLabel}.`) + } + return [normalizeKey(key), branchId] as const + }) + + return Object.fromEntries(normalizedEntries) +} + +function normalizeKey(value: string) { + return String(value || "") + .trim() + .replace(/\s+/g, " ") + .toLocaleLowerCase("de-DE") +} + +function decodeXmlText(value: string) { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, "\"") + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code))) +} + +function getWorkbookXml(workbookPath: string, innerPath: string) { + return execFileSync("unzip", ["-p", workbookPath, innerPath], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }) +} + +function readSharedStrings(workbookPath: string) { + const xml = getWorkbookXml(workbookPath, "xl/sharedStrings.xml") + return [...xml.matchAll(/]*>([\s\S]*?)<\/si>/g)].map((match) => { + const parts = [...match[1].matchAll(/]*>([\s\S]*?)<\/t>/g)].map((part) => decodeXmlText(part[1])) + return parts.join("") + }) +} + +function readSheetRows(workbookPath: string, sharedStrings: string[]) { + const sheetXml = getWorkbookXml(workbookPath, "xl/worksheets/sheet1.xml") + const rows: Record[] = [] + + for (const rowMatch of sheetXml.matchAll(/]*r="(\d+)"[^>]*>([\s\S]*?)<\/row>/g)) { + const cellMap: Record = {} + const rowXml = rowMatch[2] + + for (const cellMatch of rowXml.matchAll(/]*)>([\s\S]*?)<\/c>/g)) { + const attrs = cellMatch[1] + const cellXml = cellMatch[2] + const refMatch = attrs.match(/r="([A-Z]+)\d+"/) + if (!refMatch) continue + + const column = refMatch[1] + const typeMatch = attrs.match(/t="([^"]+)"/) + const type = typeMatch?.[1] || "" + const valueMatch = cellXml.match(/([\s\S]*?)<\/v>/) + const inlineTextMatch = cellXml.match(/]*>([\s\S]*?)<\/t>/) + + let value = "" + if (type === "s" && valueMatch) { + value = sharedStrings[Number(valueMatch[1])] || "" + } else if (inlineTextMatch) { + value = decodeXmlText(inlineTextMatch[1]) + } else if (valueMatch) { + value = decodeXmlText(valueMatch[1]) + } + + cellMap[column] = value.trim() + } + + rows.push(cellMap) + } + + return rows +} + +function parseNumber(value: string) { + const normalized = String(value || "").trim().replace(",", ".") + if (!normalized) return null + + const parsed = Number(normalized) + return Number.isFinite(parsed) ? parsed : null +} + +function parseWorkbook(workbookPath: string): ImportRow[] { + const sharedStrings = readSharedStrings(workbookPath) + const rows = readSheetRows(workbookPath, sharedStrings) + + if (!rows.length) { + throw new Error("Die Arbeitsmappe enthält keine Zeilen.") + } + + const header = rows[0] + if (header.A !== "Mitarbeiter" || header.B !== "Betrieb") { + throw new Error("Unerwartetes Format der Excel-Datei. Erwartet wurden die Spalten 'Mitarbeiter' und 'Betrieb'.") + } + + return rows + .slice(1) + .map((row, index) => ({ + rowNumber: index + 2, + mitarbeiter: row.A || "", + betrieb: row.B || "", + anstellung: row.C || "", + position: row.D || "", + bereich: row.E || "", + stundenMonat: parseNumber(row.F || ""), + urlaub: parseNumber(row.G || ""), + })) + .filter((row) => row.mitarbeiter) +} + +function splitFullName(fullName: string) { + const parts = fullName.trim().split(/\s+/).filter(Boolean) + if (parts.length <= 1) { + return { + firstName: fullName.trim(), + lastName: "Unbekannt", + } + } + + return { + firstName: parts.slice(0, -1).join(" "), + lastName: parts[parts.length - 1], + } +} + +function toWeeklyHours(monthlyHours: number | null) { + if (monthlyHours == null) return null + return Math.round(((monthlyHours * 12) / 52) * 100) / 100 +} + +async function validateTenantAndBranches( + db: any, + tenantId: number, + branchIds: number[] +) { + const [tenant] = await db + .select({ id: tenants.id, name: tenants.name }) + .from(tenants) + .where(eq(tenants.id, tenantId)) + .limit(1) + + if (!tenant) { + throw new Error(`Tenant ${tenantId} wurde nicht gefunden.`) + } + + const branchRows = await db + .select({ id: branches.id, name: branches.name }) + .from(branches) + .where(eq(branches.tenant, tenantId)) + + const validBranchIds = new Set(branchRows.map((branch: any) => Number(branch.id))) + for (const branchId of branchIds) { + if (!validBranchIds.has(branchId)) { + throw new Error(`Branch-ID ${branchId} gehört nicht zum Tenant ${tenantId}.`) + } + } + + return { + tenant, + branchRows, + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)) + if (!options) { + printHelp() + return + } + + if (!fs.existsSync(options.workbookPath)) { + throw new Error(`Excel-Datei nicht gefunden: ${options.workbookPath}`) + } + + const rows = parseWorkbook(options.workbookPath) + if (!rows.length) { + throw new Error("Keine importierbaren Mitarbeiter gefunden.") + } + + const mappedBranchIds = [ + ...new Set( + Object.values(options.branchMap) + .concat(options.defaultBranchId != null ? [options.defaultBranchId] : []) + ), + ] + + const { db, pool } = await import("../db") + + try { + const { tenant } = await validateTenantAndBranches(db, options.tenantId, mappedBranchIds) + + const existingProfiles = await db + .select() + .from(authProfiles) + .where(eq(authProfiles.tenant_id, options.tenantId)) + + const existingByName = new Map() + for (const profile of existingProfiles) { + const key = normalizeKey(`${profile.first_name} ${profile.last_name}`) + if (existingByName.has(key)) { + throw new Error(`Mehrdeutiger bestehender Mitarbeiter: ${profile.first_name} ${profile.last_name}`) + } + existingByName.set(key, profile) + } + + const duplicateImportNames = new Set() + const seenImportNames = new Set() + for (const row of rows) { + const key = normalizeKey(row.mitarbeiter) + if (seenImportNames.has(key)) duplicateImportNames.add(row.mitarbeiter) + seenImportNames.add(key) + } + + if (duplicateImportNames.size) { + throw new Error(`Die Excel-Datei enthält doppelte Mitarbeiternamen: ${[...duplicateImportNames].join(", ")}`) + } + + const missingBranchMappings = new Set() + const preparedRows = rows.map((row) => { + const branchKey = normalizeKey(row.betrieb) + const branchId = options.branchMap[branchKey] ?? options.defaultBranchId ?? null + if (!branchId) { + missingBranchMappings.add(row.betrieb || `(Zeile ${row.rowNumber})`) + } + + return { + ...row, + branchId, + weeklyHours: toWeeklyHours(row.stundenMonat), + } + }) + + if (missingBranchMappings.size) { + throw new Error( + `Für folgende Betriebe fehlt eine Branch-ID: ${[...missingBranchMappings].join(", ")}` + ) + } + + let createdProfiles = 0 + let updatedProfiles = 0 + + for (const row of preparedRows) { + const nameParts = splitFullName(row.mitarbeiter) + const nameKey = normalizeKey(row.mitarbeiter) + const existing = existingByName.get(nameKey) + + const tempConfig = { + ...((existing?.temp_config && typeof existing.temp_config === "object") ? existing.temp_config : {}), + mitarbeiterImport: { + betrieb: row.betrieb, + bereich: row.bereich, + stundenMonat: row.stundenMonat, + urlaub: row.urlaub, + quelle: path.basename(options.workbookPath), + importiertAm: new Date().toISOString(), + }, + } + + const payload = { + tenant_id: options.tenantId, + branch_id: row.branchId, + first_name: nameParts.firstName, + last_name: nameParts.lastName, + contract_type: row.anstellung || null, + position: row.position || null, + qualification: row.bereich || null, + weekly_working_hours: row.weeklyHours ?? existing?.weekly_working_hours ?? 0, + annual_paid_leave_days: row.urlaub != null ? Math.round(row.urlaub) : existing?.annual_paid_leave_days ?? null, + temp_config: tempConfig, + active: existing?.active ?? true, + } + + if (!existing) { + if (!options.dryRun) { + const [created] = await db + .insert(authProfiles) + .values(payload) + .returning() + + if (!created) { + throw new Error(`Profil für "${row.mitarbeiter}" konnte nicht erstellt werden.`) + } + + await db.insert(authProfileBranches).values({ + profile_id: created.id, + branch_id: row.branchId, + }) + + existingByName.set(nameKey, created) + } + + createdProfiles += 1 + continue + } + + if (!options.dryRun) { + await db + .update(authProfiles) + .set(payload) + .where( + and( + eq(authProfiles.id, existing.id), + eq(authProfiles.tenant_id, options.tenantId) + ) + ) + + await db + .delete(authProfileBranches) + .where(eq(authProfileBranches.profile_id, existing.id)) + + await db.insert(authProfileBranches).values({ + profile_id: existing.id, + branch_id: row.branchId, + }) + } + + updatedProfiles += 1 + } + + console.log("") + console.log(`[IMPORT MITARBEITER] Tenant: ${tenant.id} (${tenant.name})`) + console.log(`[IMPORT MITARBEITER] Datei: ${options.workbookPath}`) + console.log(`[IMPORT MITARBEITER] Dry-Run: ${options.dryRun ? "JA" : "NEIN"}`) + console.log(`[IMPORT MITARBEITER] Zeilen gelesen: ${rows.length}`) + console.log(`[IMPORT MITARBEITER] Profile erstellt: ${createdProfiles}`) + console.log(`[IMPORT MITARBEITER] Profile aktualisiert: ${updatedProfiles}`) + console.log("") + } finally { + await pool.end() + } +} + +main().catch((error) => { + console.error("[IMPORT MITARBEITER] Fehler:", error) + process.exitCode = 1 +})