Füge Importskript für Mitarbeiterlisten mit Tenant- und Niederlassungszuordnung hinzu
This commit is contained in:
@@ -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": {
|
||||
|
||||
470
backend/scripts/import-mitarbeiterliste.ts
Normal file
470
backend/scripts/import-mitarbeiterliste.ts
Normal 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(/&/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(/<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
|
||||
})
|
||||
Reference in New Issue
Block a user