Füge Importskript für Mitarbeiterlisten mit Tenant- und Niederlassungszuordnung hinzu

This commit is contained in:
2026-04-28 14:06:09 +02:00
parent 20818beb3a
commit e9bbc196f7
2 changed files with 471 additions and 0 deletions

View File

@@ -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<string, number>
}
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<string, number> {
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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&apos;/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(/<si\b[^>]*>([\s\S]*?)<\/si>/g)].map((match) => {
const parts = [...match[1].matchAll(/<t\b[^>]*>([\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<string, string>[] = []
for (const rowMatch of sheetXml.matchAll(/<row\b[^>]*r="(\d+)"[^>]*>([\s\S]*?)<\/row>/g)) {
const cellMap: Record<string, string> = {}
const rowXml = rowMatch[2]
for (const cellMatch of rowXml.matchAll(/<c\b([^>]*)>([\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(/<v>([\s\S]*?)<\/v>/)
const inlineTextMatch = cellXml.match(/<t\b[^>]*>([\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<string, any>()
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<string>()
const seenImportNames = new Set<string>()
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<string>()
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
})