package payroll import ( "business-engine/internal/database" "business-engine/internal/domain" "context" "fmt" "log/slog" "time" "github.com/jackc/pgx/v5/pgtype" ) type Repository interface { ListActiveEmployees(ctx context.Context) ([]database.ListActiveEmployeesRow, error) CountTourengeldAmountInPeriod(ctx context.Context, args database.CountTourengeldAmountInPeriodParams) (float64, error) GetWeeklyTimeSummaryForEmployee(ctx context.Context, args database.GetWeeklyTimeSummaryForEmployeeParams) ([]database.GetWeeklyTimeSummaryForEmployeeRow, error) EvaluatePunctualityForEmployee(ctx context.Context, args database.EvaluatePunctualityForEmployeeParams) (int64, error) IsEmployeesDepartmentNotLeitstelle(ctx context.Context, args database.IsEmployeesDepartmentNotLeitstelleParams) (bool, error) IsEmployeesDepartmentKTW(ctx context.Context, args database.IsEmployeesDepartmentKTWParams) (bool, error) CountVerpflegungspauschale(ctx context.Context, args database.CountVerpflegungspauschaleParams) (int64, error) ListSickdaysForEmployee(ctx context.Context, employeeId int32) ([]database.ListSickdaysForEmployeeRow, error) } type Service struct { repo Repository logger *slog.Logger zuschlagParser *ZuschlagParser } func NewService(repo Repository, logger *slog.Logger, zParser *ZuschlagParser) *Service { return &Service{ repo: repo, logger: logger, zuschlagParser: zParser, } } func (s *Service) FetchActiveEmployees(ctx context.Context) ([]database.ListActiveEmployeesRow, error) { s.logger.Info("fetching active employees") employees, err := s.repo.ListActiveEmployees(ctx) if err != nil { s.logger.Error("could not fetch list of active employees", "error", err) return nil, err } return employees, nil } func (s *Service) GeneratePayrollStatement(input domain.PayrollInput) domain.PayrollStatement { statement := domain.PayrollStatement{ Nachname: input.Surname, Vorname: input.Firstname, VertragStundenlohn: input.HourlyRate, Vertragsstunden: float64(input.ContractHours) * 4.333, GearbeiteteStunden: input.WorkedHours, GearbeiteteTage: float64(input.WorkedDays), Krankheitstage: float64(input.SickDays), Ueberstunden: input.OvertimeHours, SickdaysYear: input.SickdaysYear, Wochenendzuschlag15: input.ZuschlagSamstag, Sonntagszuschlag50: input.ZuschlagSonntag, Nachtzuschlag25: input.ZuschlagNacht25, Nachtzuschlag40: input.ZuschlagNacht40, Feiertagszuschlag125: input.ZuschlagFeiertag125, Feiertagszuschlag150: input.ZuschlagFeiertag150, } if input.DepartmentNotLeitstelle { statement.Verpflegungspauschale = float64(input.Verpflegungspauschale) } if input.SickDays > 0 || !input.DepartmentIsKtw { statement.GesundheitsbonusAbzug = "ja" } if input.SickDays <= 0 && input.DepartmentIsKtw { switch { case statement.Vertragsstunden > 173.0: statement.Gesundheitsbonus = 200.0 case statement.Vertragsstunden > 120: statement.Gesundheitsbonus = 150.0 case statement.Vertragsstunden > 100: statement.Gesundheitsbonus = 100.0 default: statement.Gesundheitsbonus = 0.0 } } if input.DepartmentIsKtw { statement.Urlaubsgeld = float64(input.HolidayDays) statement.PScheinZulage = float64(input.ZulagePassengerTransport) statement.PuenktlichkeitspraemieAnzahl = float64(input.ZulagePuenktlichkeit) statement.Tourengeld = input.ZulageTourengeld } return statement } func (s *Service) LoadPayrollInput(ctx context.Context, month time.Time, employee database.ListActiveEmployeesRow, zuschlaege map[string]ZuschlagData) (domain.PayrollInput, error) { weeklyTimeData, err := s.getWeeklyTimeData(ctx, month, int64(employee.ID)) if err != nil { s.logger.Error("failed to sum worked hours for employee", "employeeId", employee.ID, "error", err) return domain.PayrollInput{}, fmt.Errorf("failed to get time data for employee %d: %w", employee.ID, err) } metrics := s.calculatePayrollMetrics(weeklyTimeData, employee) tourengeld, err := s.fetchTourengeld(ctx, month, employee.DlId) if err != nil { s.logger.Error("could not fetch Tourengeld for employee", "employeeId", employee.ID, "error", err) return domain.PayrollInput{}, err } punctuality, err := s.getPunctualityData(ctx, month, int64(employee.ID)) if err != nil { s.logger.Error("could not fetch punctuality for employee", "employeeId", employee.ID, "error", err) return domain.PayrollInput{}, err } verpflegungsPauschale, err := s.countVerpflegungspauschale(ctx, month, int64(employee.ID)) if err != nil { s.logger.Error("could not count verpflegungspauschale for employee", "employeeId", employee.ID, "error", err) return domain.PayrollInput{}, err } isNotLeitstelle, err := s.isEmployeesDepartmentNotLeitstelle(ctx, month, int64(employee.ID)) if err != nil { s.logger.Error("could not load employee department", "error", err) return domain.PayrollInput{}, err } isKtw, err := s.isEmployeesDepartmentKTW(ctx, month, int64(employee.ID)) if err != nil { s.logger.Error("could not load employee department", "error", err) return domain.PayrollInput{}, err } sickdays, err := s.loadSickdays(ctx, int64(employee.ID)) if err != nil { s.logger.Error("could not load sickdays of employee", "employeeId", employee.ID, "error", err) } input := domain.PayrollInput{ Firstname: employee.Firstname, Surname: employee.Surname, HourlyRate: employee.ContractSalaryHour, ContractHours: int64(employee.ContractHoursWeek), DepartmentIsKtw: isKtw, DepartmentNotLeitstelle: isNotLeitstelle, WorkedHours: metrics.WorkedHours, WorkedDays: int64(metrics.WorkedDays), SickDays: int64(metrics.SickHours / 8), HolidayDays: int64(metrics.HolidayDays), OvertimeHours: metrics.OvertimeHours, TotalHours: metrics.TotalHours, SickdaysYear: sickdays, ZulageTourengeld: tourengeld, ZulagePuenktlichkeit: punctuality, Verpflegungspauschale: verpflegungsPauschale, } if isKtw && employee.PersonTransportCertificate != nil { if employee.PersonTransportCertificate.After(month) { input.ZulagePassengerTransport = int64(metrics.WorkedDays) } } compoundKey := fmt.Sprintf("%s,%s", employee.Surname, employee.Firstname) if zuschlagData, ok := zuschlaege[compoundKey]; ok { input.ZuschlagNacht25 = zuschlagData.Nacht25 input.ZuschlagNacht40 = zuschlagData.Nacht40 input.ZuschlagSamstag = zuschlagData.Samstag15 input.ZuschlagSonntag = zuschlagData.Sonntag50 input.ZuschlagFeiertag125 = zuschlagData.Feiertag125 input.ZuschlagFeiertag150 = zuschlagData.Feiertag150 input.ZuschlagVerpflegung = zuschlagData.Verpflegung14 } else { s.logger.Warn("Keine Zuschlag-Daten für Mitarbeiter gefunden", "key", compoundKey) } return input, nil } func (s *Service) loadSickdays(ctx context.Context, employeeId int64) ([]database.ListSickdaysForEmployeeRow, error) { return s.repo.ListSickdaysForEmployee(ctx, int32(employeeId)) } func (s *Service) countVerpflegungspauschale(ctx context.Context, month time.Time, employeeId int64) (int64, error) { startOfMonth := beginningOfMonth(month) endOfMonth := endOfMonth(month) s.logger.Info("counting VerpflegungsPauschale for employee", "employeeId", employeeId, "startDate", startOfMonth, "endDate", endOfMonth) verpflegungsPauschale, err := s.repo.CountVerpflegungspauschale(ctx, database.CountVerpflegungspauschaleParams{ StartDate: &startOfMonth, EndDate: &endOfMonth, EmployeeID: employeeId, }) if err != nil { return 0, fmt.Errorf("failed to count VerpflegungsPauschale: %w", err) } return verpflegungsPauschale, nil } func (s *Service) isEmployeesDepartmentNotLeitstelle(ctx context.Context, month time.Time, employeeId int64) (bool, error) { startOfMonth := beginningOfMonth(month) endOfMonth := endOfMonth(month) s.logger.Info("fetching department for employee", "employeeId", employeeId, "startDate", startOfMonth, "endDate", endOfMonth) isNotLeitstelle, err := s.repo.IsEmployeesDepartmentNotLeitstelle(ctx, database.IsEmployeesDepartmentNotLeitstelleParams{ StartDate: &startOfMonth, EndDate: &endOfMonth, EmployeeID: employeeId, }) if err != nil { return false, fmt.Errorf("failed to fetch department for employee %d: %w", employeeId, err) } return isNotLeitstelle, nil } func (s *Service) isEmployeesDepartmentKTW(ctx context.Context, month time.Time, employeeId int64) (bool, error) { startOfMonth := beginningOfMonth(month) endOfMonth := endOfMonth(month) s.logger.Info("fetching department for employee", "employeeId", employeeId, "startDate", startOfMonth, "endDate", endOfMonth) isKtw, err := s.repo.IsEmployeesDepartmentKTW(ctx, database.IsEmployeesDepartmentKTWParams{ StartDate: &startOfMonth, EndDate: &endOfMonth, EmployeeID: employeeId, }) if err != nil { return false, fmt.Errorf("failed to fetch department for employee %d: %w", employeeId, err) } return isKtw, nil } func (s *Service) getPunctualityData(ctx context.Context, month time.Time, employeeId int64) (int64, error) { startOfMonth := beginningOfMonth(month) endOfMonth := endOfMonth(month) s.logger.Info("fetching punctuality for employee", "employeeId", employeeId, "startDate", startOfMonth, "endDate", endOfMonth) punctuality, err := s.repo.EvaluatePunctualityForEmployee(ctx, database.EvaluatePunctualityForEmployeeParams{ StartDate: &startOfMonth, EndDate: &endOfMonth, EmployeeID: employeeId, }) if err != nil { return 0, fmt.Errorf("failed to fetch punctuality evaluation for employee %d: %w", employeeId, err) } return punctuality, nil } type payrollMetrics struct { WorkedHours float64 WorkedDays float64 SickHours float64 HolidayDays float64 OvertimeHours float64 TotalHours float64 } func (s *Service) calculatePayrollMetrics(weeklyTimeData []domain.WeeklyTimeData, employee database.ListActiveEmployeesRow) payrollMetrics { var m payrollMetrics employeeDailyContractHours := 0.0 switch { case employee.ContractDaysWeek <= 0: employeeDailyContractHours = employee.ContractHoursWeek / 5 default: employeeDailyContractHours = employee.ContractHoursWeek / float64(employee.ContractDaysWeek) } for _, week := range weeklyTimeData { m.WorkedHours += week.WorkedHours m.WorkedDays += week.WorkedDays m.SickHours += week.SickHours m.HolidayDays += week.HolidayDays } m.TotalHours = m.WorkedHours + m.SickHours + (m.HolidayDays * employeeDailyContractHours) m.OvertimeHours = m.TotalHours - (employee.ContractHoursWeek * 4.3333) return m } func (s *Service) getWeeklyTimeData(ctx context.Context, month time.Time, employeeId int64) ([]domain.WeeklyTimeData, error) { startOfMonth := beginningOfMonth(month) endOfMonth := endOfMonth(month) s.logger.Info("fetching weekly summary for employee", "employeeId", employeeId, "startDate", startOfMonth, "endDate", endOfMonth) timeSummary, err := s.repo.GetWeeklyTimeSummaryForEmployee(ctx, database.GetWeeklyTimeSummaryForEmployeeParams{ StartDate: &startOfMonth, EndDate: &endOfMonth, EmployeeID: fmt.Sprintf("%d", employeeId), }) if err != nil { return nil, fmt.Errorf("failed to fetch weekly time summary for employee %d. %w", employeeId, err) } var weeklyTimeData []domain.WeeklyTimeData for _, week := range timeSummary { wtd := domain.WeeklyTimeData{ WeekStart: week.WeekStart, WorkedHours: week.TotalWorkedHours, WorkedDays: week.TotalWorkedDays, SickHours: week.TotalSickHours, HolidayDays: week.TotalHolidayDays, } weeklyTimeData = append(weeklyTimeData, wtd) } return weeklyTimeData, nil } func (s *Service) fetchTourengeld(ctx context.Context, month time.Time, dlEmployeeId string) (float64, error) { startOfMonth := beginningOfMonth(month) endOfMonth := endOfMonth(month) s.logger.Info("fetching tourengeld for employee", "employeeId", dlEmployeeId, "startDate", startOfMonth, "endDate", endOfMonth) tourengeld, err := s.repo.CountTourengeldAmountInPeriod(ctx, database.CountTourengeldAmountInPeriodParams{ StartDate: startOfMonth, EndDate: endOfMonth, EmployeeID: pgtype.Text{ String: dlEmployeeId, Valid: true, }, }) if err != nil { return 0.0, fmt.Errorf("failed to count tourengeld %w", err) } return tourengeld, nil } func beginningOfMonth(date time.Time) time.Time { return date.AddDate(0, 0, -date.Day()+1) } func endOfMonth(date time.Time) time.Time { eom := date.AddDate(0, 1, -date.Day()) return time.Date( eom.Year(), eom.Month(), eom.Day(), 23, 59, 59, 999999999, eom.Location(), ) }