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",
|
"schema:index": "ts-node scripts/generate-schema-index.ts",
|
||||||
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
||||||
"members:import:csv": "tsx scripts/import-members-csv.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"
|
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
|
||||||
},
|
},
|
||||||
"repository": {
|
"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