implement Gehaltsaufstellung dyflexis data access

This commit is contained in:
Marcel Arndt 2025-11-18 13:32:36 +01:00
parent 4abe9e44d8
commit 4cf79f3094
18 changed files with 1015 additions and 1107 deletions

View File

@ -46,7 +46,8 @@ func NewServer(cfg *config.Config, logger *slog.Logger) (*Server, error) {
// setupRoutes initializes all routes and handlers
func (s *Server) setupRoutes() {
employeeRepo := database.New(s.db)
employeeService := payroll.NewService(employeeRepo, s.logger)
zuschlagParser := payroll.NewZuschlagParser(s.logger)
employeeService := payroll.NewService(employeeRepo, s.logger, zuschlagParser)
employeeHandler := payroll.NewEmployeeHandler(*employeeService, s.logger)
s.router.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
@ -54,8 +55,6 @@ func (s *Server) setupRoutes() {
w.Write([]byte("OK"))
})
s.router.HandleFunc("GET /api/v1/employees", employeeHandler.ListEmployees)
s.router.HandleFunc("GET /api/v1/payroll", employeeHandler.RunPayroll)
}

View File

@ -16,6 +16,20 @@ SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Name: public; Type: SCHEMA; Schema: -; Owner: -
--
-- *not* creating schema, since initdb creates it
--
-- Name: SCHEMA public; Type: COMMENT; Schema: -; Owner: -
--
COMMENT ON SCHEMA public IS '';
--
-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: -
--
@ -120,7 +134,7 @@ CREATE TABLE public."Anomaly" (
CREATE TABLE public."AttendanceRegistration" (
"attendanceRegistrationId" integer NOT NULL,
"employeeId" integer NOT NULL,
"personnelNumber" text NOT NULL,
"personnelNumber" text,
"dateTime" timestamp(3) without time zone NOT NULL,
event text NOT NULL
);
@ -151,7 +165,8 @@ CREATE TABLE public."AttendanceRegistrationStaging" (
"attendanceRegistrationId" integer NOT NULL,
"employeeId" integer NOT NULL,
"dateTime" timestamp(3) without time zone NOT NULL,
event text NOT NULL
event text NOT NULL,
"personnelNumber" text DEFAULT ''::text
);
@ -286,6 +301,19 @@ CREATE TABLE public."EmployeeDyflexisStaging" (
);
--
-- Name: Holiday; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public."Holiday" (
id uuid DEFAULT gen_random_uuid() NOT NULL,
date timestamp(3) without time zone NOT NULL,
locked boolean NOT NULL,
"excludedFromPayroll" boolean NOT NULL,
"employeeId" integer NOT NULL
);
--
-- Name: Patient; Type: TABLE; Schema: public; Owner: -
--
@ -411,6 +439,22 @@ CREATE TABLE public."PlannedTimeStaging" (
);
--
-- Name: Sickdays; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public."Sickdays" (
id uuid DEFAULT gen_random_uuid() NOT NULL,
start timestamp(3) without time zone NOT NULL,
"end" timestamp(3) without time zone NOT NULL,
hours double precision NOT NULL,
days integer NOT NULL,
note text,
locked boolean NOT NULL,
"employeeId" integer NOT NULL
);
--
-- Name: SolutionTarget; Type: TABLE; Schema: public; Owner: -
--
@ -540,7 +584,10 @@ CREATE TABLE public."Tour" (
"createdAt" timestamp(3) without time zone,
"deletedAt" timestamp(3) without time zone,
"ticketId" uuid,
"updatedAt" timestamp(3) without time zone
"updatedAt" timestamp(3) without time zone,
"companyName" text,
"overRTW" boolean DEFAULT false NOT NULL,
schwerlast boolean DEFAULT false NOT NULL
);
@ -616,7 +663,10 @@ CREATE TABLE public."TourSource" (
"fahrerName" text,
fahrer text,
color text,
"lastUpdate" text
"lastUpdate" text,
"companyName" text,
"overRTW" text,
schwerlast text
);
@ -674,7 +724,10 @@ CREATE TABLE public."TourStaging" (
"revenueDeviation" double precision DEFAULT 0 NOT NULL,
"consumptionCosts" double precision DEFAULT 0 NOT NULL,
"createdAt" timestamp(3) without time zone,
"updatedAt" timestamp(3) without time zone
"updatedAt" timestamp(3) without time zone,
"companyName" text,
"overRTW" boolean DEFAULT false NOT NULL,
schwerlast boolean DEFAULT false NOT NULL
);
@ -860,6 +913,14 @@ ALTER TABLE ONLY public."Employee"
ADD CONSTRAINT "Employee_pkey" PRIMARY KEY (id);
--
-- Name: Holiday Holiday_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public."Holiday"
ADD CONSTRAINT "Holiday_pkey" PRIMARY KEY (id);
--
-- Name: PatientSource PatientSource_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@ -908,6 +969,14 @@ ALTER TABLE ONLY public."PlannedTime"
ADD CONSTRAINT "PlannedTime_pkey" PRIMARY KEY (id);
--
-- Name: Sickdays Sickdays_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public."Sickdays"
ADD CONSTRAINT "Sickdays_pkey" PRIMARY KEY (id);
--
-- Name: SolutionTargetStep SolutionTargetStep_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--

View File

@ -12,90 +12,136 @@ SELECT
e."personTransportCertificate"
FROM "DyflexisEmployee" as de
INNER JOIN "Employee" as e ON de.id::text = e."personnelNumber"
WHERE de."contractStart" < CURRENT_DATE AND de."contractEnd" > (CURRENT_DATE - INTERVAL '2 months');
WHERE de."contractStart" < CURRENT_DATE AND de."contractEnd" > (CURRENT_DATE - INTERVAL '2 months')
AND de.surname <> 'Shahrasebhi'
ORDER BY de.firstname ASC;
-- name: GetWorkSummaryForEmployeeInPeriod :one
WITH wt AS (
-- name: GetWeeklyTimeSummaryForEmployee :many
WITH all_time_entries AS (
SELECT
COUNT(*) as days,
COALESCE(sum(duration), 0)::bigint as minutes
DATE_TRUNC('week', "startDate") AS week_start,
SUM("duration" / 60.0) as worked_hours,
count(DISTINCT DATE_TRUNC('day', "startDate"))::float as worked_days,
0::float AS sick_hours,
0::float AS holiday_days
FROM "WorkTime"
WHERE "userId" = @employee_id AND "startDate" > @start_date AND "startDate" < @end_date
WHERE
"startDate" BETWEEN @start_date AND @end_date
AND "userId"::int = @employee_id
AND "startDate" IS NOT NULL
GROUP BY
DATE_TRUNC('week', "startDate")
UNION ALL
SELECT
DATE_TRUNC('week', "start") AS week_start,
0::float as worked_hours,
0::float as worked_days,
SUM("hours") AS sick_hours,
0::float AS holiday_days
FROM "Sickdays"
WHERE
"start" BETWEEN @start_date AND @end_date
AND "employeeId" = @employee_id
AND "start" IS NOT NULL
GROUP BY
DATE_TRUNC('week', "start")
UNION ALL
SELECT
DATE_TRUNC('week', "date") AS week_start,
0::float as worked_hours,
0::float as worked_days,
0::float AS sick_hours,
count(*)::float AS holiday_days
FROM "Holiday"
WHERE
"date" BETWEEN @start_date AND @end_date
AND "employeeId" = @employee_id
AND "date" IS NOT NULL
GROUP BY
DATE_TRUNC('week', "date")
)
SELECT
wt.days,
(minutes / 60.0)::float as hours,
wt.minutes
FROM wt;
week_start::timestamp,
SUM(worked_hours)::float AS total_worked_hours,
SUM(worked_days)::float AS total_worked_days,
SUM(sick_hours)::float AS total_sick_hours,
SUM(holiday_days)::float AS total_holiday_days
FROM all_time_entries
GROUP BY
week_start
ORDER BY
week_start;
-- name: ListPlannedTimesForEmployeeInPeriod :many
-- name: EvaluatePunctualityForEmployee :one
WITH
planned_start_times AS (
SELECT
DISTINCT ON (day)
"startDate"::DATE as day,
"startDate",
"endDate",
department,
pause,
duration
DISTINCT ON ("startDate"::DATE)
"startDate"::DATE AS plan_day,
"startDate" AS planned_start_time
FROM "PlannedTime" as p
WHERE
p."userId"::int = @employee_id::bigint
AND p."startDate" BETWEEN @start_date AND @end_date
AND p."startDate" IS NOT NULL
AND p.department <> 'Fortbildung'
AND p.note IS NULL
ORDER BY
"startDate"::DATE, "startDate" ASC
),
actual_start_times AS (
SELECT
DISTINCT ON ("startDate"::DATE)
"startDate"::DATE AS work_day,
"startDate" AS actual_start_time
FROM "WorkTime"
WHERE
"userId"::int = @employee_id::bigint
AND "startDate" BETWEEN @start_date AND @end_date
AND "startDate" IS NOT NULL
ORDER BY
"startDate"::DATE, "startDate" ASC
),
daily_comparison AS (
SELECT
p.plan_day,
p.planned_start_time,
a.actual_start_time,
(a.actual_start_time IS NOT NULL AND a.actual_start_time <= p.planned_start_time) AS was_punctual
FROM planned_start_times p
LEFT JOIN actual_start_times a ON p.plan_day = a.work_day
)
SELECT
COUNT(CASE WHEN was_punctual THEN 1 END) AS punctual_days
FROM daily_comparison;
-- name: IsEmployeesDepartmentKTW :one
SELECT COALESCE(
(
bool_or(department LIKE '%KTW%')
AND
bool_and(department <> 'Leitstelle')
),
false
)::bool as meets_requirements
FROM "PlannedTime"
WHERE
"userId" = @employee_id AND
"startDate" > @start_date AND
"startDate" < @end_date AND
department <> 'Fortbildung' AND
note IS NULL
ORDER BY day, "startDate" ASC;
"userId"::int = @employee_id::bigint
AND "startDate" BETWEEN @start_date AND @end_date
AND department IS NOT NULL;
-- name: ListWorkTimesForEmployeeInPeriod :many
SELECT * FROM "WorkTime"
WHERE "userId" = @employee_id AND "startDate" > @start_date AND "startDate" < @end_date;
-- name: GetSicknessSummaryForEmployeeInPeriod :one
WITH pt AS (
-- name: CountVerpflegungspauschale :one
SELECT
COUNT(*) as days,
COALESCE(sum(duration), 0)::bigint as minutes
FROM "PlannedTime"
WHERE "userId" = @employee_id AND "startDate" > @start_date AND "startDate" < @end_date AND note = 'sick'
)
SELECT
pt.days,
(minutes / 60)::float as hours,
pt.minutes
FROM pt;
-- name: GetEmployeeDyflexis :one
SELECT * FROM "DyflexisEmployee"
count(*)
FROM "WorkTime"
WHERE
id = $1 AND
"contractStart" < CURRENT_DATE AND
"contractEnd" > (CURRENT_DATE - INTERVAL '2 months')
LIMIT 1;
-- name: FetchDrivenToursInInterval :many
SELECT * FROM "Tour"
WHERE "check" <> 3
AND "deletedAt" IS NULL
AND (
"patientId" IS NULL
OR "patientId" NOT IN (
'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
'3554e1a9-9c07-45e5-b53c-692a77e4fa9a'
)
)
-- ID's for Pause and Feierabend respectively
AND (
"carName" IS NULL
OR "carName" NOT IN (
'Storno',
'Abgabe',
'Löschen'
)
)
AND "startDate" >= @start_date
AND "startDate" <= @end_date
AND ("driverId" = @employee_id OR "codriverId" = @employee_id)
ORDER BY "startDate" DESC;
duration > 480
AND "startDate" BETWEEN @start_date AND @end_date
AND "userId"::int = @employee_id::bigint;
-- name: CountTourengeldAmountInPeriod :one
WITH ToursInPeriod AS (
@ -159,23 +205,13 @@ HighActivityDays AS (
GREATEST(0, tour_amount - 6) AS tourengeld
FROM
DailyCounts
)
),
TourenGeld AS (
SELECT
SUM(tourengeld)::float as tourengeld
FROM
HighActivityDays;
-- name: GetEmployeeByName :one
HighActivityDays
)
SELECT
dy.id as dyflexis_id,
dl.id as dispolive_id,
dy."contractSalaryHour"
FROM "DyflexisEmployee" as dy
INNER JOIN
"Employee" as dl ON dy.id::text = dl."personnelNumber"
WHERE
TRIM(dy.firstname) = @firstname AND
TRIM(dy.surname) = @lastname AND
"contractStart" < CURRENT_DATE AND
"contractEnd" > (CURRENT_DATE - INTERVAL '2 months')
ORDER BY dy."contractEnd" DESC;
COALESCE(tourengeld, 0.0)
FROM TourenGeld;

View File

@ -217,7 +217,7 @@ type Anomaly struct {
type AttendanceRegistration struct {
AttendanceRegistrationId int32 `json:"attendanceRegistrationId"`
EmployeeId int32 `json:"employeeId"`
PersonnelNumber string `json:"personnelNumber"`
PersonnelNumber pgtype.Text `json:"personnelNumber"`
DateTime time.Time `json:"dateTime"`
Event string `json:"event"`
}
@ -239,6 +239,7 @@ type AttendanceRegistrationStaging struct {
EmployeeId int32 `json:"employeeId"`
DateTime time.Time `json:"dateTime"`
Event string `json:"event"`
PersonnelNumber pgtype.Text `json:"personnelNumber"`
}
type AvicennaTenant struct {
@ -337,6 +338,14 @@ type EmployeeDyflexisStaging struct {
ContractTypeName pgtype.Text `json:"contractTypeName"`
}
type Holiday struct {
ID pgtype.UUID `json:"id"`
Date time.Time `json:"date"`
Locked bool `json:"locked"`
ExcludedFromPayroll bool `json:"excludedFromPayroll"`
EmployeeId int32 `json:"employeeId"`
}
type Patient struct {
ID string `json:"id"`
Surname pgtype.Text `json:"surname"`
@ -443,6 +452,17 @@ type PrismaMigration struct {
AppliedStepsCount int32 `json:"applied_steps_count"`
}
type Sickday struct {
ID pgtype.UUID `json:"id"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
Hours float64 `json:"hours"`
Days int32 `json:"days"`
Note pgtype.Text `json:"note"`
Locked bool `json:"locked"`
EmployeeId int32 `json:"employeeId"`
}
type SolutionTarget struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
@ -539,6 +559,9 @@ type Tour struct {
DeletedAt *time.Time `json:"deletedAt"`
TicketId pgtype.UUID `json:"ticketId"`
UpdatedAt *time.Time `json:"updatedAt"`
CompanyName pgtype.Text `json:"companyName"`
OverRTW bool `json:"overRTW"`
Schwerlast bool `json:"schwerlast"`
}
type TourSource struct {
@ -610,6 +633,9 @@ type TourSource struct {
Fahrer pgtype.Text `json:"fahrer"`
Color pgtype.Text `json:"color"`
LastUpdate pgtype.Text `json:"lastUpdate"`
CompanyName pgtype.Text `json:"companyName"`
OverRTW pgtype.Text `json:"overRTW"`
Schwerlast pgtype.Text `json:"schwerlast"`
}
type TourStaging struct {
@ -663,6 +689,9 @@ type TourStaging struct {
ConsumptionCosts float64 `json:"consumptionCosts"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
CompanyName pgtype.Text `json:"companyName"`
OverRTW bool `json:"overRTW"`
Schwerlast bool `json:"schwerlast"`
}
type Trace struct {

View File

@ -74,11 +74,16 @@ HighActivityDays AS (
GREATEST(0, tour_amount - 6) AS tourengeld
FROM
DailyCounts
)
),
TourenGeld AS (
SELECT
SUM(tourengeld)::float as tourengeld
FROM
HighActivityDays
)
SELECT
COALESCE(tourengeld, 0.0)
FROM TourenGeld
`
type CountTourengeldAmountInPeriodParams struct {
@ -94,99 +99,176 @@ func (q *Queries) CountTourengeldAmountInPeriod(ctx context.Context, arg CountTo
return tourengeld, err
}
const fetchDrivenToursInInterval = `-- name: FetchDrivenToursInInterval :many
SELECT id, done, direction, "carName", "operationId", "driverId", "driverName", "codriverId", "codriverName", "patientId", "patientName", "patientSurname", "patientStreet", "patientZip", "patientCity", "healthInsurance", "healthInsuranceNumber", type, category, "transportType", "ordinanceType", "rangeEndDate", "startInstitution", "startStreet", "startZip", "startCity", "targetInstitution", "targetStreet", "targetZip", "targetCity", "startDate", "check", "occupiedKm", "totalKm", "startBegin", "startEnd", target, "targetBegin", empty, "hasInfection", "infectionName", "revenueDispoLive", "billDate", "billNumber", revenue, "consumptionCosts", "revenueDeviation", "createdAt", "deletedAt", "ticketId", "updatedAt" FROM "Tour"
WHERE "check" <> 3
AND "deletedAt" IS NULL
AND (
"patientId" IS NULL
OR "patientId" NOT IN (
'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
'3554e1a9-9c07-45e5-b53c-692a77e4fa9a'
)
)
AND (
"carName" IS NULL
OR "carName" NOT IN (
'Storno',
'Abgabe',
'Löschen'
)
)
AND "startDate" >= $1
AND "startDate" <= $2
AND ("driverId" = $3 OR "codriverId" = $3)
ORDER BY "startDate" DESC
const countVerpflegungspauschale = `-- name: CountVerpflegungspauschale :one
SELECT
count(*)
FROM "WorkTime"
WHERE
duration > 480
AND "startDate" BETWEEN $1 AND $2
AND "userId"::int = $3::bigint
`
type FetchDrivenToursInIntervalParams struct {
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
EmployeeID pgtype.Text `json:"employee_id"`
type CountVerpflegungspauschaleParams struct {
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
EmployeeID int64 `json:"employee_id"`
}
// ID's for Pause and Feierabend respectively
func (q *Queries) FetchDrivenToursInInterval(ctx context.Context, arg FetchDrivenToursInIntervalParams) ([]Tour, error) {
rows, err := q.db.Query(ctx, fetchDrivenToursInInterval, arg.StartDate, arg.EndDate, arg.EmployeeID)
func (q *Queries) CountVerpflegungspauschale(ctx context.Context, arg CountVerpflegungspauschaleParams) (int64, error) {
row := q.db.QueryRow(ctx, countVerpflegungspauschale, arg.StartDate, arg.EndDate, arg.EmployeeID)
var count int64
err := row.Scan(&count)
return count, err
}
const evaluatePunctualityForEmployee = `-- name: EvaluatePunctualityForEmployee :one
WITH
planned_start_times AS (
SELECT
DISTINCT ON ("startDate"::DATE)
"startDate"::DATE AS plan_day,
"startDate" AS planned_start_time
FROM "PlannedTime" as p
WHERE
p."userId"::int = $1::bigint
AND p."startDate" BETWEEN $2 AND $3
AND p."startDate" IS NOT NULL
AND p.department <> 'Fortbildung'
AND p.note IS NULL
ORDER BY
"startDate"::DATE, "startDate" ASC
),
actual_start_times AS (
SELECT
DISTINCT ON ("startDate"::DATE)
"startDate"::DATE AS work_day,
"startDate" AS actual_start_time
FROM "WorkTime"
WHERE
"userId"::int = $1::bigint
AND "startDate" BETWEEN $2 AND $3
AND "startDate" IS NOT NULL
ORDER BY
"startDate"::DATE, "startDate" ASC
),
daily_comparison AS (
SELECT
p.plan_day,
p.planned_start_time,
a.actual_start_time,
(a.actual_start_time IS NOT NULL AND a.actual_start_time <= p.planned_start_time) AS was_punctual
FROM planned_start_times p
LEFT JOIN actual_start_times a ON p.plan_day = a.work_day
)
SELECT
COUNT(CASE WHEN was_punctual THEN 1 END) AS punctual_days
FROM daily_comparison
`
type EvaluatePunctualityForEmployeeParams struct {
EmployeeID int64 `json:"employee_id"`
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
}
func (q *Queries) EvaluatePunctualityForEmployee(ctx context.Context, arg EvaluatePunctualityForEmployeeParams) (int64, error) {
row := q.db.QueryRow(ctx, evaluatePunctualityForEmployee, arg.EmployeeID, arg.StartDate, arg.EndDate)
var punctual_days int64
err := row.Scan(&punctual_days)
return punctual_days, err
}
const getWeeklyTimeSummaryForEmployee = `-- name: GetWeeklyTimeSummaryForEmployee :many
WITH all_time_entries AS (
SELECT
DATE_TRUNC('week', "startDate") AS week_start,
SUM("duration" / 60.0) as worked_hours,
count(DISTINCT DATE_TRUNC('day', "startDate"))::float as worked_days,
0::float AS sick_hours,
0::float AS holiday_days
FROM "WorkTime"
WHERE
"startDate" BETWEEN $1 AND $2
AND "userId"::int = $3
AND "startDate" IS NOT NULL
GROUP BY
DATE_TRUNC('week', "startDate")
UNION ALL
SELECT
DATE_TRUNC('week', "start") AS week_start,
0::float as worked_hours,
0::float as worked_days,
SUM("hours") AS sick_hours,
0::float AS holiday_days
FROM "Sickdays"
WHERE
"start" BETWEEN $1 AND $2
AND "employeeId" = $3
AND "start" IS NOT NULL
GROUP BY
DATE_TRUNC('week', "start")
UNION ALL
SELECT
DATE_TRUNC('week', "date") AS week_start,
0::float as worked_hours,
0::float as worked_days,
0::float AS sick_hours,
count(*)::float AS holiday_days
FROM "Holiday"
WHERE
"date" BETWEEN $1 AND $2
AND "employeeId" = $3
AND "date" IS NOT NULL
GROUP BY
DATE_TRUNC('week', "date")
)
SELECT
week_start::timestamp,
SUM(worked_hours)::float AS total_worked_hours,
SUM(worked_days)::float AS total_worked_days,
SUM(sick_hours)::float AS total_sick_hours,
SUM(holiday_days)::float AS total_holiday_days
FROM all_time_entries
GROUP BY
week_start
ORDER BY
week_start
`
type GetWeeklyTimeSummaryForEmployeeParams struct {
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
EmployeeID string `json:"employee_id"`
}
type GetWeeklyTimeSummaryForEmployeeRow struct {
WeekStart time.Time `json:"week_start"`
TotalWorkedHours float64 `json:"total_worked_hours"`
TotalWorkedDays float64 `json:"total_worked_days"`
TotalSickHours float64 `json:"total_sick_hours"`
TotalHolidayDays float64 `json:"total_holiday_days"`
}
func (q *Queries) GetWeeklyTimeSummaryForEmployee(ctx context.Context, arg GetWeeklyTimeSummaryForEmployeeParams) ([]GetWeeklyTimeSummaryForEmployeeRow, error) {
rows, err := q.db.Query(ctx, getWeeklyTimeSummaryForEmployee, arg.StartDate, arg.EndDate, arg.EmployeeID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Tour
var items []GetWeeklyTimeSummaryForEmployeeRow
for rows.Next() {
var i Tour
var i GetWeeklyTimeSummaryForEmployeeRow
if err := rows.Scan(
&i.ID,
&i.Done,
&i.Direction,
&i.CarName,
&i.OperationId,
&i.DriverId,
&i.DriverName,
&i.CodriverId,
&i.CodriverName,
&i.PatientId,
&i.PatientName,
&i.PatientSurname,
&i.PatientStreet,
&i.PatientZip,
&i.PatientCity,
&i.HealthInsurance,
&i.HealthInsuranceNumber,
&i.Type,
&i.Category,
&i.TransportType,
&i.OrdinanceType,
&i.RangeEndDate,
&i.StartInstitution,
&i.StartStreet,
&i.StartZip,
&i.StartCity,
&i.TargetInstitution,
&i.TargetStreet,
&i.TargetZip,
&i.TargetCity,
&i.StartDate,
&i.Check,
&i.OccupiedKm,
&i.TotalKm,
&i.StartBegin,
&i.StartEnd,
&i.Target,
&i.TargetBegin,
&i.Empty,
&i.HasInfection,
&i.InfectionName,
&i.RevenueDispoLive,
&i.BillDate,
&i.BillNumber,
&i.Revenue,
&i.ConsumptionCosts,
&i.RevenueDeviation,
&i.CreatedAt,
&i.DeletedAt,
&i.TicketId,
&i.UpdatedAt,
&i.WeekStart,
&i.TotalWorkedHours,
&i.TotalWorkedDays,
&i.TotalSickHours,
&i.TotalHolidayDays,
); err != nil {
return nil, err
}
@ -198,132 +280,33 @@ func (q *Queries) FetchDrivenToursInInterval(ctx context.Context, arg FetchDrive
return items, nil
}
const getEmployeeByName = `-- name: GetEmployeeByName :one
SELECT
dy.id as dyflexis_id,
dl.id as dispolive_id,
dy."contractSalaryHour"
FROM "DyflexisEmployee" as dy
INNER JOIN
"Employee" as dl ON dy.id::text = dl."personnelNumber"
WHERE
TRIM(dy.firstname) = $1 AND
TRIM(dy.surname) = $2 AND
"contractStart" < CURRENT_DATE AND
"contractEnd" > (CURRENT_DATE - INTERVAL '2 months')
ORDER BY dy."contractEnd" DESC
`
type GetEmployeeByNameParams struct {
Firstname string `json:"firstname"`
Lastname string `json:"lastname"`
}
type GetEmployeeByNameRow struct {
DyflexisID int32 `json:"dyflexis_id"`
DispoliveID string `json:"dispolive_id"`
ContractSalaryHour float64 `json:"contractSalaryHour"`
}
func (q *Queries) GetEmployeeByName(ctx context.Context, arg GetEmployeeByNameParams) (GetEmployeeByNameRow, error) {
row := q.db.QueryRow(ctx, getEmployeeByName, arg.Firstname, arg.Lastname)
var i GetEmployeeByNameRow
err := row.Scan(&i.DyflexisID, &i.DispoliveID, &i.ContractSalaryHour)
return i, err
}
const getEmployeeDyflexis = `-- name: GetEmployeeDyflexis :one
SELECT id, firstname, surname, "contractStart", "contractEnd", "contractHoursWeek", "contractSalaryHour", "contractTypeName", "internalId" FROM "DyflexisEmployee"
WHERE
id = $1 AND
"contractStart" < CURRENT_DATE AND
"contractEnd" > (CURRENT_DATE - INTERVAL '2 months')
LIMIT 1
`
func (q *Queries) GetEmployeeDyflexis(ctx context.Context, id int32) (DyflexisEmployee, error) {
row := q.db.QueryRow(ctx, getEmployeeDyflexis, id)
var i DyflexisEmployee
err := row.Scan(
&i.ID,
&i.Firstname,
&i.Surname,
&i.ContractStart,
&i.ContractEnd,
&i.ContractHoursWeek,
&i.ContractSalaryHour,
&i.ContractTypeName,
&i.InternalId,
)
return i, err
}
const getSicknessSummaryForEmployeeInPeriod = `-- name: GetSicknessSummaryForEmployeeInPeriod :one
WITH pt AS (
SELECT
COUNT(*) as days,
COALESCE(sum(duration), 0)::bigint as minutes
const isEmployeesDepartmentKTW = `-- name: IsEmployeesDepartmentKTW :one
SELECT COALESCE(
(
bool_or(department LIKE '%KTW%')
AND
bool_and(department <> 'Leitstelle')
),
false
)::bool as meets_requirements
FROM "PlannedTime"
WHERE "userId" = $1 AND "startDate" > $2 AND "startDate" < $3 AND note = 'sick'
)
SELECT
pt.days,
(minutes / 60)::float as hours,
pt.minutes
FROM pt
WHERE
"userId"::int = $1::bigint
AND "startDate" BETWEEN $2 AND $3
AND department IS NOT NULL
`
type GetSicknessSummaryForEmployeeInPeriodParams struct {
EmployeeID string `json:"employee_id"`
type IsEmployeesDepartmentKTWParams struct {
EmployeeID int64 `json:"employee_id"`
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
}
type GetSicknessSummaryForEmployeeInPeriodRow struct {
Days int64 `json:"days"`
Hours float64 `json:"hours"`
Minutes int64 `json:"minutes"`
}
func (q *Queries) GetSicknessSummaryForEmployeeInPeriod(ctx context.Context, arg GetSicknessSummaryForEmployeeInPeriodParams) (GetSicknessSummaryForEmployeeInPeriodRow, error) {
row := q.db.QueryRow(ctx, getSicknessSummaryForEmployeeInPeriod, arg.EmployeeID, arg.StartDate, arg.EndDate)
var i GetSicknessSummaryForEmployeeInPeriodRow
err := row.Scan(&i.Days, &i.Hours, &i.Minutes)
return i, err
}
const getWorkSummaryForEmployeeInPeriod = `-- name: GetWorkSummaryForEmployeeInPeriod :one
WITH wt AS (
SELECT
COUNT(*) as days,
COALESCE(sum(duration), 0)::bigint as minutes
FROM "WorkTime"
WHERE "userId" = $1 AND "startDate" > $2 AND "startDate" < $3
)
SELECT
wt.days,
(minutes / 60.0)::float as hours,
wt.minutes
FROM wt
`
type GetWorkSummaryForEmployeeInPeriodParams struct {
EmployeeID string `json:"employee_id"`
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
}
type GetWorkSummaryForEmployeeInPeriodRow struct {
Days int64 `json:"days"`
Hours float64 `json:"hours"`
Minutes int64 `json:"minutes"`
}
func (q *Queries) GetWorkSummaryForEmployeeInPeriod(ctx context.Context, arg GetWorkSummaryForEmployeeInPeriodParams) (GetWorkSummaryForEmployeeInPeriodRow, error) {
row := q.db.QueryRow(ctx, getWorkSummaryForEmployeeInPeriod, arg.EmployeeID, arg.StartDate, arg.EndDate)
var i GetWorkSummaryForEmployeeInPeriodRow
err := row.Scan(&i.Days, &i.Hours, &i.Minutes)
return i, err
func (q *Queries) IsEmployeesDepartmentKTW(ctx context.Context, arg IsEmployeesDepartmentKTWParams) (bool, error) {
row := q.db.QueryRow(ctx, isEmployeesDepartmentKTW, arg.EmployeeID, arg.StartDate, arg.EndDate)
var meets_requirements bool
err := row.Scan(&meets_requirements)
return meets_requirements, err
}
const listActiveEmployees = `-- name: ListActiveEmployees :many
@ -341,6 +324,8 @@ SELECT
FROM "DyflexisEmployee" as de
INNER JOIN "Employee" as e ON de.id::text = e."personnelNumber"
WHERE de."contractStart" < CURRENT_DATE AND de."contractEnd" > (CURRENT_DATE - INTERVAL '2 months')
AND de.surname <> 'Shahrasebhi'
ORDER BY de.firstname ASC
`
type ListActiveEmployeesRow struct {
@ -386,105 +371,3 @@ func (q *Queries) ListActiveEmployees(ctx context.Context) ([]ListActiveEmployee
}
return items, nil
}
const listPlannedTimesForEmployeeInPeriod = `-- name: ListPlannedTimesForEmployeeInPeriod :many
SELECT
DISTINCT ON (day)
"startDate"::DATE as day,
"startDate",
"endDate",
department,
pause,
duration
FROM "PlannedTime"
WHERE
"userId" = $1 AND
"startDate" > $2 AND
"startDate" < $3 AND
department <> 'Fortbildung' AND
note IS NULL
ORDER BY "day"
`
type ListPlannedTimesForEmployeeInPeriodParams struct {
EmployeeID string `json:"employee_id"`
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
}
type ListPlannedTimesForEmployeeInPeriodRow struct {
Day pgtype.Date `json:"day"`
StartDate *time.Time `json:"startDate"`
EndDate *time.Time `json:"endDate"`
Department pgtype.Text `json:"department"`
Pause int32 `json:"pause"`
Duration int32 `json:"duration"`
}
func (q *Queries) ListPlannedTimesForEmployeeInPeriod(ctx context.Context, arg ListPlannedTimesForEmployeeInPeriodParams) ([]ListPlannedTimesForEmployeeInPeriodRow, error) {
rows, err := q.db.Query(ctx, listPlannedTimesForEmployeeInPeriod, arg.EmployeeID, arg.StartDate, arg.EndDate)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPlannedTimesForEmployeeInPeriodRow
for rows.Next() {
var i ListPlannedTimesForEmployeeInPeriodRow
if err := rows.Scan(
&i.Day,
&i.StartDate,
&i.EndDate,
&i.Department,
&i.Pause,
&i.Duration,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWorkTimesForEmployeeInPeriod = `-- name: ListWorkTimesForEmployeeInPeriod :many
SELECT id, "userId", firstname, surname, "startDate", "endDate", department, pause, duration FROM "WorkTime"
WHERE "userId" = $1 AND "startDate" > $2 AND "startDate" < $3
`
type ListWorkTimesForEmployeeInPeriodParams struct {
EmployeeID string `json:"employee_id"`
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
}
func (q *Queries) ListWorkTimesForEmployeeInPeriod(ctx context.Context, arg ListWorkTimesForEmployeeInPeriodParams) ([]WorkTime, error) {
rows, err := q.db.Query(ctx, listWorkTimesForEmployeeInPeriod, arg.EmployeeID, arg.StartDate, arg.EndDate)
if err != nil {
return nil, err
}
defer rows.Close()
var items []WorkTime
for rows.Next() {
var i WorkTime
if err := rows.Scan(
&i.ID,
&i.UserId,
&i.Firstname,
&i.Surname,
&i.StartDate,
&i.EndDate,
&i.Department,
&i.Pause,
&i.Duration,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@ -0,0 +1,70 @@
package domain
import (
"time"
)
type PayrollInput struct {
Firstname string
Surname string
// Vertragliche Basis
HourlyRate float64
ContractHours int64
DepartmentIsKtw bool
// Erfasste Zeiten
WorkedHours float64
WorkedDays int64
SickDays int64
HolidayDays int64
TotalHours float64
OvertimeHours float64
// Zulagen und Boni
ZulageTourengeld float64
ZulagePassengerTransport int64
ZulagePuenktlichkeit int64
Verpflegungspauschale int64
// Zuschlaege
ZuschlagNacht25 float64
ZuschlagNacht40 float64
ZuschlagSamstag float64
ZuschlagSonntag float64
ZuschlagVerpflegung float64
ZuschlagFeiertag125 float64
ZuschlagFeiertag150 float64
}
type WeeklyTimeData struct {
WeekStart time.Time
WorkedHours float64
WorkedDays float64
SickHours float64
HolidayDays float64
}
type PayrollStatement struct {
Nachname string `json:"nachname" csv:"Nachname"`
Vorname string `json:"vorname" csv:"Vorname"`
Verpflegungspauschale float64 `json:"verpflegungspauschale" csv:"Verpflegungspauschale"`
VertragStundenlohn float64 `json:"stundenlohn" csv:"Vertr. Stundenlohn"`
Vertragsstunden float64 `json:"vertragsstunden" csv:"Arbeitsstunden lt. Vertrag"`
GearbeiteteStunden float64 `json:"gearbeiteteStunden" csv:"Gearbeitet Stunden"`
GearbeiteteTage float64 `json:"gearbeiteteTage" csv:"Gearbeitet Tage"`
PuenktlichkeitspraemieAnzahl float64 `json:"puenktlichkeitspraemieAnzahl" csv:"Pünktlichkeitsprämie anzahl"`
Krankheitstage float64 `json:"krankheitstage" csv:"Krankheitstage x 8 Stunden"`
Ueberstunden float64 `json:"ueberstunden" csv:"davon Überstunden"`
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)"`
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%"`
Nachtzuschlag25 float64 `json:"nachtzuschlag25" csv:"Nachtzuschlag 25%"`
Nachtzuschlag40 float64 `json:"nachtzuschlag40" csv:"Nachtzuschlag 40%"`
}

View File

@ -1,122 +0,0 @@
package excelread
import (
"business-engine/internal/database"
"fmt"
"os"
"slices"
"strings"
"time"
"github.com/gocarina/gocsv"
)
func sortSliceByDate[T any](slice []T, getDate func(T) time.Time) {
compare := func(a, b T) int {
aDate := getDate(a)
bDate := getDate(b)
if aDate.Before(bDate) {
return -1
}
if aDate.After(bDate) {
return 1
}
return 0
}
slices.SortFunc(slice, compare)
}
// Zu kalkulieren
// - Puenktlichkeitspraemie
// - Tourengeld Anzahl
// - Tourengeld Bonus
func GeneratePayrollStatement(data EmployeeDataRow, input PayrollInputData) PayrollStatement {
isKTW := slices.IndexFunc(input.Plannedtimes, func(pt database.ListPlannedTimesForEmployeeInPeriodRow) bool {
return strings.Contains(pt.Department.String, "KTW")
}) >= 0 && slices.IndexFunc(input.Plannedtimes, func(pt database.ListPlannedTimesForEmployeeInPeriodRow) bool {
return strings.Contains(pt.Department.String, "Leitstelle")
}) == -1
statement := PayrollStatement{
Nachname: data.Nachname,
Vorname: data.Vorname,
Verpflegungspauschale: data.AuszahlenVerpflegungspauschaleTage,
VertragStundenlohn: input.ContractSalaryHour,
Vertragsstunden: data.IstSollVertragsStunden,
GearbeiteteStunden: data.GearbeiteteStunden,
GearbeiteteTage: data.GearbeiteteTage,
Krankheitstage: data.Krankheitstage,
Ueberstunden: data.IstSollPlusMinusStunden,
Wochenendzuschlag15: data.AuszahlenZuschlag15Stunden,
Sonntagszuschlag50: data.AuszahlenZuschlag50Stunden,
Nachtzuschlag25: data.AuszahlenZuschlag25Stunden,
Nachtzuschlag40: data.AuszahlenZuschlag40Stunden,
}
if data.Krankheitstage > 0 || !isKTW {
statement.GesundheitsbonusAbzug = "ja"
}
if isKTW && data.GenommeneUrlaubstage > 0 {
statement.Urlaubsgeld = data.GenommeneUrlaubstage
}
if isKTW && data.AuszahlenAnzahlPSchein > 0 {
statement.PScheinZulage = data.AuszahlenAnzahlPSchein
}
if isKTW && input.Tourengeld > 0 {
statement.Tourengeld = float64(input.Tourengeld)
}
if isKTW && len(input.Worktimes) > 0 && len(input.Plannedtimes) > 0 {
ptGetStartDate := func(pt database.ListPlannedTimesForEmployeeInPeriodRow) time.Time {
return *pt.StartDate
}
wtGetStartDate := func(pt database.WorkTime) time.Time {
return *pt.StartDate
}
sortSliceByDate(input.Plannedtimes, ptGetStartDate)
sortSliceByDate(input.Worktimes, wtGetStartDate)
var employeeOnTime []EmployeeOnTime
for _, pt := range input.Plannedtimes {
for _, wt := range input.Worktimes {
if isSameDay(ptGetStartDate(pt), wtGetStartDate(wt)) && (wtGetStartDate(wt).Before(ptGetStartDate(pt)) || wtGetStartDate(wt) == ptGetStartDate(pt)) {
statement.PuenktlichkeitspraemieAnzahl++
employeeOnTime = append(employeeOnTime, EmployeeOnTime{
EmployeeId: fmt.Sprintf("%s %s", data.Vorname, data.Nachname),
WorkTime: *wt.StartDate,
PlannedTime: *pt.StartDate,
IsOnTime: true,
})
}
}
}
if data.Vorname == "David" && data.Nachname == "Hastedt" {
GenerateEmployeesOnTime(employeeOnTime)
}
}
return statement
}
func GenerateEmployeesOnTime(employeesontime []EmployeeOnTime) error {
file, err := os.Create("employees_on_time.csv")
if err != nil {
return err
}
defer file.Close()
if err := gocsv.MarshalFile(&employeesontime, file); err != nil {
return err
}
return nil
}
// isSameDay prüft, ob zwei Zeitpunkte am selben Kalendertag liegen.
func isSameDay(t1, t2 time.Time) bool {
return t1.Year() == t2.Year() && t1.Month() == t2.Month() && t1.Day() == t2.Day()
}

View File

@ -1,210 +0,0 @@
package excelread
import (
"encoding/csv"
"fmt"
"log"
"os"
"strconv"
"strings"
)
// mapRowToStruct konvertiert eine einzelne CSV-Zeile (als []string)
// in die EmployeeDataRow-Struct.
func mapRowToStruct(row []string) EmployeeDataRow {
data := EmployeeDataRow{}
data.Personalnummer = parseInt(row[0])
data.Nachname = strings.TrimSpace(row[1])
data.Vorname = strings.TrimSpace(row[2])
data.Vertragsart = strings.TrimSpace(row[3])
data.HatPersonenbefoerderungsschein = strings.TrimSpace(row[4])
data.HatRechtAufZuschlaege = strings.TrimSpace(row[5])
data.Vertragsstart = strings.TrimSpace(row[6])
data.VertragstageProWoche = parseFloat(row[7])
data.VertragsstundenProWoche = parseFloat(row[8])
data.VertragsstundenMax = strings.TrimSpace(row[9])
data.UrlaubstageProJahr = parseFloat(row[10])
data.GearbeiteteTage = parseFloat(row[11])
data.GearbeiteteStunden = parseFloat(row[12])
data.GenommeneUrlaubstage = parseFloat(row[13])
data.GenommeneUrlaubsstunden = parseFloat(row[14])
data.Krankheitstage = parseFloat(row[15])
data.Krankheitsstunden = parseFloat(row[16])
// data.KrankengeldTage = parseFloat(row[17])
// data.KrankengeldStunden = parseFloat(row[18])
// data.UnbezahlterUrlaubTage = parseFloat(row[19])
// data.UnbezahlterUrlaubStunden = parseFloat(row[20])
data.IstSollGesamtTage = parseFloat(row[17])
data.IstSollGesamtStunden = parseFloat(row[18])
data.IstSollVertragsTage = parseFloat(row[19])
data.IstSollVertragsStunden = parseFloat(row[20])
data.IstSollPlusMinusStunden = parseFloat(row[21])
data.BerechnungMehrstunden = parseFloat(row[22])
data.BerechnungUeberstunden = parseFloat(row[23])
data.BerechnungAnzahlPSchein = parseFloat(row[24])
data.BerechnungKrankOhneAUTage = parseFloat(row[25])
data.BerechnungKrankOhneAUStunden = parseFloat(row[26])
data.BerechnungNachtzuschlag25Tage = parseFloat(row[27])
data.BerechnungNachtzuschlag25Stunden = parseFloat(row[28])
data.BerechnungNachtzuschlag40Tage = parseFloat(row[29])
data.BerechnungNachtzuschlag40Stunden = parseFloat(row[30])
data.BerechnungSonntagszuschlag50Tage = parseFloat(row[31])
data.BerechnungSonntagszuschlag50Stunden = parseFloat(row[32])
data.BerechnungVerpflegungspauschale14Tage = parseFloat(row[33])
data.BerechnungSamstagszuschlag15Tage = parseFloat(row[34])
data.BerechnungSamstagszuschlag15Stunden = parseFloat(row[35])
data.BerechnungZeitausgleichStart = parseFloat(row[36])
data.BerechnungZeitausgleichPlusMinus = parseFloat(row[37])
data.BerechnungZeitausgleichAenderungen = parseFloat(row[38])
data.BerechnungZeitausgleichAuszahlen = parseFloat(row[39])
data.BerechnungZeitausgleichJetzt = parseFloat(row[40])
data.SaldenUrlaubStart = parseFloat(row[41])
data.SaldenUrlaubAbbau = parseFloat(row[42])
data.SaldenUrlaubKorrektur = parseFloat(row[43])
data.SaldenUrlaubJetzt = parseFloat(row[44])
data.SaldenMehrstunden = parseFloat(row[45])
data.SaldenUeberstunden = parseFloat(row[46])
data.AuszahlenFahrtkostenKm = parseFloat(row[47])
data.AuszahlenVerpflegungspauschaleTage = parseFloat(row[48])
data.AuszahlenAnzahlPSchein = parseFloat(row[49])
data.AuszahlenZuschlag25Stunden = parseFloat(row[50])
data.AuszahlenZuschlag40Stunden = parseFloat(row[51])
data.AuszahlenZuschlag50Stunden = parseFloat(row[52])
data.AuszahlenZuschlag14Stunden = parseFloat(row[53])
data.AuszahlenZuschlag15Stunden = parseFloat(row[54])
return data
}
// map from: July 2025
// func mapRowToStruct(row []string) EmployeeDataRow {
// data := EmployeeDataRow{}
// data.Personalnummer = parseInt(row[0])
// data.Nachname = strings.TrimSpace(row[1])
// data.Vorname = strings.TrimSpace(row[2])
// data.Vertragsart = strings.TrimSpace(row[3])
// data.HatPersonenbefoerderungsschein = strings.TrimSpace(row[4])
// data.HatRechtAufZuschlaege = strings.TrimSpace(row[5])
// data.Vertragsstart = strings.TrimSpace(row[6])
// data.VertragstageProWoche = parseFloat(row[7])
// data.VertragsstundenProWoche = parseFloat(row[8])
// data.VertragsstundenMax = strings.TrimSpace(row[9])
// data.UrlaubstageProJahr = parseFloat(row[10])
// data.GearbeiteteTage = parseFloat(row[11])
// data.GearbeiteteStunden = parseFloat(row[12])
// data.GenommeneUrlaubstage = parseFloat(row[13])
// data.GenommeneUrlaubsstunden = parseFloat(row[14])
// data.Krankheitstage = parseFloat(row[15])
// data.Krankheitsstunden = parseFloat(row[16])
// data.KrankengeldTage = parseFloat(row[17])
// data.KrankengeldStunden = parseFloat(row[18])
// data.UnbezahlterUrlaubTage = parseFloat(row[19])
// data.UnbezahlterUrlaubStunden = parseFloat(row[20])
// data.IstSollGesamtTage = parseFloat(row[21])
// data.IstSollGesamtStunden = parseFloat(row[22])
// data.IstSollVertragsTage = parseFloat(row[23])
// data.IstSollVertragsStunden = parseFloat(row[24])
// data.IstSollPlusMinusStunden = parseFloat(row[25])
// data.BerechnungMehrstunden = parseFloat(row[26])
// data.BerechnungUeberstunden = parseFloat(row[27])
// data.BerechnungAnzahlPSchein = parseFloat(row[28])
// data.BerechnungKrankOhneAUTage = parseFloat(row[29])
// data.BerechnungKrankOhneAUStunden = parseFloat(row[30])
// data.BerechnungNachtzuschlag25Tage = parseFloat(row[31])
// data.BerechnungNachtzuschlag25Stunden = parseFloat(row[32])
// data.BerechnungNachtzuschlag40Tage = parseFloat(row[33])
// data.BerechnungNachtzuschlag40Stunden = parseFloat(row[34])
// data.BerechnungSonntagszuschlag50Tage = parseFloat(row[35])
// data.BerechnungSonntagszuschlag50Stunden = parseFloat(row[36])
// data.BerechnungVerpflegungspauschale14Tage = parseFloat(row[37])
// data.BerechnungSamstagszuschlag15Tage = parseFloat(row[38])
// data.BerechnungSamstagszuschlag15Stunden = parseFloat(row[39])
// data.BerechnungZeitausgleichStart = parseFloat(row[40])
// data.BerechnungZeitausgleichPlusMinus = parseFloat(row[41])
// data.BerechnungZeitausgleichAenderungen = parseFloat(row[42])
// data.BerechnungZeitausgleichAuszahlen = parseFloat(row[43])
// data.BerechnungZeitausgleichJetzt = parseFloat(row[44])
// data.SaldenUrlaubStart = parseFloat(row[45])
// data.SaldenUrlaubAbbau = parseFloat(row[46])
// data.SaldenUrlaubJetzt = parseFloat(row[47])
// data.SaldenMehrstunden = parseFloat(row[48])
// data.SaldenUeberstunden = parseFloat(row[49])
// data.AuszahlenFahrtkostenKm = parseFloat(row[50])
// data.AuszahlenVerpflegungspauschaleTage = parseFloat(row[51])
// data.AuszahlenAnzahlPSchein = parseFloat(row[52])
// data.AuszahlenZuschlag25Stunden = parseFloat(row[53])
// data.AuszahlenZuschlag40Stunden = parseFloat(row[54])
// data.AuszahlenZuschlag50Stunden = parseFloat(row[55])
// data.AuszahlenZuschlag14Stunden = parseFloat(row[56])
// data.AuszahlenZuschlag15Stunden = parseFloat(row[57])
// return data
// }
func ReadEmployeeData() []EmployeeDataRow {
content, err := os.ReadFile("daten.csv")
if err != nil {
log.Fatalf("Fehler beim Lesen der Datei: %v", err)
}
reader := csv.NewReader(strings.NewReader(string(content)))
reader.Comma = ';'
reader.FieldsPerRecord = -1
records, err := reader.ReadAll()
if err != nil {
log.Fatalf("Fehler beim Parsen der CSV: %v", err)
}
if len(records) < 5 {
log.Fatal("Die CSV-Datei hat nicht genügend Zeilen.")
}
dataRows := records[3 : len(records)-1]
var employees []EmployeeDataRow
for i, row := range dataRows {
fmt.Printf("%v", len(row))
if row[1] != "Shahrasebi" {
employee := mapRowToStruct(row)
employees = append(employees, employee)
fmt.Printf("Zeile %d geladen: %s, %s\n", i+4, employee.Nachname, employee.Vorname)
}
}
fmt.Printf("\n--- Insgesamt %d Mitarbeiterdatensätze erfolgreich geladen. ---\n", len(employees))
return employees
}
// Helfer-Funktion, um Strings mit Komma als Dezimaltrennzeichen in float64 umzuwandeln.
func parseFloat(s string) float64 {
s = strings.TrimSpace(s)
if s == "" {
return 0.0
}
// Ersetze das Komma durch einen Punkt für die Standard-Konvertierung
s = strings.Replace(s, ",", ".", 1)
val, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0.0 // Bei Fehler einfach 0 zurückgeben
}
return val
}
// Helfer-Funktion, um einen String in einen Integer umzuwandeln.
func parseInt(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
val, err := strconv.Atoi(s)
if err != nil {
return 0
}
return val
}

View File

@ -1,131 +0,0 @@
package excelread
import (
"business-engine/internal/database"
"fmt"
"time"
)
// EmployeeDataRow repräsentiert eine einzelne, vollständige Datenzeile aus der CSV.
type EmployeeDataRow struct {
Personalnummer int `json:"personalnummer"`
Nachname string `json:"nachname"`
Vorname string `json:"vorname"`
Vertragsart string `json:"vertragsart"`
HatPersonenbefoerderungsschein string `json:"hatPersonenbefoerderungsschein"`
HatRechtAufZuschlaege string `json:"hatRechtAufZuschlaege"`
Vertragsstart string `json:"vertragsstart"`
VertragstageProWoche float64 `json:"vertragstageProWoche"`
VertragsstundenProWoche float64 `json:"vertragsstundenProWoche"`
VertragsstundenMax string `json:"vertragsstundenMax"`
UrlaubstageProJahr float64 `json:"urlaubstageProJahr"`
GearbeiteteTage float64 `json:"gearbeiteteTage"`
GearbeiteteStunden float64 `json:"gearbeiteteStunden"`
GenommeneUrlaubstage float64 `json:"genommeneUrlaubstage"`
GenommeneUrlaubsstunden float64 `json:"genommeneUrlaubsstunden"`
Krankheitstage float64 `json:"krankheitstage"`
Krankheitsstunden float64 `json:"krankheitsstunden"`
KrankengeldTage float64 `json:"krankengeldTage"`
KrankengeldStunden float64 `json:"krankengeldStunden"`
UnbezahlterUrlaubTage float64 `json:"unbezahlterUrlaubTage"`
UnbezahlterUrlaubStunden float64 `json:"unbezahlterUrlaubStunden"`
EinbehaltungGesamtTage float64 `json:"einbehaltungGesamtTage"`
EinbehaltungGesamtStunden float64 `json:"einbehaltungGesamtStunden"`
IstSollGesamtTage float64 `json:"istSollGesamtTage"`
IstSollGesamtStunden float64 `json:"istSollGesamtStunden"`
IstSollVertragsTage float64 `json:"istSollVertragsTage"`
IstSollVertragsStunden float64 `json:"istSollVertragsStunden"`
IstSollPlusMinusStunden float64 `json:"istSollPlusMinusStunden"`
BerechnungMehrstunden float64 `json:"berechnungMehrstunden"`
BerechnungUeberstunden float64 `json:"berechnungUeberstunden"`
BerechnungAnzahlPSchein float64 `json:"berechnungAnzahlPSchein"`
BerechnungKrankOhneAUTage float64 `json:"berechnungKrankOhneAUTage"`
BerechnungKrankOhneAUStunden float64 `json:"berechnungKrankOhneAUStunden"`
BerechnungNachtzuschlag25Tage float64 `json:"berechnungNachtzuschlag25Tage"`
BerechnungNachtzuschlag25Stunden float64 `json:"berechnungNachtzuschlag25Stunden"`
BerechnungNachtzuschlag40Tage float64 `json:"berechnungNachtzuschlag40Tage"`
BerechnungNachtzuschlag40Stunden float64 `json:"berechnungNachtzuschlag40Stunden"`
BerechnungSonntagszuschlag50Tage float64 `json:"berechnungSonntagszuschlag50Tage"`
BerechnungSonntagszuschlag50Stunden float64 `json:"berechnungSonntagszuschlag50Stunden"`
BerechnungVerpflegungspauschale14Tage float64 `json:"berechnungVerpflegungspauschale14Tage"`
BerechnungSamstagszuschlag15Tage float64 `json:"berechnungSamstagszuschlag15Tage"`
BerechnungSamstagszuschlag15Stunden float64 `json:"berechnungSamstagszuschlag15Stunden"`
BerechnungZeitausgleichStart float64 `json:"berechnungZeitausgleichStart"`
BerechnungZeitausgleichPlusMinus float64 `json:"berechnungZeitausgleichPlusMinus"`
BerechnungZeitausgleichAenderungen float64 `json:"berechnungZeitausgleichAenderungen"`
BerechnungZeitausgleichAuszahlen float64 `json:"berechnungZeitausgleichAuszahlen"`
BerechnungZeitausgleichJetzt float64 `json:"berechnungZeitausgleichJetzt"`
SaldenUrlaubStart float64 `json:"saldenUrlaubStart"`
SaldenUrlaubAbbau float64 `json:"saldenUrlaubAbbau"`
SaldenUrlaubKorrektur float64 `json:"saldenUrlaubKorrektur"`
SaldenUrlaubJetzt float64 `json:"saldenUrlaubJetzt"`
SaldenMehrstunden float64 `json:"saldenMehrstunden"`
SaldenUeberstunden float64 `json:"saldenUeberstunden"`
AuszahlenFahrtkostenKm float64 `json:"auszahlenFahrtkostenKm"`
AuszahlenVerpflegungspauschaleTage float64 `json:"auszahlenVerpflegungspauschaleTage"`
AuszahlenAnzahlPSchein float64 `json:"auszahlenAnzahlPSchein"`
AuszahlenZuschlag25Stunden float64 `json:"auszahlenZuschlag25Stunden"`
AuszahlenZuschlag40Stunden float64 `json:"auszahlenZuschlag40Stunden"`
AuszahlenZuschlag50Stunden float64 `json:"auszahlenZuschlag50Stunden"`
AuszahlenZuschlag14Stunden float64 `json:"auszahlenZuschlag14Stunden"`
AuszahlenZuschlag15Stunden float64 `json:"auszahlenZuschlag15Stunden"`
}
type PayrollInputData struct {
ContractSalaryHour float64
Tourengeld float64
Worktimes []database.WorkTime
Plannedtimes []database.ListPlannedTimesForEmployeeInPeriodRow
}
func NewPayrollInputData(salaryHour float64, tourengeld float64, worktimes []database.WorkTime, plannedtimes []database.ListPlannedTimesForEmployeeInPeriodRow) PayrollInputData {
return PayrollInputData{
ContractSalaryHour: salaryHour,
Tourengeld: tourengeld,
Worktimes: worktimes,
Plannedtimes: plannedtimes,
}
}
type PayrollStatement struct {
Nachname string `json:"nachname" csv:"Nachname"`
Vorname string `json:"vorname" csv:"Vorname"`
Verpflegungspauschale float64 `json:"verpflegungspauschale" csv:"Verpflegungspauschale"`
VertragStundenlohn float64 `json:"stundenlohn" csv:"Vertr. Stundenlohn"`
Vertragsstunden float64 `json:"vertragsstunden" csv:"Arbeitsstunden lt. Vertrag"`
GearbeiteteStunden float64 `json:"gearbeiteteStunden" csv:"Gearbeitet Stunden"`
GearbeiteteTage float64 `json:"gearbeiteteTage" csv:"Gearbeitet Tage"`
PuenktlichkeitspraemieAnzahl float64 `json:"puenktlichkeitspraemieAnzahl" csv:"Pünktlichkeitsprämie anzahl"`
Krankheitstage float64 `json:"krankheitstage" csv:"Krankheitstage x 8 Stunden"`
Ueberstunden float64 `json:"ueberstunden" csv:"davon Überstunden"`
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)"`
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%"`
Nachtzuschlag25 float64 `json:"nachtzuschlag25" csv:"Nachtzuschlag 25%"`
Nachtzuschlag40 float64 `json:"nachtzuschlag40" csv:"Nachtzuschlag 40%"`
}
type EmployeeOnTime struct {
EmployeeId string `csv:"EmployeeId"`
WorkTime time.Time `csv:"Arbeitszeit"`
PlannedTime time.Time `csv:"Schichtplanung"`
IsOnTime bool `csv:"Pünktlich?"`
}
func NewEmployeeOnTime(employeeId string, worktime, plannedtime time.Time) EmployeeOnTime {
loc, err := time.LoadLocation("Europe/Berlin")
if err != nil {
fmt.Println("Fehler beim Laden der Zeitzone:", err)
}
return EmployeeOnTime{
EmployeeId: employeeId,
WorkTime: worktime.In(loc),
PlannedTime: plannedtime.In(loc),
IsOnTime: worktime.Before(plannedtime) || worktime == plannedtime,
}
}

View File

@ -1,7 +1,7 @@
package gencsv
import (
excelread "business-engine/internal/excel_read"
"business-engine/internal/domain"
"encoding/csv"
"io"
"os"
@ -9,7 +9,7 @@ import (
"github.com/gocarina/gocsv"
)
func GeneratePayrollStatementCSV(stmts []excelread.PayrollStatement) error {
func GeneratePayrollStatementCSV(stmts []domain.PayrollStatement) error {
file, err := os.Create("payroll_statement.csv")
if err != nil {
return err
@ -27,16 +27,3 @@ func GeneratePayrollStatementCSV(stmts []excelread.PayrollStatement) error {
}
return nil
}
func GenerateEmployeesOnTime(employeesontime []excelread.EmployeeOnTime) error {
file, err := os.Create("employees_on_time.csv")
if err != nil {
return err
}
defer file.Close()
if err := gocsv.MarshalFile(&employeesontime, file); err != nil {
return err
}
return nil
}

View File

@ -1,7 +1,7 @@
package genpdf
import (
excelread "business-engine/internal/excel_read"
domain "business-engine/internal/domain"
"fmt"
"slices"
"time"
@ -35,7 +35,7 @@ func (item LineItem) Total() float64 {
return item.Anzahl * item.Betrag
}
func GenerateSalaryStatement(stmt excelread.PayrollStatement, filename string, month time.Time) error {
func GenerateSalaryStatement(stmt domain.PayrollStatement, filename string, month time.Time) error {
pdf := fpdf.New("P", "mm", "A4", "")
pdf.AddUTF8Font("DejaVu", "", "DejaVuSans.ttf")
pdf.AddUTF8Font("DejaVu", "B", "DejaVuSans-Bold.ttf")
@ -86,7 +86,7 @@ func GenerateSalaryStatement(stmt excelread.PayrollStatement, filename string, m
return nil
}
func genLineItemsFromStatement(stmt excelread.PayrollStatement) []LineItem {
func genLineItemsFromStatement(stmt domain.PayrollStatement) []LineItem {
var items []LineItem
// --- Gehalt & Stunden ---

View File

@ -1,14 +0,0 @@
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
}

View File

@ -1,7 +1,7 @@
package payroll
import (
excelread "business-engine/internal/excel_read"
"business-engine/internal/domain"
"business-engine/internal/gencsv"
"business-engine/internal/genpdf"
"encoding/json"
@ -24,38 +24,40 @@ func NewEmployeeHandler(service Service, logger *slog.Logger) *EmployeeHandler {
}
}
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)
func (h *EmployeeHandler) RunPayroll(w http.ResponseWriter, r *http.Request) {
month := time.Date(2025, time.November, 1, 0, 0, 0, 0, time.UTC)
employees, err := h.service.FetchActiveEmployees(r.Context())
if err != nil {
h.logger.Error("failed to get employees", "error", err)
h.logger.Error("failed to fetch active employees", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
h.logger.Info(fmt.Sprintf("found %d active employees", len(employees)))
allZuschlaege, err := h.service.zuschlagParser.ParseFile("daten.csv")
if err != nil {
h.logger.Error("failed to load data from csv export", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
var data []domain.PayrollInput
var payrollStatements []domain.PayrollStatement
for _, employee := range employees {
h.logger.Info("loading payroll input for employee", "name", fmt.Sprintf("%s %s", employee.Firstname, employee.Surname))
input, err := h.service.LoadPayrollInput(r.Context(), month.AddDate(0, -1, 0), employee, allZuschlaege)
if err != nil {
h.logger.Error("failed to fetch payroll input", "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)
statement := h.service.GeneratePayrollStatement(input)
payrollStatements = append(payrollStatements, statement)
data = append(data, input)
}
slices.SortFunc(payrollStatements, func(a, b excelread.PayrollStatement) int {
slices.SortFunc(payrollStatements, func(a, b domain.PayrollStatement) int {
if a.Tourengeld < b.Tourengeld {
return 1
}
@ -70,7 +72,7 @@ func (h *EmployeeHandler) RunPayroll(w http.ResponseWriter, r *http.Request) {
payrollStatements[2].TourengeldBonus = 300
h.logger.Info("generating csv salary statements")
err := gencsv.GeneratePayrollStatementCSV(payrollStatements)
err = gencsv.GeneratePayrollStatementCSV(payrollStatements)
if err != nil {
h.logger.Error("could not generate payroll statement csv", "error", err)
}
@ -83,8 +85,57 @@ func (h *EmployeeHandler) RunPayroll(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payrollStatements); err != nil {
if err := json.NewEncoder(w).Encode(data); err != nil {
h.logger.Error("failed to encode response", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
// func (h *EmployeeHandler) ReadEmployeeData(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)
// }
// }

View File

@ -1,29 +0,0 @@
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,
}
}

View File

@ -0,0 +1,185 @@
package payroll
import (
"encoding/csv"
"fmt"
"io"
"log/slog"
"os"
"strconv"
"strings"
)
type ZuschlagData struct {
Nacht25 float64
Nacht40 float64
Sonntag50 float64
Samstag15 float64
Feiertag125 float64
Feiertag150 float64
Verpflegung14 float64
}
type ZuschlagParser struct {
logger *slog.Logger
knownIdentifiers map[string]string
knownColumns map[string]string
}
func NewZuschlagParser(logger *slog.Logger) *ZuschlagParser {
return &ZuschlagParser{
logger: logger,
knownIdentifiers: map[string]string{
"Nachname": "Lastname",
"Vorname": "Firstname",
},
knownColumns: map[string]string{
"25%": "Nacht25",
"40%": "Nacht40",
"50%": "Sonntag50",
"14%": "Verpflegung14",
"15%": "Samstag15",
"125%": "Feiertag125",
"150%": "Feiertag150",
},
}
}
func (p *ZuschlagParser) ParseFile(filepath string) (map[string]ZuschlagData, error) {
file, err := os.Open(filepath)
if err != nil {
return nil, fmt.Errorf("zuschlag parser: failed to open file: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.Comma = ','
reader.LazyQuotes = true
if _, err := reader.Read(); err != nil {
return nil, fmt.Errorf("zuschlag parser: an error occured while reading header 1: %w", err)
}
headerRow, err := reader.Read()
if err != nil {
return nil, fmt.Errorf("zuschlag parser: fehler beim lesen von header 2 (zuschlag row): %w", err)
}
identifierHeaderRow, err := reader.Read()
if err != nil {
return nil, fmt.Errorf("zuschlag parser: fehler beim lesen von header 3 (identifier row): %w", err)
}
identifierIndex := p.buildIndex(identifierHeaderRow, p.knownIdentifiers)
zuschlagIndex := p.buildIndex(headerRow, p.knownColumns)
runtimeIndex := make(map[string]int)
for k, v := range identifierIndex {
runtimeIndex[k] = v
}
for k, v := range zuschlagIndex {
runtimeIndex[k] = v
}
results := make(map[string]ZuschlagData)
for {
row, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
p.logger.Warn("zuschlag parser: zeile übersprungen (lesefehler)", "error", err)
continue
}
compoundKey, data, err := p.extractRowData(row, runtimeIndex)
if err != nil {
p.logger.Warn("zuschlag parser: zeile übersprungen (extraktionsfehler)", "error", err)
continue
}
results[compoundKey] = data
}
p.logger.Info("Zuschlag-Datei erfolgreich geparst", "records", len(results))
return results, nil
}
// buildAndValidateIndex erstellt die Index-Map und validiert Pflichtfelder.
// (map[internerName]csvSpaltenIndex)
func (p *ZuschlagParser) buildIndex(headerRow []string, knownMap map[string]string) map[string]int {
zuschlagIndex := make(map[string]int)
for i, colNameFromFile := range headerRow {
trimmedName := strings.TrimSpace(colNameFromFile)
if internalName, ok := knownMap[trimmedName]; ok {
zuschlagIndex[internalName] = i
p.logger.Debug("zuschlag parser: index gemappt", "header_name", trimmedName, "internal_name", internalName, "index", i)
}
}
return zuschlagIndex
}
// extractRowData liest eine einzelne Datenzeile basierend auf dem Index.
func (p *ZuschlagParser) extractRowData(row []string, index map[string]int) (string, ZuschlagData, error) {
var data ZuschlagData
lastname := row[index["Lastname"]]
firstname := row[index["Firstname"]]
if lastname == "" || firstname == "" {
return "", data, fmt.Errorf("leerer name in zeile (lastname: '%s', firstname: '%s')", lastname, firstname)
}
compoundKey := fmt.Sprintf("%s,%s", lastname, firstname)
for internalName, colIdx := range index {
if internalName == "Lastname" || internalName == "Firstname" {
continue
}
if colIdx >= len(row) {
p.logger.Warn("zuschlag parser: zeile kürzer als header-index", "key", compoundKey, "missing_index", colIdx)
continue
}
valueStr := row[colIdx]
valueFloat := p.parseGermanFloat(valueStr)
switch internalName {
case "Nacht25":
data.Nacht25 = valueFloat
case "Nacht40":
data.Nacht40 = valueFloat
case "Sonntag50":
data.Sonntag50 = valueFloat
case "Verpflegung14":
data.Verpflegung14 = valueFloat
case "Samstag15":
data.Samstag15 = valueFloat
case "Feiertag125":
data.Feiertag125 = valueFloat
case "Feiertag150":
data.Feiertag150 = valueFloat
}
}
return compoundKey, data, nil
}
// parseGermanFloat bereinigt und parst deutsche Währungs-/Zahlenformate.
func (p *ZuschlagParser) parseGermanFloat(valueStr string) float64 {
valueStr = strings.ReplaceAll(valueStr, ".", ",")
valueStr = strings.TrimSpace(valueStr)
valueStr = strings.ReplaceAll(valueStr, ",", ".")
if valueStr == "" {
return 0.0
}
f, err := strconv.ParseFloat(valueStr, 64)
if err != nil {
p.logger.Warn("zuschlag parser: float-parse-fehler", "input", valueStr, "error", err)
return 0.0
}
return f
}

View File

@ -1,21 +0,0 @@
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),
},
}
}

View File

@ -2,7 +2,7 @@ package payroll
import (
"business-engine/internal/database"
excelread "business-engine/internal/excel_read"
"business-engine/internal/domain"
"context"
"fmt"
"log/slog"
@ -13,165 +13,280 @@ import (
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
GetWeeklyTimeSummaryForEmployee(ctx context.Context, args database.GetWeeklyTimeSummaryForEmployeeParams) ([]database.GetWeeklyTimeSummaryForEmployeeRow, error)
EvaluatePunctualityForEmployee(ctx context.Context, args database.EvaluatePunctualityForEmployeeParams) (int64, error)
IsEmployeesDepartmentKTW(ctx context.Context, args database.IsEmployeesDepartmentKTWParams) (bool, error)
CountVerpflegungspauschale(ctx context.Context, args database.CountVerpflegungspauschaleParams) (int64, error)
}
type Service struct {
repo Repository
engine Engine
logger *slog.Logger
zuschlagParser *ZuschlagParser
}
func NewService(repo Repository, logger *slog.Logger) *Service {
func NewService(repo Repository, logger *slog.Logger, zParser *ZuschlagParser) *Service {
return &Service{
repo: repo,
logger: logger,
zuschlagParser: zParser,
}
}
func (s *Service) FetchPayrollInput(ctx context.Context, month time.Time, employeeFirstname, employeeLastname string) (excelread.PayrollInputData, error) {
s.logger.Info("fetching payroll input", "month", month)
func (s *Service) FetchActiveEmployees(ctx context.Context) ([]database.ListActiveEmployeesRow, error) {
s.logger.Info("fetching active employees")
employeeIDs, err := s.repo.GetEmployeeByName(ctx, database.GetEmployeeByNameParams{
Firstname: employeeFirstname,
Lastname: employeeLastname,
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,
Verpflegungspauschale: float64(input.Verpflegungspauschale),
Wochenendzuschlag15: input.ZuschlagSamstag,
Sonntagszuschlag50: input.ZuschlagSonntag,
Nachtzuschlag25: input.ZuschlagNacht25,
Nachtzuschlag40: input.ZuschlagNacht40,
Feiertagszuschlag125: input.ZuschlagFeiertag125,
Feiertagszuschlag150: input.ZuschlagFeiertag150,
}
if input.SickDays > 0 || !input.DepartmentIsKtw {
statement.GesundheitsbonusAbzug = "ja"
}
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
}
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)
input := domain.PayrollInput{
Firstname: employee.Firstname,
Surname: employee.Surname,
HourlyRate: employee.ContractSalaryHour,
ContractHours: int64(employee.ContractHoursWeek),
DepartmentIsKtw: isKtw,
WorkedHours: metrics.WorkedHours,
WorkedDays: int64(metrics.WorkedDays),
SickDays: int64(metrics.SickHours / 8),
HolidayDays: int64(metrics.HolidayDays),
OvertimeHours: metrics.OvertimeHours,
TotalHours: metrics.TotalHours,
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)
// (Kein Fehler, der Mitarbeiter hatte vielleicht keine Zuschläge)
}
return input, nil
}
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 {
s.logger.Error("could not find employee with that name", "firstname", employeeFirstname, "lastname", employeeLastname, "error", err)
return excelread.PayrollInputData{}, err
return 0, fmt.Errorf("failed to count VerpflegungsPauschale: %w", err)
}
return verpflegungsPauschale, nil
}
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)
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 := employee.ContractHoursWeek / 5
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)
tourengeld, err := s.repo.CountTourengeldAmountInPeriod(ctx, database.CountTourengeldAmountInPeriodParams{
StartDate: startOfMonth,
EndDate: endOfMonth,
EmployeeID: pgtype.Text{
String: employeeIDs.DispoliveID,
String: dlEmployeeId,
Valid: true,
},
})
if err != nil {
s.logger.Error("could not count tourengeld for employee", "employeeId", employeeIDs.DyflexisID, "error", err)
return 0.0, fmt.Errorf("failed to count tourengeld %w", err)
}
return tourengeld, nil
}
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 {
func beginningOfMonth(date time.Time) time.Time {
return date.AddDate(0, 0, -date.Day()+1)
}
func EndOfMonth(date time.Time) time.Time {
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,
23, 59, 59, 999999999,
eom.Location(),
)
}

View File

@ -23,4 +23,15 @@ sql:
- db_type: "pg_catalog.timestamptz"
nullable: true
go_type:
type: "*time.Time"
type: "time.Time"
pointer: true
- db_type: bool
go_type:
import: ""
type: bool
- db_type: bool
go_type:
import: ""
type: bool
pointer: true
nullable: true