diff --git a/ReportImp/KetteReports/Export/CustomDataExporter.cs b/ReportImp/KetteReports/Export/CustomDataExporter.cs index a1bd2cf1b..5e5a1a95b 100644 --- a/ReportImp/KetteReports/Export/CustomDataExporter.cs +++ b/ReportImp/KetteReports/Export/CustomDataExporter.cs @@ -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); } diff --git a/ReportImp/KetteReports/Export/PersonioAbwesenheitExporter.cs b/ReportImp/KetteReports/Export/PersonioAbwesenheitExporter.cs new file mode 100644 index 000000000..558c884bf --- /dev/null +++ b/ReportImp/KetteReports/Export/PersonioAbwesenheitExporter.cs @@ -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(); + } + } +} diff --git a/ReportImp/KetteReports/Export/PersonioAnwesenheitExporter.cs b/ReportImp/KetteReports/Export/PersonioAnwesenheitExporter.cs new file mode 100644 index 000000000..5c490c685 --- /dev/null +++ b/ReportImp/KetteReports/Export/PersonioAnwesenheitExporter.cs @@ -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(); + } + } +} diff --git a/ReportImp/KetteReports/Export/PersonioExportUtils.cs b/ReportImp/KetteReports/Export/PersonioExportUtils.cs new file mode 100644 index 000000000..33214ae09 --- /dev/null +++ b/ReportImp/KetteReports/Export/PersonioExportUtils.cs @@ -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 BuildEmployeeIdentifierByOid() + { + var result = new Dictionary(); + var employees = DAOFactory.GenericDAO.GetAll(); + 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(); + } + } +} diff --git a/ReportImp/KetteReports/KetteReports.csproj b/ReportImp/KetteReports/KetteReports.csproj index 85ee7d069..80e04b49a 100644 --- a/ReportImp/KetteReports/KetteReports.csproj +++ b/ReportImp/KetteReports/KetteReports.csproj @@ -136,6 +136,9 @@ + + + Component