implement latest changes to employee payroll process

- Add ContractDaysWeek
- Add Sickness Days to employee export sheet
- Add Gesundheitsbonus value per employee
- Additional fixes
This commit is contained in:
Marcel Arndt
2025-12-12 11:52:03 +01:00
parent 156733f65a
commit 0951282f11
17 changed files with 408 additions and 205 deletions
@@ -275,6 +275,7 @@ type DyflexisEmployee struct {
ContractSalaryHour float64 `json:"contractSalaryHour"`
ContractTypeName pgtype.Text `json:"contractTypeName"`
InternalId pgtype.UUID `json:"internalId"`
ContractDaysWeek int32 `json:"contractDaysWeek"`
}
type Employee struct {
@@ -564,6 +565,18 @@ type Tour struct {
Schwerlast bool `json:"schwerlast"`
}
type TourFile struct {
ID pgtype.UUID `json:"id"`
TourId string `json:"tourId"`
TicketId string `json:"ticketId"`
StoragePath string `json:"storagePath"`
OriginalFilename string `json:"originalFilename"`
MimeType string `json:"mimeType"`
FileSizeBytes int32 `json:"fileSizeBytes"`
DlUploadStatus string `json:"dlUploadStatus"`
CreatedAt *time.Time `json:"createdAt"`
}
type TourSource struct {
InternalId pgtype.UUID `json:"internalId"`
InsertedAt time.Time `json:"insertedAt"`
@@ -309,6 +309,39 @@ func (q *Queries) IsEmployeesDepartmentKTW(ctx context.Context, arg IsEmployeesD
return meets_requirements, err
}
const isEmployeesDepartmentNotLeitstelle = `-- name: IsEmployeesDepartmentNotLeitstelle :one
SELECT COALESCE(
(
(
bool_or(department LIKE '%KTW%')
OR bool_or(department LIKE '%BTW%')
OR bool_or(department LIKE '%TSW%')
)
AND
bool_and(department <> 'Leitstelle')
),
false
)::bool as meets_requirements
FROM "PlannedTime"
WHERE
"userId"::int = $1::bigint
AND "startDate" BETWEEN $2 AND $3
AND department IS NOT NULL
`
type IsEmployeesDepartmentNotLeitstelleParams struct {
EmployeeID int64 `json:"employee_id"`
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
}
func (q *Queries) IsEmployeesDepartmentNotLeitstelle(ctx context.Context, arg IsEmployeesDepartmentNotLeitstelleParams) (bool, error) {
row := q.db.QueryRow(ctx, isEmployeesDepartmentNotLeitstelle, arg.EmployeeID, arg.StartDate, arg.EndDate)
var meets_requirements bool
err := row.Scan(&meets_requirements)
return meets_requirements, err
}
const listActiveEmployees = `-- name: ListActiveEmployees :many
SELECT
de.id,
@@ -316,6 +349,7 @@ SELECT
de.surname,
de."contractStart",
de."contractEnd",
de."contractDaysWeek",
de."contractHoursWeek",
de."contractSalaryHour",
de."contractTypeName",
@@ -334,6 +368,7 @@ type ListActiveEmployeesRow struct {
Surname string `json:"surname"`
ContractStart time.Time `json:"contractStart"`
ContractEnd *time.Time `json:"contractEnd"`
ContractDaysWeek int32 `json:"contractDaysWeek"`
ContractHoursWeek float64 `json:"contractHoursWeek"`
ContractSalaryHour float64 `json:"contractSalaryHour"`
ContractTypeName pgtype.Text `json:"contractTypeName"`
@@ -356,6 +391,7 @@ func (q *Queries) ListActiveEmployees(ctx context.Context) ([]ListActiveEmployee
&i.Surname,
&i.ContractStart,
&i.ContractEnd,
&i.ContractDaysWeek,
&i.ContractHoursWeek,
&i.ContractSalaryHour,
&i.ContractTypeName,
@@ -371,3 +407,47 @@ func (q *Queries) ListActiveEmployees(ctx context.Context) ([]ListActiveEmployee
}
return items, nil
}
const listSickdaysForEmployee = `-- name: ListSickdaysForEmployee :many
SELECT
"start",
"end",
"days",
locked
FROM "Sickdays"
WHERE
"employeeId" = $1
ORDER BY start asc
`
type ListSickdaysForEmployeeRow struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
Days int32 `json:"days"`
Locked bool `json:"locked"`
}
func (q *Queries) ListSickdaysForEmployee(ctx context.Context, employeeID int32) ([]ListSickdaysForEmployeeRow, error) {
rows, err := q.db.Query(ctx, listSickdaysForEmployee, employeeID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListSickdaysForEmployeeRow
for rows.Next() {
var i ListSickdaysForEmployeeRow
if err := rows.Scan(
&i.Start,
&i.End,
&i.Days,
&i.Locked,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
@@ -1,6 +1,7 @@
package domain
import (
"business-engine/internal/database"
"time"
)
@@ -9,9 +10,10 @@ type PayrollInput struct {
Surname string
// Vertragliche Basis
HourlyRate float64
ContractHours int64
DepartmentIsKtw bool
HourlyRate float64
ContractHours int64
DepartmentIsKtw bool
DepartmentNotLeitstelle bool
// Erfasste Zeiten
WorkedHours float64
@@ -21,6 +23,8 @@ type PayrollInput struct {
TotalHours float64
OvertimeHours float64
SickdaysYear []database.ListSickdaysForEmployeeRow
// Zulagen und Boni
ZulageTourengeld float64
ZulagePassengerTransport int64
@@ -59,12 +63,15 @@ type PayrollStatement struct {
Tourengeld float64 `json:"tourengeld" csv:"Tourengeld Anzahl"`
TourengeldBonus float64 `json:"tourengeldBonus" csv:"Tourengeld-Bonus (Platz 1-3, EUR)"`
GesundheitsbonusAbzug string `json:"gesundheitsbonusAbzug" csv:"Gesundheitsbonus abzuziehen? (ja/nein)"`
Gesundheitsbonus float64 `json:"gesundheitsbonus" csv:"Gesundheitsbonus"`
Urlaubsgeld float64 `json:"urlaubsgeld" csv:"Urlaubsgeld (44 EUR pro tag)"`
PScheinZulage float64 `json:"pScheinZulage" csv:"P-schein Zulage"`
Wochenendzuschlag15 float64 `json:"wochenendzuschlag15" csv:"Wochenendzuschlag 15%"`
Sonntagszuschlag50 float64 `json:"sonntagszuschlag50" csv:"Sonntagszuschlag 50%"`
Feiertagszuschlag125 float64 `json:"feiertagszuschlag125" csv:"Feiertagszuschlag 125%"`
Feiertagszuschlag150 float64 `json:"feiertagszuschlag150" csv:"Feiertagszuschlag 150%"`
Feiertagszuschlag125 float64 `json:"feiertagszuschlag125" csv:"Feiertagszuschlag 125%,omitempty"`
Feiertagszuschlag150 float64 `json:"feiertagszuschlag150" csv:"Feiertagszuschlag 150%,omitempty"`
Nachtzuschlag25 float64 `json:"nachtzuschlag25" csv:"Nachtzuschlag 25%"`
Nachtzuschlag40 float64 `json:"nachtzuschlag40" csv:"Nachtzuschlag 40%"`
SickdaysYear []database.ListSickdaysForEmployeeRow `json:"-" csv:"-"`
}
@@ -1,6 +1,7 @@
package genpdf
import (
"business-engine/internal/database"
domain "business-engine/internal/domain"
"fmt"
"slices"
@@ -77,6 +78,8 @@ func GenerateSalaryStatement(stmt domain.PayrollStatement, filename string, mont
drawSummary(pdf, gesamtbetrag)
drawSickdays(pdf, stmt.SickdaysYear)
if err := pdf.OutputFileAndClose(filename); err != nil {
return err
}
@@ -147,12 +150,12 @@ func genLineItemsFromStatement(stmt domain.PayrollStatement) []LineItem {
Betrag: 14.0,
})
}
if stmt.GesundheitsbonusAbzug == "" {
if stmt.Gesundheitsbonus > 0 {
items = append(items, LineItem{
Lohnart: "1032",
Beschreibung: "Gesundheitsbonus",
Anzahl: 1,
Betrag: 200.0,
Betrag: stmt.Gesundheitsbonus,
})
}
@@ -295,6 +298,41 @@ func drawSummary(pdf *fpdf.Fpdf, total float64) {
pdf.CellFormat(30, 8, formatCurrency(total), "T", 0, "R", false, 0, "")
}
func drawSickdays(pdf *fpdf.Fpdf, sickdays []database.ListSickdaysForEmployeeRow) {
// 1. Abstand und Überschrift
pdf.Ln(10)
pdf.SetFont("DejaVu", "B", 11)
// Kein Rahmen, Zeilenumbruch nach der Zelle
pdf.CellFormat(0, 10, "Krankmeldungen", "", 1, "L", false, 0, "")
// 2. Fallback: Wenn keine Krankmeldungen vorhanden sind
if len(sickdays) == 0 {
pdf.SetFont("DejaVu", "", 10)
text := "- Keine Einträge vorhanden"
pdf.CellFormat(0, 6, text, "", 1, "L", false, 0, "")
pdf.Ln(5)
return
}
// 3. Liste zeichne
pdf.SetFont("DejaVu", "", 10)
for _, sick := range sickdays {
startStr := sick.Start.Format("02.01.2006")
endStr := sick.End.Format("02.01.2006")
// Formatierung: "- 01.01.2023 bis 05.01.2023: 4 Tage"
text := fmt.Sprintf("- %s bis %s: %d Tage", startStr, endStr, sick.Days)
// Zeichnen der Zeile
pdf.CellFormat(0, 6, text, "", 1, "L", false, 0, "")
}
// Kleiner Abstand nach der Liste
pdf.Ln(5)
}
func formatFloat(f float64) string {
p := message.NewPrinter(language.German)
str := p.Sprintf("%.2f", f)
@@ -25,7 +25,7 @@ func NewEmployeeHandler(service Service, logger *slog.Logger) *EmployeeHandler {
}
func (h *EmployeeHandler) RunPayroll(w http.ResponseWriter, r *http.Request) {
month := time.Date(2025, time.November, 1, 0, 0, 0, 0, time.UTC)
month := time.Date(2025, time.December, 1, 0, 0, 0, 0, time.UTC)
employees, err := h.service.FetchActiveEmployees(r.Context())
if err != nil {
@@ -85,7 +85,7 @@ func (h *EmployeeHandler) RunPayroll(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
if err := json.NewEncoder(w).Encode(payrollStatements); err != nil {
h.logger.Error("failed to encode response", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
@@ -16,8 +16,10 @@ type Repository interface {
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 {
@@ -55,8 +57,7 @@ func (s *Service) GeneratePayrollStatement(input domain.PayrollInput) domain.Pay
GearbeiteteTage: float64(input.WorkedDays),
Krankheitstage: float64(input.SickDays),
Ueberstunden: input.OvertimeHours,
Verpflegungspauschale: float64(input.Verpflegungspauschale),
SickdaysYear: input.SickdaysYear,
Wochenendzuschlag15: input.ZuschlagSamstag,
Sonntagszuschlag50: input.ZuschlagSonntag,
@@ -66,10 +67,27 @@ func (s *Service) GeneratePayrollStatement(input domain.PayrollInput) domain.Pay
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)
@@ -107,19 +125,30 @@ func (s *Service) LoadPayrollInput(ctx context.Context, month time.Time, employe
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
}
s.logger.Debug("fetched department", "isKtw", isKtw)
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,
Firstname: employee.Firstname,
Surname: employee.Surname,
HourlyRate: employee.ContractSalaryHour,
ContractHours: int64(employee.ContractHoursWeek),
DepartmentIsKtw: isKtw,
DepartmentNotLeitstelle: isNotLeitstelle,
WorkedHours: metrics.WorkedHours,
WorkedDays: int64(metrics.WorkedDays),
@@ -128,6 +157,8 @@ func (s *Service) LoadPayrollInput(ctx context.Context, month time.Time, employe
OvertimeHours: metrics.OvertimeHours,
TotalHours: metrics.TotalHours,
SickdaysYear: sickdays,
ZulageTourengeld: tourengeld,
ZulagePuenktlichkeit: punctuality,
Verpflegungspauschale: verpflegungsPauschale,
@@ -150,12 +181,15 @@ func (s *Service) LoadPayrollInput(ctx context.Context, month time.Time, employe
input.ZuschlagVerpflegung = zuschlagData.Verpflegung14
} else {
s.logger.Warn("Keine Zuschlag-Daten für Mitarbeiter gefunden", "key", compoundKey)
// (Kein Fehler, der Mitarbeiter hatte vielleicht keine Zuschläge)
}
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)
@@ -172,6 +206,22 @@ func (s *Service) countVerpflegungspauschale(ctx context.Context, month time.Tim
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)
@@ -217,7 +267,13 @@ type payrollMetrics struct {
func (s *Service) calculatePayrollMetrics(weeklyTimeData []domain.WeeklyTimeData, employee database.ListActiveEmployeesRow) payrollMetrics {
var m payrollMetrics
employeeDailyContractHours := employee.ContractHoursWeek / 5
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
@@ -261,6 +317,7 @@ func (s *Service) getWeeklyTimeData(ctx context.Context, month time.Time, employ
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,