implement payroll generator v0.9

This commit is contained in:
Marcel Arndt
2025-11-06 11:38:59 +01:00
parent 319e7f3614
commit 4abe9e44d8
27 changed files with 4273 additions and 0 deletions
@@ -0,0 +1,14 @@
package payroll
// SalaryComponent is a single position
type SalaryComponent struct {
Name string
Type string
Amount int64
Value int64
Details string
}
type Rule interface {
Execute(data PayrollInput) []SalaryComponent
}
@@ -0,0 +1,90 @@
package payroll
import (
excelread "business-engine/internal/excel_read"
"business-engine/internal/gencsv"
"business-engine/internal/genpdf"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"slices"
"time"
)
type EmployeeHandler struct {
service Service
logger *slog.Logger
}
func NewEmployeeHandler(service Service, logger *slog.Logger) *EmployeeHandler {
return &EmployeeHandler{
service: service,
logger: logger,
}
}
func (h *EmployeeHandler) ListEmployees(w http.ResponseWriter, r *http.Request) {
month := time.Date(2025, time.September, 1, 0, 0, 0, 0, time.UTC)
employees, err := h.service.GenerateMonthlyRun(r.Context(), month)
if err != nil {
h.logger.Error("failed to get employees", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(employees); err != nil {
h.logger.Error("failed to encode response", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (h *EmployeeHandler) RunPayroll(w http.ResponseWriter, r *http.Request) {
month := time.Date(2025, time.September, 1, 0, 0, 0, 0, time.UTC)
employeeData := excelread.ReadEmployeeData()
var payrollStatements []excelread.PayrollStatement
for _, employeeRow := range employeeData {
input, err := h.service.FetchPayrollInput(r.Context(), month, employeeRow.Vorname, employeeRow.Nachname)
if err != nil {
h.logger.Error("could not fetch payroll input", "error", err)
// http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
statement := excelread.GeneratePayrollStatement(employeeRow, input)
payrollStatements = append(payrollStatements, statement)
}
slices.SortFunc(payrollStatements, func(a, b excelread.PayrollStatement) int {
if a.Tourengeld < b.Tourengeld {
return 1
}
if a.Tourengeld > b.Tourengeld {
return -1
}
return 0
})
payrollStatements[0].TourengeldBonus = 500
payrollStatements[1].TourengeldBonus = 400
payrollStatements[2].TourengeldBonus = 300
h.logger.Info("generating csv salary statements")
err := gencsv.GeneratePayrollStatementCSV(payrollStatements)
if err != nil {
h.logger.Error("could not generate payroll statement csv", "error", err)
}
h.logger.Info("generating pdf salary statements")
for _, stmt := range payrollStatements {
err := genpdf.GenerateSalaryStatement(stmt, fmt.Sprintf("%s_%s.pdf", month.Format("2006-01"), fmt.Sprintf("%s %s", stmt.Vorname, stmt.Nachname)), month)
if err != nil {
h.logger.Error("could not generate salary statement pdf", "error", err)
}
}
w.Header().Set("Content-Type", "application/json")
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)
}
}
@@ -0,0 +1,29 @@
package payroll
import (
"business-engine/internal/database"
)
type PayrollInput struct {
database.ListActiveEmployeesRow
WorkSummary struct {
Days int64
Hours float64
Minutes int64
}
SicknessSummary struct {
Days int64
Hours float64
Minutes int64
}
WorkTime []database.WorkTime
}
func NewPayrollInput(employee database.ListActiveEmployeesRow) PayrollInput {
return PayrollInput{
ListActiveEmployeesRow: employee,
}
}
@@ -0,0 +1,21 @@
package payroll
type BaseSalaryRule struct{}
func NewBaseSalaryRule() *BaseSalaryRule {
return &BaseSalaryRule{}
}
func (r *BaseSalaryRule) Execute(data PayrollInput) []SalaryComponent {
stundenlohnCent := data.ContractSalaryHour * 100
monatsGehaltCent := stundenlohnCent * data.ContractHoursWeek * 4.33
return []SalaryComponent{
{
Name: "Grundgehalt",
Type: "Grundgehalt",
Amount: int64(monatsGehaltCent),
},
}
}
@@ -0,0 +1,177 @@
package payroll
import (
"business-engine/internal/database"
excelread "business-engine/internal/excel_read"
"context"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
type Repository interface {
ListActiveEmployees(ctx context.Context) ([]database.ListActiveEmployeesRow, error)
GetWorkSummaryForEmployeeInPeriod(ctx context.Context, args database.GetWorkSummaryForEmployeeInPeriodParams) (database.GetWorkSummaryForEmployeeInPeriodRow, error)
GetSicknessSummaryForEmployeeInPeriod(ctx context.Context, args database.GetSicknessSummaryForEmployeeInPeriodParams) (database.GetSicknessSummaryForEmployeeInPeriodRow, error)
ListWorkTimesForEmployeeInPeriod(ctx context.Context, args database.ListWorkTimesForEmployeeInPeriodParams) ([]database.WorkTime, error)
ListPlannedTimesForEmployeeInPeriod(ctx context.Context, args database.ListPlannedTimesForEmployeeInPeriodParams) ([]database.ListPlannedTimesForEmployeeInPeriodRow, error)
FetchDrivenToursInInterval(ctx context.Context, args database.FetchDrivenToursInIntervalParams) ([]database.Tour, error)
CountTourengeldAmountInPeriod(ctx context.Context, args database.CountTourengeldAmountInPeriodParams) (float64, error)
GetEmployeeByName(ctx context.Context, args database.GetEmployeeByNameParams) (database.GetEmployeeByNameRow, error)
}
type Engine interface {
Calculate() // payroll input -> payroll statement
}
type Service struct {
repo Repository
engine Engine
logger *slog.Logger
}
func NewService(repo Repository, logger *slog.Logger) *Service {
return &Service{
repo: repo,
logger: logger,
}
}
func (s *Service) FetchPayrollInput(ctx context.Context, month time.Time, employeeFirstname, employeeLastname string) (excelread.PayrollInputData, error) {
s.logger.Info("fetching payroll input", "month", month)
employeeIDs, err := s.repo.GetEmployeeByName(ctx, database.GetEmployeeByNameParams{
Firstname: employeeFirstname,
Lastname: employeeLastname,
})
if err != nil {
s.logger.Error("could not find employee with that name", "firstname", employeeFirstname, "lastname", employeeLastname, "error", err)
return excelread.PayrollInputData{}, err
}
s.logger.Debug("got employee data", "employee", employeeIDs)
startOfMonth := BeginningOfMonth(month.AddDate(0, -1, 0))
endOfMonth := EndOfMonth(month.AddDate(0, -1, 0))
s.logger.Info("fetching data for interval", "startDate", startOfMonth, "endDate", endOfMonth)
tourengeld, err := s.repo.CountTourengeldAmountInPeriod(ctx, database.CountTourengeldAmountInPeriodParams{
StartDate: startOfMonth,
EndDate: endOfMonth,
EmployeeID: pgtype.Text{
String: employeeIDs.DispoliveID,
Valid: true,
},
})
if err != nil {
s.logger.Error("could not count tourengeld for employee", "employeeId", employeeIDs.DyflexisID, "error", err)
}
plannedtimes, err := s.repo.ListPlannedTimesForEmployeeInPeriod(ctx, database.ListPlannedTimesForEmployeeInPeriodParams{
EmployeeID: fmt.Sprintf("%d", employeeIDs.DyflexisID),
StartDate: &startOfMonth,
EndDate: &endOfMonth,
})
if err != nil {
s.logger.Error("could not fetch plannedtimes for employee", "employeeId", employeeIDs.DyflexisID, "error", err)
}
worktimes, err := s.repo.ListWorkTimesForEmployeeInPeriod(ctx, database.ListWorkTimesForEmployeeInPeriodParams{
EmployeeID: fmt.Sprintf("%d", employeeIDs.DyflexisID),
StartDate: &startOfMonth,
EndDate: &endOfMonth,
})
if err != nil {
s.logger.Error("could not fetch worktimes for employee", "employeeId", employeeIDs.DyflexisID, "error", err)
}
s.logger.Info("Fetched payroll input", "employee", fmt.Sprintf("%s %s", employeeFirstname, employeeLastname), "tourengeld", tourengeld, "workdays", len(worktimes))
return excelread.NewPayrollInputData(employeeIDs.ContractSalaryHour, tourengeld, worktimes, plannedtimes), nil
}
func (s *Service) GenerateMonthlyRun(ctx context.Context, month time.Time) ([]PayrollInput, error) {
s.logger.Info("starting monthly payroll run", "month", month)
employees, err := s.repo.ListActiveEmployees(ctx)
if err != nil {
s.logger.Error("could not get active employees", "error", err)
return []PayrollInput{}, err
}
s.logger.Info("found active employees for payroll run", "count", len(employees))
var inputs []PayrollInput
for _, employee := range employees {
log := s.logger.With("employee_id", employee.ID)
log.Info("processing employee")
payrollInput := NewPayrollInput(employee)
startOfMonth := BeginningOfMonth(month.AddDate(0, -1, 0))
endOfMonth := EndOfMonth(month.AddDate(0, -1, 0))
workSummary, err := s.repo.GetWorkSummaryForEmployeeInPeriod(ctx, database.GetWorkSummaryForEmployeeInPeriodParams{
EmployeeID: fmt.Sprintf("%d", employee.ID),
StartDate: &startOfMonth,
EndDate: &endOfMonth,
})
if err != nil {
s.logger.Error("could not get work summary for employee", "error", err)
return []PayrollInput{}, err
}
payrollInput.WorkSummary = struct {
Days int64
Hours float64
Minutes int64
}(workSummary)
sicknessSummary, err := s.repo.GetSicknessSummaryForEmployeeInPeriod(ctx, database.GetSicknessSummaryForEmployeeInPeriodParams{
EmployeeID: fmt.Sprintf("%d", employee.ID),
StartDate: &startOfMonth,
EndDate: &endOfMonth,
})
if err != nil {
s.logger.Error("could not get work summary for employee", "error", err)
return []PayrollInput{}, err
}
payrollInput.SicknessSummary = struct {
Days int64
Hours float64
Minutes int64
}(sicknessSummary)
worktimes, err := s.repo.ListWorkTimesForEmployeeInPeriod(ctx, database.ListWorkTimesForEmployeeInPeriodParams{
EmployeeID: fmt.Sprintf("%d", employee.ID),
StartDate: &startOfMonth,
EndDate: &endOfMonth,
})
if err != nil {
s.logger.Error("could not get worktimes for employee", "error", err)
return []PayrollInput{}, nil
}
payrollInput.WorkTime = worktimes
inputs = append(inputs, payrollInput)
}
return inputs, 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, 999,
eom.Location(),
)
}