KetteReports/Export/Personio Exporte für Arbeitszeit und Abwesenheiten neu

SupportOID=167814
This commit is contained in:
2026-07-22 15:55:54 +02:00
parent 6abefef781
commit 08a4ba959b
5 changed files with 304 additions and 1 deletions

View File

@@ -24,7 +24,15 @@ namespace KetteReports.Export
public override QueryDC CreateQuery(QueryDC query)
{
QueryDC result = null;
if (query.Oid == 1004)
if (query.Title != null && query.Title.Contains("Personio") && query.Title.Contains("Abwesenheit"))
{
result = PersonioAbwesenheitExporter.CreateQuery(query);
}
else if (query.Title != null && query.Title.Contains("Personio") && query.Title.Contains("Anwesenheit"))
{
result = PersonioAnwesenheitExporter.CreateQuery(query);
}
else if (query.Oid == 1004)
{
result = DatevExporter.CreateDifferenzQuery(query);
}

View File

@@ -0,0 +1,99 @@
using System;
using System.Linq;
using System.Text;
using BeWo.Data.Access;
using BS.Shared.DataContracts;
namespace KetteReports.Export
{
/// Exportiert die Mitarbeiter-Abwesenheiten im Personio-Importformat "Abwesenheitsperioden" als CSV (Trennzeichen ";").
public class PersonioAbwesenheitExporter
{
private const string Header =
"E-Mail;Art der Abwesenheit;Startdatum;Enddatum;Startzeit;Endzeit;Halber Tag erster Tag;Halber Tag letzter Tag";
public static QueryDC CreateQuery(QueryDC query)
{
var monatStart = PersonioExportUtils.GetMonthStart(query);
var monatEnde = monatStart.AddMonths(1).AddDays(-1);
query.FileName = string.Format("Personio_Abwesenheitsperioden_{0:yyyyMM}.csv", monatStart);
query.QueryResult = BuildCsv(monatStart, monatEnde);
return query;
}
private static string BuildCsv(DateTime monatStart, DateTime monatEnde)
{
var identifierByEmployeeOid = PersonioExportUtils.BuildEmployeeIdentifierByOid();
var sb = new StringBuilder();
sb.Append(Header);
// FindAbsenceTimes liefert auch Abwesenheiten ohne Enddatum (End IS NULL) ohne untere
// Datumsgrenze -> zusätzlich auf echte Datumsüberlappung mit dem Monat filtern.
// Erst alle Einträge eines Mitarbeiters, innerhalb chronologisch nach Startzeitpunkt.
var absenceTimes = DAOFactory.SearchDAO.FindAbsenceTimes(monatStart, monatEnde)
.Where(a => a.EmployeeOid.HasValue && a.Start.HasValue
&& a.Start.Value.Date <= monatEnde.Date
&& (a.End ?? a.Start.Value).Date >= monatStart.Date)
.OrderBy(a => a.EmployeeOid.Value).ThenBy(a => a.Start.Value).ToList();
foreach (var absence in absenceTimes)
{
// Nur Mitarbeiter-Abwesenheiten (mit EmployeeOid), keine Klienten-Abwesenheiten.
if (!absence.EmployeeOid.HasValue || !absence.Start.HasValue)
{
continue;
}
string mitarbeiter;
identifierByEmployeeOid.TryGetValue(absence.EmployeeOid.Value, out mitarbeiter);
var start = absence.Start.Value;
var hatEnde = absence.End.HasValue;
var end = absence.End ?? start;
var artDerAbwesenheit = absence.AbsenceReason != null ? absence.AbsenceReason.Description : string.Empty;
// Eine gesetzte Uhrzeit (Zeitanteil != 00:00) kennzeichnet einen halben Tag:
// Startzeit -> erster Tag halb, Endzeit -> letzter Tag halb. Ohne Uhrzeit ganztägig.
var hatStartzeit = start.TimeOfDay != TimeSpan.Zero;
var hatEndzeit = hatEnde && end.TimeOfDay != TimeSpan.Zero;
var startzeit = hatStartzeit ? start.ToString("HH:mm") : string.Empty;
var endzeit = hatEndzeit ? end.ToString("HH:mm") : string.Empty;
// Halbe Tage nicht bei "Fortbildung" ausweisen. Bei eintägiger Abwesenheit nur "Halber Tag letzter Tag" setzen.
var istFortbildung = (artDerAbwesenheit ?? string.Empty).IndexOf("Fortbildung", StringComparison.OrdinalIgnoreCase) >= 0;
var istEintaegig = start.Date == end.Date;
var halberTagErster = string.Empty;
var halberTagLetzter = string.Empty;
if (!istFortbildung)
{
if (istEintaegig)
{
halberTagLetzter = (hatStartzeit || hatEndzeit) ? "1" : string.Empty;
}
else
{
halberTagErster = hatStartzeit ? "1" : string.Empty;
halberTagLetzter = hatEndzeit ? "1" : string.Empty;
}
}
sb.AppendLine();
sb.Append(PersonioExportUtils.BuildCsvLine(
mitarbeiter ?? string.Empty,
artDerAbwesenheit ?? string.Empty,
start.ToString("yyyy-MM-dd"),
end.ToString("yyyy-MM-dd"),
startzeit,
endzeit,
halberTagErster, // Halber Tag erster Tag
halberTagLetzter)); // Halber Tag letzter Tag
}
return sb.ToString();
}
}
}

View File

@@ -0,0 +1,86 @@
using System;
using System.Linq;
using System.Text;
using BeWo.Data.Access;
using BS.Shared.Core;
using BS.Shared.DataContracts;
namespace KetteReports.Export
{
/// Exportiert die geleisteten Arbeitszeiten im Personio-Importformat "Anwesenheitsperioden" als CSV (Trennzeichen ";").
/// Quelle: ServiceRecords der Leistungskategorie "Arbeitszeit". Innerhalb dieser Kategorie gibt es die Leistungen "Arbeitszeit" und "Pause";
/// jede Buchung wird als eigene Anwesenheitsperiode exportiert.
public class PersonioAnwesenheitExporter
{
private const string Kategorie = "Arbeitszeit";
private const string Header = "E-Mail;Start (z. B. 2024-04-01 09:00);Ende (z. B. 2024-04-01 18:00);Art der Periode;Kommentar;Projekt;Unterprojekt";
public static QueryDC CreateQuery(QueryDC query)
{
var monatStart = PersonioExportUtils.GetMonthStart(query);
query.FileName = string.Format("Personio_Anwesenheitsperioden_{0:yyyyMM}.csv", monatStart);
query.QueryResult = BuildCsv(monatStart);
return query;
}
private static string BuildCsv(DateTime monatStart)
{
// Oberes Ende exklusiv: der 2-arg-DateTimeSpan-Konstruktor lässt die Uhrzeit auf 00:00,
// FindServiceRecordsInSpan verwendet Between(Start, StartDateTime, EndDateTime).
var monatEndeExklusiv = monatStart.AddMonths(1);
var identifierByEmployeeOid = PersonioExportUtils.BuildEmployeeIdentifierByOid();
var sb = new StringBuilder();
sb.Append(Header);
// Erst alle Einträge eines Mitarbeiters, innerhalb chronologisch nach Startzeitpunkt.
var serviceRecords = DAOFactory.SearchDAO.FindServiceRecordsInSpan(new DateTimeSpan(monatStart, monatEndeExklusiv), null)
.Where(sr => sr.EmployeeOid.HasValue && sr.Start.HasValue && sr.End.HasValue)
.OrderBy(sr => sr.EmployeeOid.Value).ThenBy(sr => sr.Start.Value).ToList();
foreach (var serviceRecord in serviceRecords)
{
if (!serviceRecord.EmployeeOid.HasValue || !serviceRecord.Start.HasValue || !serviceRecord.End.HasValue)
{
continue;
}
var serviceDescription = serviceRecord.ServiceDescription;
if (serviceDescription == null || serviceDescription.ServiceCategory == null)
{
continue;
}
// Nur Leistungen der Kategorie "Arbeitszeit" berücksichtigen.
var kategorieName = (serviceDescription.ServiceCategory.Name ?? string.Empty).Trim();
if (!kategorieName.Equals(Kategorie, StringComparison.OrdinalIgnoreCase))
{
continue;
}
string mitarbeiter;
identifierByEmployeeOid.TryGetValue(serviceRecord.EmployeeOid.Value, out mitarbeiter);
// Leistung "Pause" -> Art der Periode "Pause", sonst "Arbeit".
var leistung = (serviceDescription.Name ?? string.Empty).Trim();
var artDerPeriode = leistung.Equals("Pause", StringComparison.OrdinalIgnoreCase) ? "Pause" : "Arbeit";
var start = serviceRecord.Start.Value;
var end = serviceRecord.End.Value;
sb.AppendLine();
sb.Append(PersonioExportUtils.BuildCsvLine(
mitarbeiter ?? string.Empty,
start.ToString("yyyy-MM-dd HH:mm"),
end.ToString("yyyy-MM-dd HH:mm"),
artDerPeriode,
serviceRecord.Notice ?? string.Empty, // Kommentar (ServiceRecord.Notice)
string.Empty, // Projekt - nicht benötigt
string.Empty)); // Unterprojekt - nicht benötigt
}
return sb.ToString();
}
}
}

View File

@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using BeWo.Data.Access;
using BeWo.Data.Entities;
using BS.Shared;
using BS.Shared.DataContracts;
namespace KetteReports.Export
{
// Gemeinsame Hilfsfunktionen für die beiden Personio-Exporte (Abwesenheits- und Anwesenheitsperioden).
internal static class PersonioExportUtils
{
public static string GetEmail(Employee employee)
{
var contacts = employee?.Person?.Contacts;
if (contacts == null)
{
return string.Empty;
}
var mail = contacts.FirstOrDefault(c => c.Type == ContactType.business_Mail && !string.IsNullOrWhiteSpace(c.Value))
?? contacts.FirstOrDefault(c => c.Type == ContactType.private_Mail && !string.IsNullOrWhiteSpace(c.Value));
return mail?.Value ?? string.Empty;
}
// Baut ein Nachschlagewerk EmployeeOid -> Anzeige-Kennung: E-Mail, sonst "Vorname Nachname".
// GetAll (nicht GetAllActive), damit auch inaktive/ausgeschiedene Mitarbeiter mit Datensaetzen im Monat aufgeloest werden.
public static Dictionary<long, string> BuildEmployeeIdentifierByOid()
{
var result = new Dictionary<long, string>();
var employees = DAOFactory.GenericDAO.GetAll<Employee>();
foreach (var employee in employees)
{
if (!employee.Oid.HasValue)
{
continue;
}
var email = GetEmail(employee);
result[employee.Oid.Value] = !string.IsNullOrWhiteSpace(email) ? email : GetFullName(employee);
}
return result;
}
// Liefert "Vorname Nachname" des Mitarbeiters (leer, falls keine Person hinterlegt).
public static string GetFullName(Employee employee)
{
var person = employee?.Person;
if (person == null)
{
return string.Empty;
}
return $"{person.FirstName} {person.LastName}".Trim();
}
// Liest den Abrechnungsmonat aus dem ersten Query-Parameter (wie im DatevExporter) und liefert den ersten Tag dieses Monats zurück.
public static DateTime GetMonthStart(QueryDC query)
{
var dt = DateTime.Now;
if (query?.Parameter != null && query.Parameter.Count > 0 && query.Parameter[0].Value != null)
{
DateTime.TryParse(query.Parameter[0].Value.ToString(), out dt);
}
return new DateTime(dt.Year, dt.Month, 1);
}
// Maskiert ein einzelnes CSV-Feld (Trennzeichen ";") gemäß RFC 4180.
public static string CsvField(string value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
if (value.Contains(";") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r"))
{
return "\"" + value.Replace("\"", "\"\"") + "\"";
}
return value;
}
// Setzt eine CSV-Zeile aus den übergebenen Feldern zusammen (Trennzeichen ";").
public static string BuildCsvLine(params string[] fields)
{
var sb = new StringBuilder();
for (int i = 0; i < fields.Length; i++)
{
if (i > 0)
{
sb.Append(";");
}
sb.Append(CsvField(fields[i]));
}
return sb.ToString();
}
}
}

View File

@@ -136,6 +136,9 @@
</Compile>
<Compile Include="Export\CustomDataExporter.cs" />
<Compile Include="Export\DatevExporter.cs" />
<Compile Include="Export\PersonioExportUtils.cs" />
<Compile Include="Export\PersonioAbwesenheitExporter.cs" />
<Compile Include="Export\PersonioAnwesenheitExporter.cs" />
<Compile Include="Finanzauswertung.cs">
<SubType>Component</SubType>
</Compile>