implement payroll generator v0.9
This commit is contained in:
@@ -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(),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user