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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user