Compare commits
16 Commits
fb1cb5d178
...
2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 085287c7a5 | |||
| c29a952e1d | |||
| b12a467ef9 | |||
| 8bb1777519 | |||
| f21ce9a3c3 | |||
| b4bf550863 | |||
| 10df10a606 | |||
| 23896e4f08 | |||
| 7e54800bc3 | |||
| 61ce5aab3a | |||
| 1d7b563a6d | |||
| 46218f9bca | |||
| 8911165c4b | |||
| 2d8747c971 | |||
| 38322a64cf | |||
| 6b0b8906a9 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,7 +30,8 @@ DB/pg_data
|
||||
|
||||
.env.*
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
Docker/config
|
||||
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
@@ -35,7 +35,7 @@ func autoLogout(w http.ResponseWriter) {
|
||||
fmt.Printf("Error logging out user %v\n", err)
|
||||
} else {
|
||||
loggedOutUsers = append(loggedOutUsers, user)
|
||||
log.Printf("Automaticaly logged out user %s, %s ", user.Name, user.Vorname)
|
||||
log.Printf("Automaticaly logged out user %d ", user.PersonalNummer)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,66 +21,7 @@ import (
|
||||
const DE_DATE string = "02.01.2006"
|
||||
const FILE_YEAR_MONTH string = "2006_01"
|
||||
|
||||
func convertDaysToTypst(days []models.IWorkDay, u models.User) ([]typstDay, error) {
|
||||
var typstDays []typstDay
|
||||
for _, day := range days {
|
||||
var thisTypstDay typstDay
|
||||
work, pause, overtime := day.GetTimes(u, models.WorktimeBaseDay, false)
|
||||
workVirtual := day.GetWorktime(u, models.WorktimeBaseDay, true)
|
||||
overtime = workVirtual - u.ArbeitszeitProWocheFrac(0.2)
|
||||
thisTypstDay.Date = day.Date().Format(DE_DATE)
|
||||
thisTypstDay.Worktime = helper.FormatDurationFill(workVirtual, true)
|
||||
thisTypstDay.Pausetime = helper.FormatDurationFill(pause, true)
|
||||
thisTypstDay.Overtime = helper.FormatDurationFill(overtime, true)
|
||||
thisTypstDay.IsFriday = day.Date().Weekday() == time.Friday
|
||||
|
||||
if workVirtual > work {
|
||||
thisTypstDay.Kurzarbeit = helper.FormatDurationFill(workVirtual-work, true)
|
||||
} else {
|
||||
thisTypstDay.Kurzarbeit = helper.FormatDurationFill(0, true)
|
||||
}
|
||||
|
||||
thisTypstDay.DayParts = convertDayToTypstDayParts(day, u)
|
||||
typstDays = append(typstDays, thisTypstDay)
|
||||
}
|
||||
return typstDays, nil
|
||||
}
|
||||
|
||||
func convertDayToTypstDayParts(day models.IWorkDay, user models.User) []typstDayPart {
|
||||
var typstDayParts []typstDayPart
|
||||
switch day.Type() {
|
||||
case models.DayTypeWorkday:
|
||||
workDay, _ := day.(*models.WorkDay)
|
||||
for i := 0; i < len(workDay.Bookings); i += 2 {
|
||||
var typstDayPart typstDayPart
|
||||
typstDayPart.BookingFrom = workDay.Bookings[i].Timestamp.Format("15:04")
|
||||
if i+1 < len(workDay.Bookings) {
|
||||
typstDayPart.BookingTo = workDay.Bookings[i+1].Timestamp.Format("15:04")
|
||||
} else {
|
||||
typstDayPart.BookingTo = workDay.Bookings[i].Timestamp.Format("15:04")
|
||||
}
|
||||
typstDayPart.WorkType = workDay.Bookings[i].BookingType.Name
|
||||
typstDayPart.IsWorkDay = true
|
||||
typstDayParts = append(typstDayParts, typstDayPart)
|
||||
}
|
||||
if day.IsKurzArbeit() && len(workDay.Bookings) > 0 {
|
||||
tsFrom, tsTo := workDay.GenerateKurzArbeitBookings(user)
|
||||
typstDayParts = append(typstDayParts, typstDayPart{
|
||||
BookingFrom: tsFrom.Format("15:04"),
|
||||
BookingTo: tsTo.Format("15:04"),
|
||||
WorkType: "Kurzarbeit",
|
||||
IsWorkDay: true,
|
||||
})
|
||||
}
|
||||
case models.DayTypeCompound:
|
||||
for _, c := range day.(*models.CompoundDay).DayParts {
|
||||
typstDayParts = append(typstDayParts, convertDayToTypstDayParts(c, user)...)
|
||||
}
|
||||
default:
|
||||
typstDayParts = append(typstDayParts, typstDayPart{IsWorkDay: false, WorkType: day.ToString()})
|
||||
}
|
||||
return typstDayParts
|
||||
}
|
||||
var PDF_DIRECTORY = helper.GetEnv("PDF_PATH", "/doc/")
|
||||
|
||||
func PDFCreateController(w http.ResponseWriter, r *http.Request) {
|
||||
helper.RequiresLogin(Session, w, r)
|
||||
@@ -101,14 +42,16 @@ func PDFCreateController(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
n := 0
|
||||
for _, e := range employes {
|
||||
if user.IsSuperior(e) {
|
||||
employes[n] = e
|
||||
n++
|
||||
if !helper.IsDebug() {
|
||||
n := 0
|
||||
for _, e := range employes {
|
||||
if user.IsSuperior(e) {
|
||||
employes[n] = e
|
||||
n++
|
||||
}
|
||||
}
|
||||
employes = employes[:n]
|
||||
}
|
||||
employes = employes[:n]
|
||||
|
||||
reportData := createReports(employes, startDate)
|
||||
|
||||
@@ -119,8 +62,9 @@ func PDFCreateController(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Warn("Could not create pdf report", slog.Any("Error", err))
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=Monatsabrechnung_%s", startDate.Format(FILE_YEAR_MONTH)))
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=Monatsabrechnung_%s.pdf", startDate.Format(FILE_YEAR_MONTH)))
|
||||
output.WriteTo(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "download":
|
||||
@@ -131,11 +75,12 @@ func PDFCreateController(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
output, err := zipPfd(pdfReports, &reportData)
|
||||
if err != nil {
|
||||
slog.Warn("Could not create pdf report", slog.Any("Error", err))
|
||||
slog.Warn("Could not zip pdf reports", slog.Any("Error", err))
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachement; filename=Monatsabrechnung_%s", startDate.Format(FILE_YEAR_MONTH)))
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachement; filename=Monatsabrechnung_%s.zip", startDate.Format(FILE_YEAR_MONTH)))
|
||||
output.WriteTo(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -145,6 +90,75 @@ func PDFCreateController(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func convertDaysToTypst(days []models.IWorkDay, u models.User, weekbase models.WorktimeBase) ([]typstDay, error) {
|
||||
var typstDays []typstDay
|
||||
for i, day := range days {
|
||||
if !day.IsSubmittedAndAccepted() && !helper.IsDebug() {
|
||||
continue
|
||||
}
|
||||
|
||||
var thisTypstDay typstDay
|
||||
workVirtual, pause, overtime := day.GetTimes(u, weekbase, true)
|
||||
overtime = workVirtual - u.ArbeitszeitProWocheFrac(0.2)
|
||||
|
||||
if day.Type() == models.DayTypeHoliday {
|
||||
// workVirtual = 0
|
||||
overtime = 0
|
||||
}
|
||||
thisTypstDay.Date = day.Date().Format(DE_DATE)
|
||||
thisTypstDay.Worktime = helper.FormatDurationFill(workVirtual, true)
|
||||
thisTypstDay.Pausetime = helper.FormatDurationFill(pause, true)
|
||||
thisTypstDay.Overtime = helper.FormatDurationFill(overtime, true)
|
||||
thisTypstDay.IsFriday = i == len(days)-1
|
||||
|
||||
if work := day.GetWorktime(u, weekbase, false); workVirtual > work {
|
||||
thisTypstDay.Kurzarbeit = helper.FormatDurationFill(workVirtual-work, true)
|
||||
} else {
|
||||
thisTypstDay.Kurzarbeit = helper.FormatDurationFill(0, true)
|
||||
}
|
||||
|
||||
thisTypstDay.DayParts = convertDayToTypstDayParts(day, u, weekbase)
|
||||
typstDays = append(typstDays, thisTypstDay)
|
||||
}
|
||||
return typstDays, nil
|
||||
}
|
||||
|
||||
func convertDayToTypstDayParts(day models.IWorkDay, user models.User, weekBase models.WorktimeBase) []typstDayPart {
|
||||
var typstDayParts []typstDayPart
|
||||
switch day.Type() {
|
||||
case models.DayTypeWorkday:
|
||||
workDay, _ := day.(*models.WorkDay)
|
||||
for i := 0; i < len(workDay.Bookings); i += 2 {
|
||||
var typstDayPart typstDayPart
|
||||
typstDayPart.BookingFrom = workDay.Bookings[i].Timestamp.Format("15:04")
|
||||
if i+1 < len(workDay.Bookings) {
|
||||
typstDayPart.BookingTo = workDay.Bookings[i+1].Timestamp.Format("15:04")
|
||||
} else {
|
||||
typstDayPart.BookingTo = workDay.Bookings[i].Timestamp.Format("15:04")
|
||||
}
|
||||
typstDayPart.WorkType = workDay.Bookings[i].BookingType.Name
|
||||
typstDayPart.IsWorkDay = true
|
||||
typstDayParts = append(typstDayParts, typstDayPart)
|
||||
}
|
||||
if day.IsKurzArbeit() {
|
||||
tsFrom, tsTo := workDay.GenerateKurzArbeitBookings(user, weekBase)
|
||||
typstDayParts = append(typstDayParts, typstDayPart{
|
||||
BookingFrom: tsFrom.Format("15:04"),
|
||||
BookingTo: tsTo.Format("15:04"),
|
||||
WorkType: "Kurzarbeit",
|
||||
IsWorkDay: true,
|
||||
})
|
||||
}
|
||||
case models.DayTypeCompound:
|
||||
for _, c := range day.(*models.CompoundDay).DayParts {
|
||||
typstDayParts = append(typstDayParts, convertDayToTypstDayParts(c, user, weekBase)...)
|
||||
}
|
||||
default:
|
||||
typstDayParts = append(typstDayParts, typstDayPart{IsWorkDay: false, WorkType: day.ToString()})
|
||||
}
|
||||
return typstDayParts
|
||||
}
|
||||
|
||||
func createReports(employes []models.User, startDate time.Time) []typstData {
|
||||
startDate = helper.GetFirstOfMonth(startDate)
|
||||
endDate := startDate.AddDate(0, 1, -1)
|
||||
@@ -161,37 +175,49 @@ func createReports(employes []models.User, startDate time.Time) []typstData {
|
||||
}
|
||||
|
||||
func createEmployeReport(employee models.User, startDate, endDate time.Time) (typstData, error) {
|
||||
publicHolidays, err := models.GetHolidaysFromTo(startDate, endDate)
|
||||
targetHoursThisMonth := employee.ArbeitszeitProWocheFrac(.2) * time.Duration(helper.GetWorkingDays(startDate, endDate)-len(publicHolidays))
|
||||
workDaysThisMonth := models.GetDays(employee, startDate, endDate.AddDate(0, 0, 1), false)
|
||||
|
||||
slog.Debug("Baseline Working hours", "targetHours", targetHoursThisMonth.Hours())
|
||||
// publicHolidays, _ := models.GetHolidaysFromTo(startDate, endDate)
|
||||
targetHoursThisMonth := employee.ArbeitszeitProWocheFrac(.2) * time.Duration(helper.GetWorkingDays(startDate, endDate)) //-len(publicHolidays)
|
||||
mondaysThisMonth := helper.GetMondays(helper.GenerateDateRange(startDate, endDate), false)
|
||||
|
||||
var weeks []models.WorkWeek
|
||||
var workHours, kurzarbeitHours time.Duration
|
||||
for _, day := range workDaysThisMonth {
|
||||
tmpvirtualHours := day.GetWorktime(employee, models.WorktimeBaseDay, true)
|
||||
tmpactualHours := day.GetWorktime(employee, models.WorktimeBaseDay, false)
|
||||
if day.IsKurzArbeit() && tmpvirtualHours > tmpactualHours {
|
||||
slog.Debug("Adding kurzarbeit to workday", "day", day.Date())
|
||||
kurzarbeitHours += tmpvirtualHours - tmpactualHours
|
||||
}
|
||||
workHours += tmpvirtualHours
|
||||
}
|
||||
worktimeBalance := workHours - targetHoursThisMonth
|
||||
|
||||
typstDays, err := convertDaysToTypst(workDaysThisMonth, employee)
|
||||
for _, monday := range mondaysThisMonth {
|
||||
var week models.WorkWeek
|
||||
if monday.After(startDate) {
|
||||
week = models.NewWorkWeekSimple(employee, monday, true)
|
||||
} else if startDate.Sub(monday) < time.Hour*24*6 {
|
||||
week = models.NewWorkWeek(employee, startDate, monday.Add(6*24*time.Hour), true)
|
||||
}
|
||||
workHours += week.WorktimeVirtual
|
||||
kurzarbeitHours += week.Kurzarbeit
|
||||
weeks = append(weeks, week)
|
||||
}
|
||||
|
||||
monthOvertime := workHours - targetHoursThisMonth
|
||||
totalOvertime, err := employee.GetReportedOvertime(endDate)
|
||||
|
||||
var typstDays []typstDay
|
||||
for _, week := range weeks {
|
||||
weekTypstDays, err := convertDaysToTypst(week.Days, employee, week.WeekBase)
|
||||
if err != nil {
|
||||
slog.Error("Error converting days into typst", "error", err)
|
||||
continue
|
||||
}
|
||||
typstDays = append(typstDays, weekTypstDays...)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Warn("Failed to convert to days", slog.Any("error", err))
|
||||
return typstData{}, err
|
||||
slog.Error("Cannot retrieve total Overtime", "Error", err)
|
||||
}
|
||||
|
||||
metadata := typstMetadata{
|
||||
EmployeeName: fmt.Sprintf("%s %s", employee.Vorname, employee.Name),
|
||||
TimeRange: fmt.Sprintf("%s - %s", startDate.Format(DE_DATE), endDate.Format(DE_DATE)),
|
||||
Overtime: helper.FormatDurationFill(worktimeBalance, true),
|
||||
Overtime: helper.FormatDurationFill(monthOvertime, true),
|
||||
WorkTime: helper.FormatDurationFill(workHours, true),
|
||||
Kurzarbeit: helper.FormatDurationFill(kurzarbeitHours, true),
|
||||
OvertimeTotal: "",
|
||||
OvertimeTotal: helper.FormatDurationFill(totalOvertime+monthOvertime, true),
|
||||
CurrentTimestamp: time.Now().Format("02.01.2006 - 15:04 Uhr"),
|
||||
}
|
||||
return typstData{Meta: metadata, Days: typstDays, FileName: fmt.Sprintf("%s_%s.pdf", startDate.Format(FILE_YEAR_MONTH), employee.Name)}, nil
|
||||
@@ -202,8 +228,7 @@ func renderPDFSingle(data []typstData) (bytes.Buffer, error) {
|
||||
var output bytes.Buffer
|
||||
|
||||
typstCLI := typst.CLI{
|
||||
WorkingDirectory: "/doc/",
|
||||
// ContainerName: helper.GetEnv("TYPST_CONTAINER", "arbeitszeitmessung-doc-creator"),
|
||||
WorkingDirectory: PDF_DIRECTORY,
|
||||
}
|
||||
|
||||
if err := typst.InjectValues(&markup, map[string]any{"data": data}); err != nil {
|
||||
@@ -230,8 +255,7 @@ func renderPDFMulti(data []typstData) ([]bytes.Buffer, error) {
|
||||
var outputMulti []bytes.Buffer
|
||||
|
||||
typstRender := typst.CLI{
|
||||
WorkingDirectory: "/doc/",
|
||||
// ContainerName: helper.GetEnv("TYPST_CONTAINER", "arbeitszeitmessung-doc-creator"),
|
||||
WorkingDirectory: PDF_DIRECTORY,
|
||||
}
|
||||
|
||||
for _, d := range data {
|
||||
@@ -273,6 +297,16 @@ func zipPfd(pdfReports []bytes.Buffer, reportData *[]typstData) (bytes.Buffer, e
|
||||
return zipOutput, err
|
||||
}
|
||||
|
||||
func lenWorkDays(workDays []models.IWorkDay) int {
|
||||
var lenght int
|
||||
for _, day := range workDays {
|
||||
if !day.IsEmpty() || day.IsKurzArbeit() {
|
||||
lenght += 1
|
||||
}
|
||||
}
|
||||
return lenght
|
||||
}
|
||||
|
||||
type typstMetadata struct {
|
||||
TimeRange string `json:"time-range"`
|
||||
EmployeeName string `json:"employee-name"`
|
||||
|
||||
@@ -42,7 +42,7 @@ func submitReport(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
workWeek := models.NewWorkWeek(user, weekTs, true)
|
||||
workWeek := models.NewWorkWeekSimple(user, weekTs, true)
|
||||
|
||||
switch r.FormValue("method") {
|
||||
case "send":
|
||||
@@ -70,7 +70,7 @@ func showWeeks(w http.ResponseWriter, r *http.Request) {
|
||||
submissionDate := pp.ParseTimestampFallback("submission_date", time.DateOnly, user.GetLastWorkWeekSubmission())
|
||||
lastSub := helper.GetMonday(submissionDate)
|
||||
|
||||
userWeek := models.NewWorkWeek(user, lastSub, true)
|
||||
userWeek := models.NewWorkWeekSimple(user, lastSub, true)
|
||||
|
||||
var workWeeks []models.WorkWeek
|
||||
teamMembers, err := user.GetTeamMembers()
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Relevant for arduino inputs -> creates new Booking from get and put method
|
||||
@@ -40,7 +39,7 @@ func createBooking(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
booking := (*models.Booking).FromUrlParams(nil, r.URL.Query())
|
||||
booking.Timestamp = time.Now()
|
||||
// booking.Timestamp = time.Now()
|
||||
if booking.Verify() {
|
||||
err := booking.Insert()
|
||||
if errors.Is(models.SameBookingError{}, err) {
|
||||
|
||||
@@ -100,7 +100,7 @@ func getBookings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
aggregatedOvertime += day.GetOvertime(user, models.WorktimeBaseDay, true)
|
||||
}
|
||||
if reportedOvertime, err := user.GetReportedOvertime(); err == nil {
|
||||
if reportedOvertime, err := user.GetReportedOvertime(time.Now()); err == nil {
|
||||
user.Overtime = (reportedOvertime + aggregatedOvertime).Round(time.Minute)
|
||||
} else {
|
||||
log.Println("Cannot calculate overtime: ", err)
|
||||
@@ -257,12 +257,6 @@ func updateAbsence(r *http.Request) error {
|
||||
log.Println("Cannot get Absence for id: ", absenceId, err)
|
||||
return err
|
||||
}
|
||||
if r.FormValue("action") == "delete" {
|
||||
log.Println("Deleting Absence!", "Not implemented")
|
||||
// TODO
|
||||
//absence.Delete()
|
||||
return nil
|
||||
}
|
||||
|
||||
if absence.Update(newAbsence) {
|
||||
err = absence.Save()
|
||||
@@ -272,5 +266,4 @@ func updateAbsence(r *http.Request) error {
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ type FileLog struct {
|
||||
var Logs map[string]FileLog = make(map[string]FileLog)
|
||||
|
||||
func NewAudit() (i *log.Logger, close func() error) {
|
||||
LOG_FILE := "logs/" + time.Now().Format(time.DateOnly) + ".log"
|
||||
logFile, err := os.OpenFile(LOG_FILE, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
logName := "logs/" + time.Now().Format(time.DateOnly) + ".log"
|
||||
logFile, err := os.OpenFile(logName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ func GetEnv(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
func IsDebug() bool {
|
||||
return GetEnv("GO_ENV", "production") == "debug"
|
||||
}
|
||||
|
||||
type CacheItem struct {
|
||||
value any
|
||||
expiration time.Time
|
||||
|
||||
@@ -4,6 +4,7 @@ package helper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,64 @@ func GetMonday(ts time.Time) time.Time {
|
||||
return ts
|
||||
}
|
||||
|
||||
func GetMondays(allDays []time.Time, onlyInRange bool) []time.Time {
|
||||
var mondays []time.Time
|
||||
var start, end time.Time
|
||||
|
||||
for _, day := range allDays {
|
||||
mondays = append(mondays, GetMonday(day))
|
||||
|
||||
if start.IsZero() || day.Before(start) {
|
||||
start = day
|
||||
}
|
||||
if end.IsZero() || day.After(end) {
|
||||
end = day
|
||||
}
|
||||
}
|
||||
mondays = slices.Compact(mondays)
|
||||
if onlyInRange {
|
||||
return DaysInRange(mondays, start, end)
|
||||
}
|
||||
return mondays
|
||||
}
|
||||
|
||||
func DaysInRange(days []time.Time, startDate, endDate time.Time) []time.Time {
|
||||
filtered := []time.Time{}
|
||||
startDate = startDate.Add(-time.Minute)
|
||||
endDate = endDate.Add(time.Minute)
|
||||
|
||||
for _, day := range days {
|
||||
if day.After(startDate) && day.Before(endDate) {
|
||||
filtered = append(filtered, day)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func IsMonday(day time.Time) bool {
|
||||
return day.Weekday() == time.Monday
|
||||
}
|
||||
|
||||
// GenerateDateRange returns a slice of all dates between start and end (inclusive).
|
||||
func GenerateDateRange(start, end time.Time) []time.Time {
|
||||
var dates []time.Time
|
||||
|
||||
// Ensure start is before or equal to end
|
||||
if start.After(end) {
|
||||
return dates
|
||||
}
|
||||
|
||||
// Normalize times to midnight
|
||||
current := start.Truncate(time.Hour * 24)
|
||||
end = end.Truncate(time.Hour * 24)
|
||||
|
||||
for !current.After(end) {
|
||||
dates = append(dates, current)
|
||||
current = current.AddDate(0, 0, 1) // Add one day
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
func GetFirstOfMonth(ts time.Time) time.Time {
|
||||
if ts.Day() > 1 {
|
||||
return ts.AddDate(0, 0, -(ts.Day() - 1))
|
||||
|
||||
@@ -26,6 +26,101 @@ func TestGetMonday(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMonday_ReturnsTrueForMonday(t *testing.T) {
|
||||
monday := time.Date(2023, 4, 3, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
if !IsMonday(monday) {
|
||||
t.Errorf("Expected IsMonday to return true for Monday, got false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMonday_ReturnsFalseForNonMonday(t *testing.T) {
|
||||
tuesday := time.Date(2023, 4, 4, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
if IsMonday(tuesday) {
|
||||
t.Errorf("Expected IsMonday to return false for Tuesday, got true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDateRange(t *testing.T) {
|
||||
start := time.Date(2026, 2, 9, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2026, 2, 11, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
dates := GenerateDateRange(start, end)
|
||||
|
||||
if len(dates) != 3 {
|
||||
t.Fatalf("expected 3 dates, got %d", len(dates))
|
||||
}
|
||||
|
||||
expected := []string{"2026-02-09", "2026-02-10", "2026-02-11"}
|
||||
for i, d := range dates {
|
||||
got := d.Format("2006-01-02")
|
||||
if got != expected[i] {
|
||||
t.Errorf("expected %s, got %s", expected[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMondays_ReturnsOnlyMondays(t *testing.T) {
|
||||
startDate := time.Date(2026, 01, 01, 0, 0, 0, 0, time.UTC)
|
||||
endDate := time.Date(2026, 01, 31, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
daysInMonth := GenerateDateRange(startDate, endDate)
|
||||
result := GetMondays(daysInMonth, false)
|
||||
if len(result) < 5 {
|
||||
t.Errorf("Expected 5 monday, got %d", len(result))
|
||||
} else if len(result) > 5 {
|
||||
t.Errorf("Expected 5 monday, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0] != time.Date(2025, 12, 29, 0, 0, 0, 0, time.UTC) {
|
||||
t.Errorf("Expected first monday to be %v, got %v", "2025-12-29", result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMondays_ReturnsOnlyMondaysInRange(t *testing.T) {
|
||||
startDate := time.Date(2026, 01, 01, 0, 0, 0, 0, time.UTC)
|
||||
endDate := time.Date(2026, 01, 31, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
daysInMonth := GenerateDateRange(startDate, endDate)
|
||||
result := GetMondays(daysInMonth, true)
|
||||
if len(result) < 4 {
|
||||
t.Errorf("Expected 4 monday, got %d", len(result))
|
||||
} else if len(result) > 4 {
|
||||
t.Errorf("Expected 4 monday, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0] != time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC) {
|
||||
t.Errorf("Expected first monday to be %v, got %v", "2026-01-05", result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaysInRange(t *testing.T) {
|
||||
days := []time.Time{
|
||||
time.Date(2023, 4, 3, 0, 0, 0, 0, time.UTC), // Tuesday
|
||||
time.Date(2023, 4, 4, 0, 0, 0, 0, time.UTC), // Wednesday
|
||||
time.Date(2023, 4, 5, 0, 0, 0, 0, time.UTC), // Thursday
|
||||
time.Date(2023, 4, 6, 0, 0, 0, 0, time.UTC), // Friday
|
||||
}
|
||||
|
||||
start := time.Date(2023, 4, 3, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2023, 4, 5, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
daysInRange := DaysInRange(days, start, end)
|
||||
|
||||
if len(daysInRange) != 3 {
|
||||
t.Errorf("Expected 3 days in range, got %d", len(daysInRange))
|
||||
}
|
||||
|
||||
if daysInRange[0] != days[0] {
|
||||
t.Errorf("Expected first day in range to be %v, got %v", days[0], daysInRange[0])
|
||||
}
|
||||
|
||||
if daysInRange[2] != days[2] {
|
||||
t.Errorf("Expected third day in range to be %v, got %v", days[2], daysInRange[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDurationFill(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
|
||||
@@ -24,7 +24,7 @@ func SetCors(w http.ResponseWriter) {
|
||||
|
||||
func RequiresLogin(session *scs.SessionManager, w http.ResponseWriter, r *http.Request) {
|
||||
r = r.WithContext(context.WithValue(r.Context(), "session", session))
|
||||
if GetEnv("GO_ENV", "production") == "debug" {
|
||||
if IsDebug() {
|
||||
return
|
||||
}
|
||||
if session.Exists(r.Context(), "user") {
|
||||
|
||||
@@ -38,7 +38,7 @@ func main() {
|
||||
if err != nil {
|
||||
slog.Info("No .env file found in directory!")
|
||||
}
|
||||
if helper.GetEnv("GO_ENV", "production") == "debug" {
|
||||
if helper.IsDebug() {
|
||||
logLevel.Set(slog.LevelDebug)
|
||||
envs := os.Environ()
|
||||
slog.Debug("Debug mode enabled", "Environment Variables", envs)
|
||||
@@ -52,6 +52,8 @@ func main() {
|
||||
|
||||
defer models.DB.(*sql.DB).Close()
|
||||
|
||||
models.Options = configure()
|
||||
|
||||
if helper.GetEnv("GO_ENV", "production") != "debug" {
|
||||
err = Migrate()
|
||||
if err != nil {
|
||||
@@ -114,3 +116,10 @@ func loggingMiddleware(next http.Handler) http.Handler {
|
||||
slog.Info("Completet Request", slog.String("Time", time.Since(start).String()))
|
||||
})
|
||||
}
|
||||
|
||||
func configure() models.BookingOptions {
|
||||
return models.BookingOptions{
|
||||
AllowOutOfBounds: helper.GetEnv("BOOKING_OUT_OF_BOUNDS", "false") == "true",
|
||||
AllowUnknownUser: helper.GetEnv("BOOKING_FOR_UNKNOWN_USER", "false") == "true",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,10 @@ package models
|
||||
// the absence data is based on the entries in the "abwesenheit" database table
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -61,7 +63,7 @@ func (a *Absence) GetWorktime(u User, base WorktimeBase, includeKurzarbeit bool)
|
||||
return u.ArbeitszeitProTagFrac(float32(a.AbwesenheitTyp.WorkTime) / 100)
|
||||
case WorktimeBaseWeek:
|
||||
if a.AbwesenheitTyp.WorkTime <= 0 && includeKurzarbeit {
|
||||
return u.ArbeitszeitProTagFrac(0.2)
|
||||
return u.ArbeitszeitProWocheFrac(0.2)
|
||||
} else if a.AbwesenheitTyp.WorkTime <= 0 {
|
||||
return 0
|
||||
}
|
||||
@@ -295,3 +297,24 @@ func (a *Absence) Delete() error {
|
||||
_, err = qStr.Exec(a.CounterId)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Absence) IsSubmittedAndAccepted() bool {
|
||||
qStr, err := DB.Prepare(`SELECT bestaetigt from wochen_report WHERE $1 = ANY(abwesenheiten) AND $2 >= woche_start AND $2 < woche_start + INTERVAL '1 week';`) // @> array contains
|
||||
if err != nil {
|
||||
slog.Warn("Error when preparing SQL Statement", "error", err)
|
||||
return false
|
||||
}
|
||||
defer qStr.Close()
|
||||
var isSubmittedAndChecked bool = false
|
||||
|
||||
err = qStr.QueryRow(a.CounterId, a.Date()).Scan(&isSubmittedAndChecked)
|
||||
if err == sql.ErrNoRows {
|
||||
// No rows found ==> not even submitted
|
||||
return false
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Warn("Unexpected error when executing SQL Statement", "error", err)
|
||||
}
|
||||
return isSubmittedAndChecked
|
||||
}
|
||||
|
||||
@@ -36,6 +36,12 @@ type Booking struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
CounterId int `json:"counter_id"`
|
||||
BookingType BookingType `json:"anwesenheit_typ"`
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
|
||||
type BookingOptions struct {
|
||||
AllowOutOfBounds bool
|
||||
AllowUnknownUser bool
|
||||
}
|
||||
|
||||
type IDatabase interface {
|
||||
@@ -45,6 +51,8 @@ type IDatabase interface {
|
||||
|
||||
var DB IDatabase
|
||||
|
||||
var Options BookingOptions
|
||||
|
||||
func (b *Booking) NewBooking(cardUid string, gereatId int16, checkInOut int16, typeId int8) Booking {
|
||||
bookingType, err := GetBookingTypeById(typeId)
|
||||
if err != nil {
|
||||
@@ -91,31 +99,44 @@ func (b *Booking) Verify() bool {
|
||||
} else {
|
||||
b.BookingType.Name = bookingType.Name
|
||||
}
|
||||
|
||||
user, err := GetUserByCardUID(b.CardUID)
|
||||
if err == sql.ErrNoRows {
|
||||
log.Println("Cannot find user with given CardUID")
|
||||
return Options.AllowUnknownUser // if allow do not fail verify if not allow fail verify
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Error("Cannot get user from CardUID", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if bookingOutOfBounds(b, &user) {
|
||||
auditLog, closeLog := logs.NewAudit()
|
||||
defer closeLog()
|
||||
if !Options.AllowOutOfBounds {
|
||||
return false
|
||||
}
|
||||
|
||||
oldTime := b.Timestamp
|
||||
if oldTime.IsZero() {
|
||||
oldTime = time.Now()
|
||||
}
|
||||
if b.CheckInOut%2 == 1 && b.CheckInOut < 200 { //kommen Booking
|
||||
b.Timestamp = user.ArbeitMinStartTime(oldTime)
|
||||
} else {
|
||||
b.Timestamp = user.ArbeitMaxEndeTime(oldTime)
|
||||
}
|
||||
auditLog.Printf("Buchung (%s) von '%s' außerhalb der regulaeren Zeit. Verschieben der Zeit %s -> %s", b.GetBookingType(), user.CardUID, oldTime.Format(time.TimeOnly), b.Timestamp.Format(time.TimeOnly))
|
||||
slog.Info("Booking is out of work time bounds, setting time to match worktime bounds", "new_time", b.Timestamp.String(), "old_time", oldTime)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *Booking) IsSubmittedAndChecked() bool {
|
||||
qStr, err := DB.Prepare(`SELECT bestaetigt from wochen_report WHERE $1 = ANY(anwesenheiten);`)
|
||||
if err != nil {
|
||||
slog.Warn("Error when preparing SQL Statement", "error", err)
|
||||
return false
|
||||
}
|
||||
defer qStr.Close()
|
||||
var isSubmittedAndChecked bool = false
|
||||
|
||||
err = qStr.QueryRow(b.CounterId).Scan(&isSubmittedAndChecked)
|
||||
if err == sql.ErrNoRows {
|
||||
// No rows found ==> not even submitted
|
||||
return false
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Warn("Unexpected error when executing SQL Statement", "error", err)
|
||||
}
|
||||
return isSubmittedAndChecked
|
||||
}
|
||||
|
||||
func (b *Booking) Insert() error {
|
||||
if !b.Timestamp.IsZero() {
|
||||
return b.InsertWithTimestamp()
|
||||
}
|
||||
if !checkLastBooking(*b) {
|
||||
return SameBookingError{}
|
||||
}
|
||||
@@ -208,7 +229,7 @@ func (b Booking) Save() {
|
||||
}
|
||||
|
||||
func (b *Booking) GetBookingType() string {
|
||||
debug := (helper.GetEnv("GO_ENV", "production") == "debug")
|
||||
debug := helper.IsDebug()
|
||||
switch b.CheckInOut {
|
||||
case 1: //manuelle Änderung
|
||||
return "kommen"
|
||||
@@ -244,20 +265,22 @@ func (b *Booking) Update(nb Booking) {
|
||||
b.GeraetID = nb.GeraetID
|
||||
}
|
||||
if b.Timestamp != nb.Timestamp {
|
||||
auditLog.Printf("Änderung in Buchung %d von '%s': Buchungszeit (%s -> %s).", b.CounterId, b.CardUID, b.Timestamp.Format("15:04"), nb.Timestamp.Format("15:04)"))
|
||||
auditLog.Printf("Änderung in Buchung %d von '%s': Buchungszeit (%s -> %s).", b.CounterId, b.CardUID, b.Timestamp.Format(time.TimeOnly), nb.Timestamp.Format(time.TimeOnly))
|
||||
b.Timestamp = nb.Timestamp
|
||||
}
|
||||
}
|
||||
|
||||
func checkLastBooking(b Booking) bool {
|
||||
var check_in_out int
|
||||
slog.Info("Checking with timestamp:", "timestamp", b.Timestamp.String())
|
||||
stmt, err := DB.Prepare((`SELECT check_in_out FROM "anwesenheit" WHERE "card_uid" = $1 AND "timestamp"::DATE <= $2::DATE ORDER BY "timestamp" DESC LIMIT 1;`))
|
||||
var timestamp time.Time
|
||||
slog.Debug("Checking with timestamp:", "timestamp", b.Timestamp)
|
||||
stmt, err := DB.Prepare((`SELECT check_in_out, timestamp FROM "anwesenheit" WHERE "card_uid" = $1 AND "timestamp" <= $2 ORDER BY "timestamp" DESC LIMIT 1;`))
|
||||
if err != nil {
|
||||
log.Fatalf("Error preparing query: %v", err)
|
||||
return false
|
||||
}
|
||||
err = stmt.QueryRow(b.CardUID, b.Timestamp).Scan(&check_in_out)
|
||||
err = stmt.QueryRow(b.CardUID, b.Timestamp).Scan(&check_in_out, ×tamp)
|
||||
slog.Info("Checking last bookings check_in_out", "Check", check_in_out)
|
||||
if err == sql.ErrNoRows {
|
||||
return true
|
||||
}
|
||||
@@ -265,9 +288,13 @@ func checkLastBooking(b Booking) bool {
|
||||
log.Println("Error checking last booking: ", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if int16(check_in_out)%2 == b.CheckInOut%2 {
|
||||
return false
|
||||
}
|
||||
if timestamp.Equal(b.Timestamp) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -276,8 +303,6 @@ func (b *Booking) UpdateTime(newTime time.Time) {
|
||||
if hour == b.Timestamp.Hour() && minute == b.Timestamp.Minute() {
|
||||
return
|
||||
}
|
||||
// TODO: add check for time overlap
|
||||
|
||||
var newBooking Booking
|
||||
newBooking.Timestamp = time.Date(b.Timestamp.Year(), b.Timestamp.Month(), b.Timestamp.Day(), hour, minute, 0, 0, b.Timestamp.Location())
|
||||
if b.CheckInOut < 3 {
|
||||
@@ -287,14 +312,11 @@ func (b *Booking) UpdateTime(newTime time.Time) {
|
||||
newBooking.CheckInOut = 4
|
||||
}
|
||||
b.Update(newBooking)
|
||||
// TODO Check verify
|
||||
if b.Verify() {
|
||||
b.Save()
|
||||
} else {
|
||||
log.Println("Cannot save updated booking!", b.ToString())
|
||||
}
|
||||
// b.Verify()
|
||||
// b.Save()
|
||||
}
|
||||
|
||||
func (b *Booking) ToString() string {
|
||||
@@ -346,3 +368,12 @@ func GetBookingTypesCached() []BookingType {
|
||||
}
|
||||
return types.([]BookingType)
|
||||
}
|
||||
|
||||
func bookingOutOfBounds(b *Booking, u *User) bool {
|
||||
bookingTime := b.Timestamp
|
||||
if b.Timestamp.IsZero() {
|
||||
bookingTime = time.Now()
|
||||
}
|
||||
res := bookingTime.Before(u.ArbeitMinStartTime(bookingTime)) || bookingTime.After(u.ArbeitMaxEndeTime(bookingTime))
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -13,35 +13,250 @@ var testBookingType = models.BookingType{
|
||||
var testBookings8hrs = []models.Booking{{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 08:00")),
|
||||
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
}, {
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 16:00")),
|
||||
Timestamp: time.Date(2025, 01, 01, 16, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
}}
|
||||
|
||||
var testBookings6hrs = []models.Booking{{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 08:00")),
|
||||
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
}, {
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 14:00")),
|
||||
Timestamp: time.Date(2025, 01, 01, 14, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
}}
|
||||
|
||||
var testBookings10hrs = []models.Booking{{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 08:00")),
|
||||
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
}, {
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 18:00")),
|
||||
Timestamp: time.Date(2025, 01, 01, 18, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
}}
|
||||
|
||||
var testBookings6hrsBreak30min = []models.Booking{
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: time.Date(2025, 01, 01, 9, 30, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: time.Date(2025, 01, 01, 14, 30, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
}}
|
||||
|
||||
var testBookings610hrsBreak30min = []models.Booking{
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: time.Date(2025, 01, 01, 9, 30, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: time.Date(2025, 01, 01, 14, 40, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
}}
|
||||
|
||||
var testBookings9hrsBreak30min = []models.Booking{
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: time.Date(2025, 01, 01, 9, 30, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: time.Date(2025, 01, 01, 17, 30, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
}}
|
||||
|
||||
var testBookings930hrs = []models.Booking{
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: time.Date(2025, 01, 01, 17, 30, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
}}
|
||||
|
||||
var testBookings910hrsBreak30min = []models.Booking{
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: time.Date(2025, 01, 01, 9, 30, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: time.Date(2025, 01, 01, 17, 40, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
}
|
||||
|
||||
var testBookings910hrsBreak35min = []models.Booking{
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: time.Date(2025, 01, 01, 9, 35, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: time.Date(2025, 01, 01, 17, 45, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
}
|
||||
|
||||
var testBookings945hrs = []models.Booking{
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: time.Date(2025, 01, 01, 17, 45, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
}
|
||||
|
||||
var testBookings10hrsBreak45min = []models.Booking{
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: time.Date(2025, 01, 01, 9, 45, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: time.Date(2025, 01, 01, 18, 00, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
}
|
||||
|
||||
var testBookings1030hrsBreak45min = []models.Booking{
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 1,
|
||||
Timestamp: time.Date(2025, 01, 01, 9, 45, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
{
|
||||
CardUID: "aaaa-aaaa",
|
||||
CheckInOut: 2,
|
||||
Timestamp: time.Date(2025, 01, 01, 18, 30, 0, 0, time.UTC),
|
||||
BookingType: testBookingType,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -17,6 +17,15 @@ type CompoundDay struct {
|
||||
DayParts []IWorkDay
|
||||
}
|
||||
|
||||
// IsSubmittedAndAccepted implements IWorkDay.
|
||||
func (c *CompoundDay) IsSubmittedAndAccepted() bool {
|
||||
var isSubmittedAndAccepted = true
|
||||
for _, day := range c.DayParts {
|
||||
isSubmittedAndAccepted = isSubmittedAndAccepted && day.IsSubmittedAndAccepted()
|
||||
}
|
||||
return isSubmittedAndAccepted
|
||||
}
|
||||
|
||||
func NewCompondDay(date time.Time, dayParts ...IWorkDay) *CompoundDay {
|
||||
return &CompoundDay{Day: date, DayParts: dayParts}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type IWorkDay interface {
|
||||
GetTimes(User, WorktimeBase, bool) (work, pause, overtime time.Duration)
|
||||
GetOvertime(User, WorktimeBase, bool) time.Duration
|
||||
IsEmpty() bool
|
||||
IsSubmittedAndAccepted() bool
|
||||
}
|
||||
|
||||
type DayType int
|
||||
@@ -54,7 +55,9 @@ func GetDays(user User, tsFrom, tsTo time.Time, orderedForward bool) []IWorkDay
|
||||
}
|
||||
|
||||
for _, absentDay := range absences {
|
||||
|
||||
if weekDay := absentDay.Date().Weekday(); weekDay == time.Saturday || weekDay == time.Sunday {
|
||||
continue
|
||||
}
|
||||
// Check if there is already a day
|
||||
existingDay, ok := allDays[absentDay.Date().Format(time.DateOnly)]
|
||||
switch {
|
||||
|
||||
@@ -19,6 +19,11 @@ type PublicHoliday struct {
|
||||
worktime int8
|
||||
}
|
||||
|
||||
// IsSubmittedAndAccepted implements IWorkDay.
|
||||
func (p *PublicHoliday) IsSubmittedAndAccepted() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// IsEmpty implements [IWorkDay].
|
||||
func (p *PublicHoliday) IsEmpty() bool {
|
||||
return false
|
||||
|
||||
@@ -28,12 +28,14 @@ type User struct {
|
||||
ArbeitszeitPerTag float32 //`json:"arbeitszeit_per_tag"`
|
||||
ArbeitszeitPerWoche float32 //`json:"arbeitszeit_per_woche"`
|
||||
Overtime time.Duration
|
||||
ArbeitMinStart time.Time
|
||||
ArbeitMaxEnde time.Time
|
||||
}
|
||||
|
||||
func GetUserFromSession(Session *scs.SessionManager, ctx context.Context) (User, error) {
|
||||
var user User
|
||||
var err error
|
||||
if helper.GetEnv("GO_ENV", "production") == "debug" {
|
||||
if helper.IsDebug() {
|
||||
user, err = GetUserByPersonalNr(123)
|
||||
} else {
|
||||
if !Session.Exists(ctx, "user") {
|
||||
@@ -50,50 +52,56 @@ func GetUserFromSession(Session *scs.SessionManager, ctx context.Context) (User,
|
||||
}
|
||||
|
||||
// Returns the actual overtime for this moment
|
||||
func (u *User) GetReportedOvertime() (time.Duration, error) {
|
||||
func (u *User) GetReportedOvertime(startDate time.Time) (time.Duration, error) {
|
||||
var overtime time.Duration
|
||||
|
||||
qStr, err := DB.Prepare("SELECT COALESCE(SUM(EXTRACT(EPOCH FROM ueberstunden) * 1000000000)::BIGINT, 0) AS total_ueberstunden_ns FROM wochen_report WHERE personal_nummer = $1;")
|
||||
qStr, err := DB.Prepare("SELECT COALESCE(SUM(EXTRACT(EPOCH FROM ueberstunden) * 1000000000)::BIGINT, 0) AS total_ueberstunden_ns FROM wochen_report WHERE personal_nummer = $1 AND woche_start::DATE <= $2::DATE;")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer qStr.Close()
|
||||
err = qStr.QueryRow(u.PersonalNummer).Scan(&overtime)
|
||||
err = qStr.QueryRow(u.PersonalNummer, startDate).Scan(&overtime)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return overtime, nil
|
||||
}
|
||||
|
||||
func GetAllUsers() ([]User, error) {
|
||||
qStr, err := DB.Prepare((`SELECT card_uid, vorname, nachname,arbeitszeit_per_tag, arbeitszeit_per_woche FROM s_personal_daten;`))
|
||||
var users []User
|
||||
if err != nil {
|
||||
return users, err
|
||||
}
|
||||
defer qStr.Close()
|
||||
rows, err := qStr.Query()
|
||||
if err != nil {
|
||||
return users, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
func GetUserByCardUID(cardUid string) (User, error) {
|
||||
var user User
|
||||
|
||||
var user User
|
||||
if err := rows.Scan(&user.CardUID, &user.Vorname, &user.Name, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche); err != nil {
|
||||
log.Println("Error creating user!", err)
|
||||
continue
|
||||
}
|
||||
users = append(users, user)
|
||||
qStr, err := DB.Prepare((`SELECT personal_nummer, vorname, nachname, card_uid, arbeitszeit_per_tag, arbeitszeit_per_woche, arbeitszeit_min_start, arbeitszeit_max_ende FROM s_personal_daten WHERE card_uid = $1;`))
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return users, nil
|
||||
err = qStr.QueryRow(cardUid).Scan(&user.PersonalNummer, &user.Vorname, &user.Name, &user.CardUID, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche, &user.ArbeitMinStart, &user.ArbeitMaxEnde)
|
||||
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
return users, nil
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (u *User) GetAll() ([]User, error) {
|
||||
qStr, err := DB.Prepare((`SELECT card_uid, vorname, nachname FROM s_personal_daten;`))
|
||||
func (u *User) ArbeitMinStartTime(date time.Time) time.Time {
|
||||
if date.Hour() > 0 {
|
||||
date = date.Truncate(24 * time.Hour).Add(-time.Hour)
|
||||
}
|
||||
date = date.Truncate(time.Hour)
|
||||
slog.Info("Date truncate", "date", date)
|
||||
return date.Add(time.Hour*time.Duration(u.ArbeitMinStart.Hour()) + time.Minute*time.Duration(u.ArbeitMinStart.Minute()))
|
||||
}
|
||||
|
||||
func (u *User) ArbeitMaxEndeTime(date time.Time) time.Time {
|
||||
if date.Hour() > 0 {
|
||||
date = date.Truncate(24 * time.Hour).Add(-time.Hour)
|
||||
}
|
||||
date = date.Truncate(time.Hour)
|
||||
slog.Info("Date truncate", "date", date)
|
||||
return date.Add(time.Hour*time.Duration(u.ArbeitMaxEnde.Hour()) + time.Minute*time.Duration(u.ArbeitMaxEnde.Minute()))
|
||||
}
|
||||
|
||||
func GetAllUsers() ([]User, error) {
|
||||
qStr, err := DB.Prepare((`SELECT personal_nummer, vorname, nachname, card_uid, arbeitszeit_per_tag, arbeitszeit_per_woche, arbeitszeit_min_start, arbeitszeit_max_ende FROM s_personal_daten;`))
|
||||
var users []User
|
||||
if err != nil {
|
||||
return users, err
|
||||
@@ -107,7 +115,7 @@ func (u *User) GetAll() ([]User, error) {
|
||||
for rows.Next() {
|
||||
|
||||
var user User
|
||||
if err := rows.Scan(&user.CardUID, &user.Vorname, &user.Name); err != nil {
|
||||
if err := rows.Scan(&user.PersonalNummer, &user.Vorname, &user.Name, &user.CardUID, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche, &user.ArbeitMinStart, &user.ArbeitMaxEnde); err != nil {
|
||||
log.Println("Error creating user!", err)
|
||||
continue
|
||||
}
|
||||
@@ -167,11 +175,11 @@ func (u *User) CheckOut() error {
|
||||
func GetUserByPersonalNr(personalNummer int) (User, error) {
|
||||
var user User
|
||||
|
||||
qStr, err := DB.Prepare((`SELECT personal_nummer, card_uid, vorname, nachname, arbeitszeit_per_tag, arbeitszeit_per_woche FROM s_personal_daten WHERE personal_nummer = $1;`))
|
||||
qStr, err := DB.Prepare((`SELECT personal_nummer, vorname, nachname, card_uid, arbeitszeit_per_tag, arbeitszeit_per_woche, arbeitszeit_min_start, arbeitszeit_max_ende FROM s_personal_daten WHERE personal_nummer = $1;`))
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
err = qStr.QueryRow(personalNummer).Scan(&user.PersonalNummer, &user.CardUID, &user.Vorname, &user.Name, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche)
|
||||
err = qStr.QueryRow(personalNummer).Scan(&user.PersonalNummer, &user.Vorname, &user.Name, &user.CardUID, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche, &user.ArbeitMinStart, &user.ArbeitMaxEnde)
|
||||
|
||||
if err != nil {
|
||||
return user, err
|
||||
@@ -185,7 +193,7 @@ func GetUserByPersonalNrMulti(personalNummerMulti []int) ([]User, error) {
|
||||
return users, errors.New("No personalNumbers provided")
|
||||
}
|
||||
|
||||
qStr, err := DB.Prepare((`SELECT personal_nummer, card_uid, vorname, nachname, arbeitszeit_per_tag, arbeitszeit_per_woche FROM s_personal_daten WHERE personal_nummer = ANY($1::int[]);`))
|
||||
qStr, err := DB.Prepare((`SELECT personal_nummer, vorname, nachname, card_uid, arbeitszeit_per_tag, arbeitszeit_per_woche, arbeitszeit_min_start, arbeitszeit_max_ende FROM s_personal_daten WHERE personal_nummer = ANY($1::int[]);`))
|
||||
if err != nil {
|
||||
return users, err
|
||||
}
|
||||
@@ -200,7 +208,7 @@ func GetUserByPersonalNrMulti(personalNummerMulti []int) ([]User, error) {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var user User
|
||||
if err := rows.Scan(&user.PersonalNummer, &user.CardUID, &user.Vorname, &user.Name, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche); err != nil {
|
||||
if err := rows.Scan(&user.PersonalNummer, &user.Vorname, &user.Name, &user.CardUID, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche, &user.ArbeitMinStart, &user.ArbeitMaxEnde); err != nil {
|
||||
return users, err
|
||||
}
|
||||
users = append(users, user)
|
||||
@@ -246,6 +254,7 @@ func (u *User) ChangePass(password, newPassword string) (bool, error) {
|
||||
}
|
||||
|
||||
func (u *User) GetTeamMembers() ([]User, error) {
|
||||
var teamMemberPNrs []int
|
||||
var teamMembers []User
|
||||
qStr, err := DB.Prepare(`SELECT personal_nummer FROM s_personal_daten WHERE vorgesetzter_pers_nr = $1 ORDER BY "nachname";`)
|
||||
if err != nil {
|
||||
@@ -261,12 +270,16 @@ func (u *User) GetTeamMembers() ([]User, error) {
|
||||
for rows.Next() {
|
||||
var personalNr int
|
||||
err := rows.Scan(&personalNr)
|
||||
user, err := GetUserByPersonalNr(personalNr)
|
||||
teamMemberPNrs = append(teamMemberPNrs, personalNr)
|
||||
if err != nil {
|
||||
log.Println("Error getting user!")
|
||||
return teamMembers, err
|
||||
}
|
||||
teamMembers = append(teamMembers, user)
|
||||
}
|
||||
teamMembers, err = GetUserByPersonalNrMulti(teamMemberPNrs)
|
||||
if err != nil {
|
||||
log.Println("Error getting users!")
|
||||
return teamMembers, err
|
||||
}
|
||||
|
||||
return teamMembers, nil
|
||||
@@ -292,10 +305,42 @@ func (u *User) GetNextWeek() WorkWeek {
|
||||
func (u *User) GetLastWorkWeekSubmission() time.Time {
|
||||
var lastSub time.Time
|
||||
qStr, err := DB.Prepare(`
|
||||
SELECT COALESCE(
|
||||
(SELECT woche_start + INTERVAL '1 week' FROM wochen_report WHERE personal_nummer = $1 ORDER BY woche_start DESC LIMIT 1),
|
||||
(SELECT timestamp FROM anwesenheit WHERE card_uid = $2 ORDER BY timestamp LIMIT 1)
|
||||
) AS letzte_buchung;
|
||||
SELECT new_week
|
||||
FROM (
|
||||
-- Highest priority
|
||||
SELECT
|
||||
woche_start AS new_week,
|
||||
1 AS priority
|
||||
FROM wochen_report
|
||||
WHERE personal_nummer = $1
|
||||
AND bestaetigt IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Fallback if #1 returns nothing
|
||||
SELECT
|
||||
woche_start + INTERVAL '1 week' AS new_week,
|
||||
2 AS priority
|
||||
FROM wochen_report wo
|
||||
WHERE personal_nummer = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM wochen_report wi
|
||||
WHERE wi.woche_start = wo.woche_start + INTERVAL '1 week'
|
||||
AND wi.personal_nummer = wo.personal_nummer
|
||||
)
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Final fallback
|
||||
SELECT
|
||||
timestamp AS new_week,
|
||||
3 AS priority
|
||||
FROM anwesenheit
|
||||
WHERE card_uid = $2
|
||||
) t
|
||||
ORDER BY priority, new_week
|
||||
LIMIT 1;
|
||||
`)
|
||||
if err != nil {
|
||||
slog.Debug("Error preparing query statement.", "error", err)
|
||||
@@ -311,22 +356,6 @@ func (u *User) GetLastWorkWeekSubmission() time.Time {
|
||||
return lastSub
|
||||
}
|
||||
|
||||
func (u *User) GetFromCardUID(card_uid string) (User, error) {
|
||||
user := User{}
|
||||
var err error
|
||||
|
||||
qStr, err := DB.Prepare((`SELECT personal_nummer, card_uid, vorname, nachname, arbeitszeit_per_tag FROM s_personal_daten WHERE card_uid = $1;`))
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
err = qStr.QueryRow(card_uid).Scan(&user.PersonalNummer, &user.CardUID, &user.Vorname, &user.Name, &user.ArbeitszeitPerTag)
|
||||
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (u *User) IsSuperior(e User) bool {
|
||||
var isSuperior int
|
||||
qStr, err := DB.Prepare(`SELECT COUNT(1) FROM s_personal_daten WHERE personal_nummer = $1 AND vorgesetzter_pers_nr = $2`)
|
||||
@@ -340,7 +369,6 @@ func (u *User) IsSuperior(e User) bool {
|
||||
return false
|
||||
}
|
||||
return isSuperior == 1
|
||||
|
||||
}
|
||||
|
||||
func getMonday(ts time.Time) time.Time {
|
||||
|
||||
@@ -7,12 +7,15 @@ package models
|
||||
|
||||
import (
|
||||
"arbeitszeitmessung/helper"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type WorkDay struct {
|
||||
@@ -46,11 +49,10 @@ func (d *WorkDay) GetWorktimeAbsence() Absence {
|
||||
|
||||
// Gets the time as is in the db (with corrected pause times)
|
||||
func (d *WorkDay) GetWorktime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration {
|
||||
if includeKurzarbeit && d.IsKurzArbeit() && len(d.Bookings) > 0 {
|
||||
if includeKurzarbeit && d.IsKurzArbeit() { //&& len(d.Bookings) > 0
|
||||
return d.kurzArbeitAbsence.GetWorktime(u, base, true)
|
||||
}
|
||||
work, pause := calcWorkPause(d.Bookings)
|
||||
work, pause = correctWorkPause(work, pause)
|
||||
work, _ := correctWorkPause(getWorkPause(d))
|
||||
if (d.worktimeAbsece != Absence{}) {
|
||||
work += d.worktimeAbsece.GetWorktime(u, base, false)
|
||||
}
|
||||
@@ -59,7 +61,7 @@ func (d *WorkDay) GetWorktime(u User, base WorktimeBase, includeKurzarbeit bool)
|
||||
|
||||
// Gets the corrected pause times based on db entries
|
||||
func (d *WorkDay) GetPausetime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration {
|
||||
work, pause := calcWorkPause(d.Bookings)
|
||||
work, pause := getWorkPause(d)
|
||||
work, pause = correctWorkPause(work, pause)
|
||||
return pause.Round(time.Minute)
|
||||
}
|
||||
@@ -81,6 +83,15 @@ func (d *WorkDay) GetTimes(u User, base WorktimeBase, includeKurzarbeit bool) (w
|
||||
return d.GetWorktime(u, base, includeKurzarbeit), d.GetPausetime(u, base, includeKurzarbeit), d.GetOvertime(u, base, includeKurzarbeit)
|
||||
}
|
||||
|
||||
func getWorkPause(d *WorkDay) (work, pause time.Duration) {
|
||||
//if today calc, else take from db
|
||||
if d.workTime == 0 && d.pauseTime == 0 && len(d.Bookings) > 0 {
|
||||
return calcWorkPause(d.Bookings)
|
||||
} else {
|
||||
return d.workTime, d.pauseTime
|
||||
}
|
||||
}
|
||||
|
||||
func calcWorkPause(bookings []Booking) (work, pause time.Duration) {
|
||||
var lastBooking Booking
|
||||
for _, b := range bookings {
|
||||
@@ -105,7 +116,8 @@ func correctWorkPause(workIn, pauseIn time.Duration) (work, pause time.Duration)
|
||||
}
|
||||
|
||||
var diff time.Duration
|
||||
if workIn <= (9*time.Hour) && pauseIn < 30*time.Minute {
|
||||
|
||||
if (workIn+pauseIn) <= (9*time.Hour+30*time.Minute) && pauseIn <= 30*time.Minute {
|
||||
diff = 30*time.Minute - pauseIn
|
||||
} else if pauseIn < 45*time.Minute {
|
||||
diff = 45*time.Minute - pauseIn
|
||||
@@ -140,12 +152,21 @@ func (d *WorkDay) Type() DayType {
|
||||
return DayTypeWorkday
|
||||
}
|
||||
|
||||
func (d *WorkDay) GenerateKurzArbeitBookings(u User) (time.Time, time.Time) {
|
||||
func (d *WorkDay) GenerateKurzArbeitBookings(u User, weekBase WorktimeBase) (time.Time, time.Time) {
|
||||
var timeFrom, timeTo time.Time
|
||||
if d.GetWorktime(u, WorktimeBaseDay, false) >= u.ArbeitszeitProTag() {
|
||||
return timeFrom, timeTo
|
||||
}
|
||||
|
||||
if d.IsEmpty() {
|
||||
switch weekBase {
|
||||
case WorktimeBaseDay:
|
||||
return d.Day.Add(time.Hour * 8), d.Day.Add(time.Hour * 8).Add(u.ArbeitszeitProTag())
|
||||
case WorktimeBaseWeek:
|
||||
return d.Day.Add(time.Hour * 8), d.Day.Add(time.Hour * 8).Add(u.ArbeitszeitProWocheFrac(0.2))
|
||||
}
|
||||
}
|
||||
|
||||
timeFrom = d.Bookings[len(d.Bookings)-1].Timestamp.Add(time.Minute)
|
||||
timeTo = timeFrom.Add(u.ArbeitszeitProTag() - d.GetWorktime(u, WorktimeBaseDay, false))
|
||||
slog.Debug("Added duration as Kurzarbeit", "date", d.Date().String(), "duration", timeTo.Sub(timeFrom).String())
|
||||
@@ -158,7 +179,7 @@ func (d *WorkDay) GetKurzArbeit() *Absence {
|
||||
}
|
||||
|
||||
func (d *WorkDay) ToString() string {
|
||||
return fmt.Sprintf("WorkDay: %s with %d bookings and worktime: %s", d.Date().Format(time.DateOnly), len(d.Bookings), helper.FormatDuration(d.workTime))
|
||||
return fmt.Sprintf("WorkDay: %s with %d bookings and worktime: %s. Is KurzArbeit %v", d.Date().Format(time.DateOnly), len(d.Bookings), helper.FormatDuration(d.workTime), d.IsKurzArbeit())
|
||||
}
|
||||
|
||||
func (d *WorkDay) IsWorkDay() bool {
|
||||
@@ -178,97 +199,148 @@ func GetWorkDays(user User, tsFrom, tsTo time.Time) []WorkDay {
|
||||
var workSec, pauseSec float64
|
||||
|
||||
qStr, err := DB.Prepare(`
|
||||
WITH all_days AS (
|
||||
SELECT generate_series($2::DATE, $3::DATE - INTERVAL '1 day', INTERVAL '1 day')::DATE AS work_date),
|
||||
normalized_bookings AS (
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT
|
||||
a.card_uid,
|
||||
a.timestamp,
|
||||
a.timestamp::DATE AS work_date,
|
||||
a.check_in_out,
|
||||
a.counter_id,
|
||||
a.anwesenheit_typ,
|
||||
sat.anwesenheit_name AS anwesenheit_typ_name,
|
||||
LAG(a.check_in_out) OVER (
|
||||
PARTITION BY a.card_uid, a.timestamp::DATE
|
||||
ORDER BY a.timestamp
|
||||
) AS prev_check
|
||||
FROM anwesenheit a
|
||||
LEFT JOIN s_anwesenheit_typen sat
|
||||
ON a.anwesenheit_typ = sat.anwesenheit_id
|
||||
WHERE a.card_uid = $1
|
||||
AND a.timestamp::DATE >= $2
|
||||
AND a.timestamp::DATE <= $3
|
||||
) t
|
||||
WHERE prev_check IS NULL OR prev_check <> check_in_out
|
||||
),
|
||||
ordered_bookings AS (
|
||||
SELECT
|
||||
*,
|
||||
LAG(timestamp) OVER (
|
||||
PARTITION BY card_uid, work_date
|
||||
ORDER BY timestamp
|
||||
) AS prev_timestamp
|
||||
FROM normalized_bookings
|
||||
)
|
||||
SELECT
|
||||
d.work_date,
|
||||
COALESCE(MIN(b.timestamp), NOW()) AS time_from,
|
||||
COALESCE(MAX(b.timestamp), NOW()) AS time_to,
|
||||
COALESCE(
|
||||
EXTRACT(EPOCH FROM SUM(
|
||||
CASE
|
||||
WHEN b.prev_check IN (1, 3) AND b.check_in_out IN (2, 4, 254)
|
||||
THEN b.timestamp - b.prev_timestamp
|
||||
ELSE INTERVAL '0'
|
||||
END
|
||||
)), 0
|
||||
) AS total_work_seconds,
|
||||
COALESCE(
|
||||
EXTRACT(EPOCH FROM SUM(
|
||||
CASE
|
||||
WHEN b.prev_check IN (2, 4, 254) AND b.check_in_out IN (1, 3)
|
||||
THEN b.timestamp - b.prev_timestamp
|
||||
ELSE INTERVAL '0'
|
||||
END
|
||||
)), 0
|
||||
) AS total_pause_seconds,
|
||||
COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'check_in_out', b.check_in_out,
|
||||
'timestamp', b.timestamp,
|
||||
'counter_id', b.counter_id,
|
||||
'anwesenheit_typ', b.anwesenheit_typ,
|
||||
'anwesenheit_typ', jsonb_build_object(
|
||||
'anwesenheit_id', b.anwesenheit_typ,
|
||||
'anwesenheit_name', b.anwesenheit_typ_name
|
||||
)
|
||||
) ORDER BY b.timestamp), '[]'::jsonb) AS bookings
|
||||
FROM all_days d
|
||||
LEFT JOIN ordered_bookings b ON d.work_date = b.work_date
|
||||
GROUP BY d.work_date
|
||||
ORDER BY d.work_date ASC;`)
|
||||
WITH
|
||||
all_days AS (
|
||||
SELECT
|
||||
generate_series(
|
||||
$2 ::DATE,
|
||||
$3 ::DATE - INTERVAL '1 day',
|
||||
INTERVAL '1 day'
|
||||
)::DATE AS work_date
|
||||
),
|
||||
all_bookings AS (
|
||||
SELECT
|
||||
a.card_uid,
|
||||
a.timestamp,
|
||||
a.timestamp::DATE AS work_date,
|
||||
a.check_in_out,
|
||||
a.counter_id,
|
||||
a.anwesenheit_typ,
|
||||
sat.anwesenheit_name AS anwesenheit_typ_name,
|
||||
LAG(a.check_in_out) OVER (
|
||||
PARTITION BY
|
||||
a.card_uid,
|
||||
a.timestamp::DATE
|
||||
ORDER BY
|
||||
a.timestamp
|
||||
) AS prev_check,
|
||||
LAG(a.timestamp) OVER (
|
||||
PARTITION BY
|
||||
a.card_uid,
|
||||
a.timestamp::DATE
|
||||
ORDER BY
|
||||
a.timestamp
|
||||
) AS prev_timestamp
|
||||
FROM
|
||||
anwesenheit a
|
||||
LEFT JOIN s_anwesenheit_typen sat ON a.anwesenheit_typ = sat.anwesenheit_id
|
||||
WHERE
|
||||
a.card_uid = $1
|
||||
AND a.timestamp::DATE >= $2::DATE
|
||||
AND a.timestamp::DATE <= $3::DATE
|
||||
),
|
||||
normalized_bookings AS (
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
all_bookings
|
||||
WHERE
|
||||
prev_check IS NULL
|
||||
OR prev_check <> check_in_out
|
||||
)
|
||||
SELECT
|
||||
d.work_date,
|
||||
COALESCE(MIN(b.timestamp), NOW()) AS time_from,
|
||||
COALESCE(MAX(b.timestamp), NOW()) AS time_to,
|
||||
EXTRACT(
|
||||
EPOCH
|
||||
FROM
|
||||
SUM(
|
||||
CASE
|
||||
WHEN b.prev_check IN (1, 3)
|
||||
AND b.check_in_out IN (2, 4, 254) THEN b.timestamp - b.prev_timestamp
|
||||
ELSE INTERVAL '0'
|
||||
END
|
||||
)
|
||||
) AS total_work_seconds,
|
||||
EXTRACT(
|
||||
EPOCH
|
||||
FROM
|
||||
SUM(
|
||||
CASE
|
||||
WHEN b.prev_check IN (2, 4, 254)
|
||||
AND b.check_in_out IN (1, 3) THEN b.timestamp - b.prev_timestamp
|
||||
ELSE INTERVAL '0'
|
||||
END
|
||||
)
|
||||
) AS total_pause_seconds,
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'check_in_out',
|
||||
b.check_in_out,
|
||||
'valid',
|
||||
coalesce(b.check_in_out != b.prev_check, true),
|
||||
'timestamp',
|
||||
b.timestamp,
|
||||
'counter_id',
|
||||
b.counter_id,
|
||||
'anwesenheit_typ',
|
||||
jsonb_build_object(
|
||||
'anwesenheit_id',
|
||||
b.anwesenheit_typ,
|
||||
'anwesenheit_name',
|
||||
b.anwesenheit_typ_name
|
||||
)
|
||||
)
|
||||
ORDER BY
|
||||
b.timestamp
|
||||
) FILTER (
|
||||
WHERE
|
||||
b.card_uid IS NOT NULL
|
||||
) AS bookings
|
||||
FROM
|
||||
all_days d
|
||||
LEFT JOIN all_bookings b ON b.work_date = d.work_date
|
||||
GROUP BY
|
||||
d.work_date;
|
||||
`)
|
||||
|
||||
// qStr, err := DB.Prepare(`
|
||||
// WITH all_days AS (
|
||||
// SELECT generate_series($2::DATE, $3::DATE - INTERVAL '1 day', INTERVAL '1 day')::DATE AS work_date),
|
||||
// ordered_bookings AS (
|
||||
// SELECT
|
||||
// a.timestamp::DATE AS work_date,
|
||||
// a.timestamp,
|
||||
// a.check_in_out,
|
||||
// a.counter_id,
|
||||
// a.anwesenheit_typ,
|
||||
// sat.anwesenheit_name AS anwesenheit_typ_name,
|
||||
// LAG(a.timestamp) OVER (PARTITION BY a.card_uid, a.timestamp::DATE ORDER BY a.timestamp) AS prev_timestamp,
|
||||
// LAG(a.check_in_out) OVER (PARTITION BY a.card_uid, a.timestamp::DATE ORDER BY a.timestamp) AS prev_check
|
||||
// FROM anwesenheit a
|
||||
// LEFT JOIN s_anwesenheit_typen sat ON a.anwesenheit_typ = sat.anwesenheit_id
|
||||
// WHERE a.card_uid = $1
|
||||
// AND a.timestamp::DATE >= $2
|
||||
// AND a.timestamp::DATE <= $3
|
||||
// )
|
||||
// normalized_bookings AS (
|
||||
// SELECT *
|
||||
// FROM (
|
||||
// SELECT
|
||||
// a.card_uid,
|
||||
// a.timestamp,
|
||||
// a.timestamp::DATE AS work_date,
|
||||
// a.check_in_out,
|
||||
// a.counter_id,
|
||||
// a.anwesenheit_typ,
|
||||
// sat.anwesenheit_name AS anwesenheit_typ_name,
|
||||
// LAG(a.check_in_out) OVER (
|
||||
// PARTITION BY a.card_uid, a.timestamp::DATE
|
||||
// ORDER BY a.timestamp
|
||||
// ) AS prev_check
|
||||
// FROM anwesenheit a
|
||||
// LEFT JOIN s_anwesenheit_typen sat
|
||||
// ON a.anwesenheit_typ = sat.anwesenheit_id
|
||||
// WHERE a.card_uid = $1
|
||||
// AND a.timestamp::DATE >= $2
|
||||
// AND a.timestamp::DATE <= $3
|
||||
// ) t
|
||||
// WHERE prev_check IS NULL OR prev_check <> check_in_out
|
||||
// ),
|
||||
// ordered_bookings AS (
|
||||
// SELECT
|
||||
// *,
|
||||
// LAG(timestamp) OVER (
|
||||
// PARTITION BY card_uid, work_date
|
||||
// ORDER BY timestamp
|
||||
// ) AS prev_timestamp
|
||||
// FROM normalized_bookings
|
||||
// )
|
||||
// SELECT
|
||||
// d.work_date,
|
||||
// COALESCE(MIN(b.timestamp), NOW()) AS time_from,
|
||||
@@ -322,26 +394,29 @@ func GetWorkDays(user User, tsFrom, tsTo time.Time) []WorkDay {
|
||||
var workDay WorkDay
|
||||
var bookings []byte
|
||||
if err := rows.Scan(&workDay.Day, &workDay.TimeFrom, &workDay.TimeTo, &workSec, &pauseSec, &bookings); err != nil {
|
||||
log.Println("Error scanning row!", err)
|
||||
slog.Error("Error scanning row!", "Error", err)
|
||||
return workDays
|
||||
}
|
||||
workDay.workTime = time.Duration(workSec * float64(time.Second))
|
||||
workDay.pauseTime = time.Duration(pauseSec * float64(time.Second))
|
||||
err = json.Unmarshal(bookings, &workDay.Bookings)
|
||||
if err != nil {
|
||||
log.Println("Error parsing bookings JSON!", err)
|
||||
return nil
|
||||
if bookings != nil {
|
||||
err = json.Unmarshal(bookings, &workDay.Bookings)
|
||||
if err != nil {
|
||||
slog.Error("Error parsing bookings JSON!", "Error", err, "Json", bookings)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// better empty day handling
|
||||
if len(workDay.Bookings) == 1 && workDay.Bookings[0].CounterId == 0 {
|
||||
workDay.Bookings = []Booking{}
|
||||
}
|
||||
// if len(workDay.Bookings) == 1 && workDay.Bookings[0].CounterId == 0 {
|
||||
// workDay.Bookings = []Booking{}
|
||||
// }
|
||||
if len(workDay.Bookings) >= 1 || !helper.IsWeekend(workDay.Date()) {
|
||||
workDays = append(workDays, workDay)
|
||||
}
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
log.Println("Error in workday rows!", err)
|
||||
slog.Error("Error in workday rows!", "Error", err)
|
||||
return workDays
|
||||
}
|
||||
return workDays
|
||||
@@ -349,10 +424,12 @@ func GetWorkDays(user User, tsFrom, tsTo time.Time) []WorkDay {
|
||||
|
||||
// returns bool wheter the workday was ended with an automatic logout
|
||||
func (d *WorkDay) RequiresAction() bool {
|
||||
if len(d.Bookings) == 0 {
|
||||
return false
|
||||
for i := range d.Bookings {
|
||||
if d.Bookings[i].CheckInOut > 250 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return d.Bookings[len(d.Bookings)-1].CheckInOut == 254
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *WorkDay) GetDayProgress(u User) int8 {
|
||||
@@ -363,3 +440,38 @@ func (d *WorkDay) GetDayProgress(u User) int8 {
|
||||
progress := (workTime.Seconds() / u.ArbeitszeitProTag().Seconds()) * 100
|
||||
return int8(progress)
|
||||
}
|
||||
|
||||
func (d *WorkDay) IsSubmittedAndAccepted() bool {
|
||||
var isKurzArbeitAccepted bool
|
||||
if d.IsKurzArbeit() {
|
||||
isKurzArbeitAccepted = d.kurzArbeitAbsence.IsSubmittedAndAccepted()
|
||||
}
|
||||
|
||||
if d.IsEmpty() {
|
||||
return isKurzArbeitAccepted
|
||||
}
|
||||
|
||||
qStr, err := DB.Prepare(`SELECT bestaetigt from wochen_report WHERE anwesenheiten @> $1 AND $2 >= woche_start AND $2 < woche_start + INTERVAL '1 week';`) // @> array contains
|
||||
if err != nil {
|
||||
slog.Warn("Error when preparing SQL Statement", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
defer qStr.Close()
|
||||
var isSubmittedAndChecked bool = false
|
||||
|
||||
var bookingsIds []int
|
||||
for _, booking := range d.Bookings {
|
||||
bookingsIds = append(bookingsIds, booking.CounterId)
|
||||
}
|
||||
|
||||
err = qStr.QueryRow(pq.Array(bookingsIds), d.Date()).Scan(&isSubmittedAndChecked)
|
||||
if err == sql.ErrNoRows {
|
||||
return false
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Warn("Unexpected error when executing SQL Statement", "error", err, "BookingsIds", bookingsIds)
|
||||
}
|
||||
return isSubmittedAndChecked
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ func CatchError[T any](val T, err error) T {
|
||||
}
|
||||
|
||||
var testWorkDay = models.WorkDay{
|
||||
Day: CatchError(time.Parse(time.DateOnly, "2025-01-01")),
|
||||
Day: time.Date(2025, 01, 01, 0, 0, 0, 0, time.Local),
|
||||
Bookings: testBookings8hrs,
|
||||
TimeFrom: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 08:00")),
|
||||
TimeTo: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 16:30")),
|
||||
TimeFrom: time.Date(2025, 01, 01, 8, 0, 0, 0, time.Local),
|
||||
TimeTo: time.Date(2025, 01, 01, 16, 30, 0, 0, time.Local),
|
||||
}
|
||||
|
||||
func TestWorkdayWorktimeDay(t *testing.T) {
|
||||
@@ -30,18 +30,63 @@ func TestWorkdayWorktimeDay(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
testName: "Bookings6hrs",
|
||||
bookings: testBookings6hrs,
|
||||
expectedTime: time.Hour * 6,
|
||||
bookings: testBookings6hrs, //work 6h
|
||||
expectedTime: time.Hour * 6, //pause 0
|
||||
},
|
||||
{
|
||||
testName: "Bookings8hrs",
|
||||
bookings: testBookings8hrs,
|
||||
expectedTime: time.Hour*7 + time.Minute*30,
|
||||
bookings: testBookings8hrs, //work 8 pause 0
|
||||
expectedTime: time.Hour*7 + time.Minute*30, //pause 30 --> corrected
|
||||
},
|
||||
{
|
||||
testName: "Bookings10hrs",
|
||||
bookings: testBookings10hrs,
|
||||
expectedTime: time.Hour*9 + time.Minute*15,
|
||||
bookings: testBookings10hrs, //work 10 pause 0
|
||||
expectedTime: time.Hour*9 + time.Minute*15, //pause 45 --> corrected
|
||||
},
|
||||
{
|
||||
testName: "Booking 6h with 30 min Break",
|
||||
bookings: testBookings6hrsBreak30min, //work 6 pause 30
|
||||
expectedTime: time.Hour * 6, //pause 30 --> bc real pause
|
||||
},
|
||||
{
|
||||
testName: "Booking 6h 10min with 30 min Break",
|
||||
bookings: testBookings610hrsBreak30min, //work 6 10 pause 30
|
||||
expectedTime: time.Hour*6 + time.Minute*10, //pause 30 --> real pause
|
||||
},
|
||||
{
|
||||
testName: "Booking 9h with 30 min Break",
|
||||
bookings: testBookings9hrsBreak30min, //work 9 pause 30
|
||||
expectedTime: time.Hour * 9, //pause 30 --> real pause
|
||||
},
|
||||
{
|
||||
testName: "Booking 9h 30min",
|
||||
bookings: testBookings930hrs, //work 9 30 pause 0
|
||||
expectedTime: time.Hour * 9, //pause 30 --> corrected
|
||||
},
|
||||
{
|
||||
testName: "Booking 9h 40min with 30min Break",
|
||||
bookings: testBookings910hrsBreak30min, //work 9 10 pause 30
|
||||
expectedTime: time.Hour*8 + time.Minute*55, //pause 45 --> real + corrected
|
||||
},
|
||||
{
|
||||
testName: "Booking 9h 40min with 35min Break",
|
||||
bookings: testBookings910hrsBreak35min, //work 9 10 pause 35
|
||||
expectedTime: time.Hour * 9, //pause 45 --> real + corrected
|
||||
},
|
||||
{
|
||||
testName: "Booking 9h 45min",
|
||||
bookings: testBookings945hrs, //work 9 45 pause 0
|
||||
expectedTime: time.Hour * 9, //pause 45 --> corrected
|
||||
},
|
||||
{
|
||||
testName: "Booking 10h Break 45min",
|
||||
bookings: testBookings10hrsBreak45min, //work 9 15 pause 45
|
||||
expectedTime: time.Hour*9 + time.Minute*15, //pause 45 --> real
|
||||
},
|
||||
{
|
||||
testName: "Booking 10h 30min Break 45min",
|
||||
bookings: testBookings1030hrsBreak45min, //work 9 45 pause 45
|
||||
expectedTime: time.Hour*9 + time.Minute*45, //pause 45 --> real
|
||||
},
|
||||
}
|
||||
|
||||
@@ -113,6 +158,51 @@ func TestWorkdayPausetimeDay(t *testing.T) {
|
||||
bookings: testBookings10hrs,
|
||||
expectedTime: time.Minute * 45,
|
||||
},
|
||||
{
|
||||
testName: "Booking 6h with 30 min Break",
|
||||
bookings: testBookings6hrsBreak30min, //work 6 pause 30
|
||||
expectedTime: time.Minute * 30, //pause 30 --> bc real pause
|
||||
},
|
||||
{
|
||||
testName: "Booking 6h 10min with 30 min Break",
|
||||
bookings: testBookings610hrsBreak30min, //work 6 10 pause 30
|
||||
expectedTime: time.Minute * 30, //pause 30 --> real pause
|
||||
},
|
||||
{
|
||||
testName: "Booking 9h with 30 min Break",
|
||||
bookings: testBookings9hrsBreak30min, //work 9 pause 30
|
||||
expectedTime: time.Minute * 30, //pause 30 --> real pause
|
||||
},
|
||||
{
|
||||
testName: "Booking 9h 30min",
|
||||
bookings: testBookings930hrs, //work 9 30 pause 0
|
||||
expectedTime: time.Minute * 30, //pause 30 --> corrected
|
||||
},
|
||||
{
|
||||
testName: "Booking 9h 40min with 30min Break",
|
||||
bookings: testBookings910hrsBreak30min, //work 9 10 pause 30
|
||||
expectedTime: time.Minute * 45, //pause 45 --> real + corrected
|
||||
},
|
||||
{
|
||||
testName: "Booking 9h 40min with 35min Break",
|
||||
bookings: testBookings910hrsBreak35min, //work 9 10 pause 35
|
||||
expectedTime: time.Minute * 45, //pause 45 --> real + corrected
|
||||
},
|
||||
{
|
||||
testName: "Booking 9h 45min",
|
||||
bookings: testBookings945hrs, //work 9 45 pause 0
|
||||
expectedTime: time.Minute * 45, //pause 45 --> corrected
|
||||
},
|
||||
{
|
||||
testName: "Booking 10h Break 45min",
|
||||
bookings: testBookings10hrsBreak45min, //work 9 15 pause 45
|
||||
expectedTime: time.Minute * 45, //pause 45 --> real
|
||||
},
|
||||
{
|
||||
testName: "Booking 10h 30min Break 45min",
|
||||
bookings: testBookings1030hrsBreak45min, //work 9 45 pause 45
|
||||
expectedTime: time.Minute * 45, //pause 45 --> real
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
|
||||
@@ -6,6 +6,7 @@ package models
|
||||
// this type is based on the "wochen_report" table
|
||||
|
||||
import (
|
||||
"arbeitszeitmessung/helper"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
@@ -24,25 +25,34 @@ type WorkWeek struct {
|
||||
Days []IWorkDay
|
||||
User User
|
||||
WeekStart time.Time
|
||||
weekEnd time.Time
|
||||
Worktime time.Duration
|
||||
WorktimeVirtual time.Duration
|
||||
Overtime time.Duration
|
||||
Status WeekStatus
|
||||
WeekBase WorktimeBase
|
||||
Kurzarbeit time.Duration
|
||||
}
|
||||
|
||||
type WeekStatus int8
|
||||
|
||||
const (
|
||||
WeekStatusNone WeekStatus = iota
|
||||
WeekStatusCorrected
|
||||
WeekStatusSent
|
||||
WeekStatusAccepted
|
||||
WeekStatusDifferences
|
||||
)
|
||||
|
||||
func NewWorkWeek(user User, tsMonday time.Time, populate bool) WorkWeek {
|
||||
func NewWorkWeekSimple(user User, tsMonday time.Time, populate bool) WorkWeek {
|
||||
return NewWorkWeek(user, tsMonday, tsMonday.Add(6*24*time.Hour), populate)
|
||||
}
|
||||
|
||||
func NewWorkWeek(user User, tsStart, tsEnd time.Time, populate bool) WorkWeek {
|
||||
var week WorkWeek = WorkWeek{
|
||||
User: user,
|
||||
WeekStart: tsMonday,
|
||||
WeekStart: tsStart,
|
||||
weekEnd: tsEnd,
|
||||
Status: WeekStatusNone,
|
||||
}
|
||||
if populate {
|
||||
@@ -52,13 +62,25 @@ func NewWorkWeek(user User, tsMonday time.Time, populate bool) WorkWeek {
|
||||
}
|
||||
|
||||
func (w *WorkWeek) PopulateWithDays(worktime time.Duration, overtime time.Duration) {
|
||||
slog.Debug("Populating Workweek for user", "user", w.User)
|
||||
slog.Debug("Got Days with overtime and worktime", slog.String("worktime", worktime.String()), slog.String("overtime", overtime.String()))
|
||||
w.Days = GetDays(w.User, w.WeekStart, w.WeekStart.Add(6*24*time.Hour), false)
|
||||
w.Days = GetDays(w.User, w.WeekStart, w.weekEnd, false)
|
||||
slog.Debug("Populating Workweek for user", "user", w.User.Name, "Days", lenWorkDays(w.Days), "Start", w.WeekStart, "End", w.weekEnd, "workdays", helper.GetWorkingDays(w.WeekStart, w.weekEnd))
|
||||
|
||||
if lenWorkDays(w.Days) == helper.GetWorkingDays(w.WeekStart, w.weekEnd) {
|
||||
w.WeekBase = WorktimeBaseWeek
|
||||
} else {
|
||||
w.WeekBase = WorktimeBaseDay
|
||||
}
|
||||
|
||||
for _, day := range w.Days {
|
||||
w.Worktime += day.GetWorktime(w.User, WorktimeBaseDay, false)
|
||||
w.WorktimeVirtual += day.GetWorktime(w.User, WorktimeBaseDay, true)
|
||||
dWorkTime := day.GetWorktime(w.User, w.WeekBase, false)
|
||||
dWorkTimeVirtual := day.GetWorktime(w.User, w.WeekBase, true)
|
||||
if dWorkTime < dWorkTimeVirtual {
|
||||
w.Kurzarbeit += dWorkTimeVirtual - dWorkTime
|
||||
}
|
||||
w.Worktime += dWorkTime
|
||||
w.WorktimeVirtual += dWorkTimeVirtual
|
||||
slog.Debug("Calculated Worktime", "Day", day.ToString(), "worktime", w.Worktime.String())
|
||||
}
|
||||
slog.Debug("Got worktime for user", "worktime", w.Worktime.String(), "virtualWorkTime", w.WorktimeVirtual.String())
|
||||
|
||||
@@ -78,6 +100,16 @@ func (w *WorkWeek) PopulateWithDays(worktime time.Duration, overtime time.Durati
|
||||
}
|
||||
}
|
||||
|
||||
func lenWorkDays(workDays []IWorkDay) int {
|
||||
var lenght int
|
||||
for _, day := range workDays {
|
||||
if !day.IsEmpty() || day.IsKurzArbeit() {
|
||||
lenght += 1
|
||||
}
|
||||
}
|
||||
return lenght
|
||||
}
|
||||
|
||||
func (w *WorkWeek) CheckStatus() WeekStatus {
|
||||
if w.Status != WeekStatusNone {
|
||||
return w.Status
|
||||
@@ -86,25 +118,31 @@ func (w *WorkWeek) CheckStatus() WeekStatus {
|
||||
log.Println("Cannot access Database!")
|
||||
return w.Status
|
||||
}
|
||||
qStr, err := DB.Prepare(`SELECT bestaetigt FROM wochen_report WHERE woche_start = $1::DATE AND personal_nummer = $2;`)
|
||||
qStr, err := DB.Prepare(`SELECT bestaetigt, id FROM wochen_report WHERE woche_start = $1::DATE AND personal_nummer = $2;`)
|
||||
if err != nil {
|
||||
log.Println("Error preparing SQL statement", err)
|
||||
return w.Status
|
||||
}
|
||||
|
||||
defer qStr.Close()
|
||||
var beastatigt bool
|
||||
err = qStr.QueryRow(w.WeekStart, w.User.PersonalNummer).Scan(&beastatigt)
|
||||
var beastatigt sql.NullBool
|
||||
err = qStr.QueryRow(w.WeekStart, w.User.PersonalNummer).Scan(&beastatigt, &w.Id)
|
||||
if err == sql.ErrNoRows {
|
||||
return w.Status
|
||||
}
|
||||
slog.Info("Bestätigt query res", "Best", beastatigt, "week", w.Id)
|
||||
if err != nil {
|
||||
log.Println("Error querying database", err)
|
||||
return w.Status
|
||||
}
|
||||
if beastatigt {
|
||||
switch {
|
||||
case beastatigt.Bool:
|
||||
w.Status = WeekStatusAccepted
|
||||
} else {
|
||||
case beastatigt.Valid:
|
||||
w.Status = WeekStatusSent
|
||||
default:
|
||||
w.Status = WeekStatusCorrected
|
||||
|
||||
}
|
||||
return w.Status
|
||||
}
|
||||
@@ -206,23 +244,33 @@ func (w *WorkWeek) SendWeek() error {
|
||||
return ErrRunningWeek
|
||||
}
|
||||
|
||||
if w.CheckStatus() != WeekStatusNone {
|
||||
qStr, err = DB.Prepare(`UPDATE "wochen_report" SET bestaetigt = FALSE, arbeitszeit = make_interval(secs => $3::numeric / 1000000000), ueberstunden = make_interval(secs => $4::numeric / 1000000000), anwesenheiten=$5, abwesenheiten=$6 WHERE personal_nummer = $1 AND woche_start = $2;`)
|
||||
if err != nil {
|
||||
slog.Warn("Error preparing SQL statement", "error", err)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
switch w.CheckStatus() {
|
||||
case WeekStatusNone:
|
||||
qStr, err = DB.Prepare(`INSERT INTO wochen_report (personal_nummer, woche_start, arbeitszeit, ueberstunden, anwesenheiten, abwesenheiten) VALUES ($1, $2, make_interval(secs => $3::numeric / 1000000000), make_interval(secs => $4::numeric / 1000000000), $5, $6);`)
|
||||
if err != nil {
|
||||
slog.Warn("Error preparing SQL statement", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
case WeekStatusCorrected:
|
||||
qStr, err = DB.Prepare(`UPDATE "wochen_report" SET bestaetigt = FALSE, arbeitszeit = make_interval(secs => $3::numeric / 1000000000), ueberstunden = make_interval(secs => $4::numeric / 1000000000), anwesenheiten=$5, abwesenheiten=$6 WHERE personal_nummer = $1 AND woche_start = $2;`)
|
||||
if err != nil {
|
||||
slog.Warn("Error preparing SQL statement", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
case WeekStatusSent, WeekStatusAccepted:
|
||||
qStr, err = DB.Prepare(`UPDATE "wochen_report" SET bestaetigt = null WHERE personal_nummer = $1 AND woche_start = $2 AND ($3::numeric IS NULL OR TRUE) AND ($4::numeric IS NULL OR TRUE) AND ($5::int[] IS NULL OR TRUE) AND ($6::int[] IS NULL OR TRUE);`)
|
||||
if err != nil {
|
||||
slog.Warn("Error preparing SQL statement", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
_, err = qStr.Exec(w.User.PersonalNummer, w.WeekStart, int64(w.Worktime), int64(w.Overtime), pq.Array(anwBookings), pq.Array(awBookings))
|
||||
if err != nil {
|
||||
log.Println("Error executing query!", err)
|
||||
slog.Error("Error executing query!", "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestNewWorkWeekNoPopulate(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workWeek := models.NewWorkWeek(testUser, monday, false)
|
||||
workWeek := models.NewWorkWeekSimple(testUser, monday, false)
|
||||
|
||||
if workWeek.User != testUser || workWeek.WeekStart != monday {
|
||||
t.Error("No populate workweek does not have right values!")
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
--color-neutral-300: oklch(87% 0 0);
|
||||
--color-neutral-400: oklch(70.8% 0 0);
|
||||
--color-neutral-500: oklch(55.6% 0 0);
|
||||
--color-neutral-600: oklch(43.9% 0 0);
|
||||
--color-neutral-700: oklch(37.1% 0 0);
|
||||
--color-neutral-800: oklch(26.9% 0 0);
|
||||
--color-black: #000;
|
||||
@@ -30,8 +29,6 @@
|
||||
--text-sm--line-height: calc(1.25 / 0.875);
|
||||
--text-xl: 1.25rem;
|
||||
--text-xl--line-height: calc(1.75 / 1.25);
|
||||
--text-2xl: 1.5rem;
|
||||
--text-2xl--line-height: calc(2 / 1.5);
|
||||
--font-weight-bold: 700;
|
||||
--radius-md: 0.375rem;
|
||||
--default-transition-duration: 150ms;
|
||||
@@ -241,6 +238,9 @@
|
||||
.-my-1 {
|
||||
margin-block: calc(var(--spacing) * -1);
|
||||
}
|
||||
.my-2 {
|
||||
margin-block: calc(var(--spacing) * 2);
|
||||
}
|
||||
.mt-1 {
|
||||
margin-top: calc(var(--spacing) * 1);
|
||||
}
|
||||
@@ -308,6 +308,32 @@
|
||||
mask-size: 100% 100%;
|
||||
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='M7.616 20q-.672 0-1.144-.472T6 18.385V6H5V5h4v-.77h6V5h4v1h-1v12.385q0 .69-.462 1.153T16.384 20zM17 6H7v12.385q0 .269.173.442t.443.173h8.769q.23 0 .423-.192t.192-.424zM9.808 17h1V8h-1zm3.384 0h1V8h-1zM7 6v13z'/%3E%3C/svg%3E");
|
||||
}
|
||||
.icon-\[material-symbols-light--edit-calendar-rounded\] {
|
||||
display: inline-block;
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
background-color: currentColor;
|
||||
-webkit-mask-image: var(--svg);
|
||||
mask-image: var(--svg);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='M5.616 21q-.691 0-1.153-.462T4 19.385V6.615q0-.69.463-1.152T5.616 5h1.769V3.308q0-.23.155-.384q.156-.155.386-.155t.383.155t.153.384V5h7.154V3.27q0-.213.143-.357q.144-.144.357-.144t.356.144t.144.356V5h1.769q.69 0 1.153.463T20 6.616v4.601q0 .213-.144.356t-.357.144t-.356-.144t-.143-.356v-.602H5v8.77q0 .23.192.423t.423.192h5.731q.213 0 .357.144t.143.357t-.143.356t-.357.143zm8.615-.808V19.12q0-.153.056-.296q.055-.144.186-.275l5.09-5.065q.149-.13.306-.19t.315-.062q.172 0 .338.064q.166.065.301.194l.925.944q.123.148.188.308q.064.159.064.319t-.052.322t-.2.31l-5.065 5.066q-.131.13-.275.186q-.143.056-.297.056h-1.073q-.343 0-.575-.232t-.232-.576m5.96-4.177l.925-.956l-.925-.944l-.95.95z'/%3E%3C/svg%3E");
|
||||
}
|
||||
.icon-\[material-symbols-light--lock\] {
|
||||
display: inline-block;
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
background-color: currentColor;
|
||||
-webkit-mask-image: var(--svg);
|
||||
mask-image: var(--svg);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='M6.616 21q-.667 0-1.141-.475T5 19.386v-8.77q0-.666.475-1.14T6.615 9H8V7q0-1.671 1.165-2.835Q10.329 3 12 3t2.836 1.165T16 7v2h1.385q.666 0 1.14.475t.475 1.14v8.77q0 .666-.475 1.14t-1.14.475zM12 16.5q.633 0 1.066-.434q.434-.433.434-1.066t-.434-1.066T12 13.5t-1.066.434Q10.5 14.367 10.5 15t.434 1.066q.433.434 1.066.434M9 9h6V7q0-1.25-.875-2.125T12 4t-2.125.875T9 7z'/%3E%3C/svg%3E");
|
||||
}
|
||||
.icon-\[material-symbols-light--more-time\] {
|
||||
display: inline-block;
|
||||
width: 1.25em;
|
||||
@@ -350,6 +376,9 @@
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
.contents {
|
||||
display: contents;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
@@ -380,6 +409,10 @@
|
||||
width: calc(var(--spacing) * 5);
|
||||
height: calc(var(--spacing) * 5);
|
||||
}
|
||||
.size-6 {
|
||||
width: calc(var(--spacing) * 6);
|
||||
height: calc(var(--spacing) * 6);
|
||||
}
|
||||
.h-2 {
|
||||
height: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -463,27 +496,12 @@
|
||||
.appearance-none {
|
||||
appearance: none;
|
||||
}
|
||||
.break-after-page {
|
||||
break-after: page;
|
||||
}
|
||||
.auto-rows-min {
|
||||
grid-auto-rows: min-content;
|
||||
}
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.grid-cols-5 {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
.grid-cols-\[3fr_2fr_2fr_2fr_3fr_3fr_3fr\] {
|
||||
grid-template-columns: 3fr 2fr 2fr 2fr 3fr 3fr 3fr;
|
||||
}
|
||||
.grid-cols-subgrid {
|
||||
grid-template-columns: subgrid;
|
||||
}
|
||||
.grid-rows-6 {
|
||||
grid-template-rows: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -534,11 +552,6 @@
|
||||
border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
|
||||
}
|
||||
}
|
||||
.divide-neutral-300 {
|
||||
:where(& > :not(:last-child)) {
|
||||
border-color: var(--color-neutral-300);
|
||||
}
|
||||
}
|
||||
.justify-self-end {
|
||||
justify-self: flex-end;
|
||||
}
|
||||
@@ -565,18 +578,10 @@
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 0px;
|
||||
}
|
||||
.border-r-0 {
|
||||
border-right-style: var(--tw-border-style);
|
||||
border-right-width: 0px;
|
||||
}
|
||||
.border-r-1 {
|
||||
border-right-style: var(--tw-border-style);
|
||||
border-right-width: 1px;
|
||||
}
|
||||
.border-b-0 {
|
||||
border-bottom-style: var(--tw-border-style);
|
||||
border-bottom-width: 0px;
|
||||
}
|
||||
.border-dashed {
|
||||
--tw-border-style: dashed;
|
||||
border-style: dashed;
|
||||
@@ -587,9 +592,6 @@
|
||||
.border-neutral-500 {
|
||||
border-color: var(--color-neutral-500);
|
||||
}
|
||||
.border-neutral-600 {
|
||||
border-color: var(--color-neutral-600);
|
||||
}
|
||||
.border-slate-800 {
|
||||
border-color: var(--color-slate-800);
|
||||
}
|
||||
@@ -620,9 +622,6 @@
|
||||
.p-2 {
|
||||
padding: calc(var(--spacing) * 2);
|
||||
}
|
||||
.p-8 {
|
||||
padding: calc(var(--spacing) * 8);
|
||||
}
|
||||
.px-3 {
|
||||
padding-inline: calc(var(--spacing) * 3);
|
||||
}
|
||||
@@ -635,10 +634,6 @@
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.text-2xl {
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--tw-leading, var(--text-2xl--line-height));
|
||||
}
|
||||
.text-sm {
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||
@@ -660,9 +655,6 @@
|
||||
.text-black {
|
||||
color: var(--color-black);
|
||||
}
|
||||
.text-neutral-300 {
|
||||
color: var(--color-neutral-300);
|
||||
}
|
||||
.text-neutral-500 {
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
@@ -719,18 +711,6 @@
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.\*\:text-center {
|
||||
:is(& > *) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
.\*\:not-print\:p-2 {
|
||||
:is(& > *) {
|
||||
@media not print {
|
||||
padding: calc(var(--spacing) * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
.group-hover\:text-black {
|
||||
&:is(:where(.group):hover *) {
|
||||
@media (hover: hover) {
|
||||
@@ -1024,7 +1004,7 @@
|
||||
border-width: 1px;
|
||||
border-color: var(--color-neutral-800);
|
||||
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
|
||||
transition-timing-function: var( --tw-ease, var(--default-transition-timing-function) );
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
input.btn, select.btn {
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
#let table-header(..headers) = {
|
||||
table.header(
|
||||
..headers.pos().map(h => strong(h))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
#let abrechnung(meta, days) = {
|
||||
set page(paper: "a4", margin: (x:1.5cm, y:2.25cm),
|
||||
footer:[#grid(
|
||||
columns: (3fr, .65fr),
|
||||
align: left + horizon,
|
||||
inset: .5em,
|
||||
[#meta.EmployeeName -- #meta.TimeRange], grid.cell(rowspan: 2)[#image("static/logo.png")],
|
||||
[Arbeitszeitrechnung maschinell erstellt am #meta.CurrentTimestamp],
|
||||
)
|
||||
])
|
||||
set text(font: "Noto Sans", size:10pt, fill: luma(10%))
|
||||
set table(
|
||||
stroke: 0.5pt + luma(10%),
|
||||
inset: .5em,
|
||||
align: center + horizon,
|
||||
)
|
||||
show text: it => {
|
||||
if it.text == "0min"{
|
||||
text(oklch(70.8%, 0, 0deg))[#it]
|
||||
}else if it.text.starts-with("-"){
|
||||
text(red)[#it]
|
||||
}else{
|
||||
it
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[= Abrechnung Arbeitszeit -- #meta.EmployeeName]
|
||||
|
||||
[Zeitraum: #meta.TimeRange]
|
||||
|
||||
table(
|
||||
columns: (1fr, 1fr, 1fr, 1fr, 1fr, 1fr, 1.25fr),
|
||||
fill: (x, y) =>
|
||||
if y == 0 { oklch(87%, 0, 0deg) },
|
||||
table-header(
|
||||
[Datum], [Kommen], [Gehen], [Arbeitsart], [Stunden], [Pause], [Überstunden]
|
||||
),
|
||||
.. for day in days {
|
||||
(
|
||||
[#day.Date],
|
||||
if day.DayParts.len() == 0{
|
||||
table.cell(colspan: 3)[Keine Buchungen]
|
||||
}else if not day.DayParts.first().IsWorkDay{
|
||||
table.cell(colspan: 3)[#day.DayParts.first().WorkType]
|
||||
}
|
||||
else {
|
||||
|
||||
table.cell(colspan: 3, inset: 0em)[
|
||||
|
||||
#table(
|
||||
columns: (1fr, 1fr, 1fr),
|
||||
.. for Zeit in day.DayParts {
|
||||
(
|
||||
[#Zeit.BookingFrom],
|
||||
[#Zeit.BookingTo],
|
||||
[#Zeit.WorkType],
|
||||
)
|
||||
},
|
||||
)
|
||||
]
|
||||
},
|
||||
[#day.Worktime],
|
||||
[#day.Pausetime],
|
||||
[#day.Overtime],
|
||||
)
|
||||
if day.IsFriday {
|
||||
( table.cell(colspan: 7, fill: oklch(87%, 0, 0deg))[Wochenende], ) // note the trailing comma
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
table(
|
||||
columns: (3fr, 1fr),
|
||||
align: right,
|
||||
inset: (x: .25em, y:.75em),
|
||||
stroke: none,
|
||||
table.hline(start: 0, end: 2, stroke: stroke(dash:"dashed", thickness:.5pt)),
|
||||
[Arbeitszeit :], table.cell(align: left)[#meta.WorkTime],
|
||||
[Überstunden :], table.cell(align: left)[#meta.Overtime],
|
||||
[Überstunden :],table.cell(align: left)[#meta.OvertimeTotal],
|
||||
table.hline(start: 0, end: 2),
|
||||
|
||||
)
|
||||
}
|
||||
@@ -61,6 +61,8 @@ templ SettingsPage(status int) {
|
||||
<div class="grid-cell col-span-3">
|
||||
<p>Nutzername: <span class="text-neutral-500">{ user.Vorname } { user.Name }</span></p>
|
||||
<p>Personalnummer: <span class="text-neutral-500">{ user.PersonalNummer }</span></p>
|
||||
<p>Frühester Arbeitsbegin: <span class="text-neutral-500">{ user.ArbeitMinStart.Format("15:06") } Uhr</span></p>
|
||||
<p>Spätester Arbeitsende: <span class="text-neutral-500">{ user.ArbeitMaxEnde.Format("15:06") } Uhr</span></p>
|
||||
<p>Arbeitszeit pro Tag: <span class="text-neutral-500">{ helper.FormatDuration(user.ArbeitszeitProTag()) }</span></p>
|
||||
<p>Arbeitszeit pro Woche: <span class="text-neutral-500">{ helper.FormatDuration(user.ArbeitszeitProWoche()) }</span></p>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,12 @@ templ workWeekComponent(week models.WorkWeek, onlyAccept bool) {
|
||||
<div class="grid grid-cols-5 gap-2 lg:grid-cols-1">
|
||||
if !onlyAccept {
|
||||
<div class="col-span-2">
|
||||
if week.CheckStatus() == models.WeekStatusCorrected {
|
||||
<span class="flex flex-row gap-2 items-center">
|
||||
<div class="icon-[material-symbols-light--edit-calendar-rounded]"></div>
|
||||
laufende Korrektur
|
||||
</span>
|
||||
}
|
||||
<span class="flex flex-row gap-2 items-center">
|
||||
@statusCheckMark(week.CheckStatus(), models.WeekStatusSent)
|
||||
Gesendet
|
||||
@@ -60,7 +66,7 @@ templ workWeekComponent(week models.WorkWeek, onlyAccept bool) {
|
||||
<div class="flex flex-row gap-2 col-span-3">
|
||||
@timeGaugeComponent(int8(progress), false)
|
||||
<div>
|
||||
<p>Arbeitszeit: { fmt.Sprintf("%s", helper.FormatDuration(week.Worktime)) }</p>
|
||||
<p>Arbeitszeit: { fmt.Sprintf("%s", helper.FormatDurationFill(week.Worktime, true)) }</p>
|
||||
<p>Überstunden: { fmt.Sprintf("%s", helper.FormatDurationFill(week.Overtime, true)) }</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,10 +176,12 @@ templ workDayWeekComponent(workDay *models.WorkDay) {
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<span class="icon-[material-symbols-light--schedule-outline] flex-shrink-0"></span>
|
||||
switch {
|
||||
case !workDay.TimeFrom.Equal(workDay.TimeTo):
|
||||
case !workDay.IsEmpty():
|
||||
<span>{ workDay.TimeFrom.Format("15:04") }</span>
|
||||
<span>-</span>
|
||||
<span>{ workDay.TimeTo.Format("15:04") }</span>
|
||||
case workDay.IsKurzArbeit():
|
||||
<span>Kurzarbeit</span>
|
||||
default:
|
||||
<p>Keine Anwesenheit</p>
|
||||
}
|
||||
|
||||
@@ -6,16 +6,22 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
templ changeButtonComponent(id string, workDay bool) {
|
||||
<button class="h-10 change-button-component btn w-auto group/button" type="button" onclick={ templ.JSFuncCall("editWorkday", templ.JSExpression("this"), templ.JSExpression("event"), id, workDay) }>
|
||||
<p class="hidden md:block group-[.edit]/button:hidden">Ändern</p>
|
||||
<p class="hidden group-[.edit]/button:md:block">Speichern</p>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4 md:hidden">
|
||||
<path class="group-[.edit]/button:hidden md:hidden" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325"></path>
|
||||
<path class="hidden group-[.edit]/button:block md:hidden" d="M12.736 3.97a.733.733 0 0 1 j1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="h-10 hidden group-[.edit]:flex btn basis-[content] items-center" onclick={ templ.JSFuncCall("clearEditState") }><span class="size-5 icon-[material-symbols-light--cancel-outline]"></span></button>
|
||||
templ changeButtonComponent(id string, workDay bool, disabled bool) {
|
||||
if disabled {
|
||||
<button class="h-10 change-button-component btn w-auto group/button" type="button" disabled>
|
||||
<p class="hidden md:block group-[.edit]/button:hidden">Ändern</p>
|
||||
</button>
|
||||
} else {
|
||||
<button class="h-10 change-button-component btn w-auto group/button" type="button" onclick={ templ.JSFuncCall("editWorkday", templ.JSExpression("this"), templ.JSExpression("event"), id, workDay) }>
|
||||
<p class="hidden md:block group-[.edit]/button:hidden">Ändern</p>
|
||||
<p class="hidden group-[.edit]/button:md:block">Speichern</p>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4 md:hidden">
|
||||
<path class="group-[.edit]/button:hidden md:hidden" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325"></path>
|
||||
<path class="hidden group-[.edit]/button:block md:hidden" d="M12.736 3.97a.733.733 0 0 1 j1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="h-10 hidden group-[.edit]:flex btn basis-[content] items-center" onclick={ templ.JSFuncCall("clearEditState") }><span class="size-5 icon-[material-symbols-light--cancel-outline]"></span></button>
|
||||
}
|
||||
}
|
||||
|
||||
templ newAbsenceComponent() {
|
||||
@@ -77,22 +83,22 @@ templ newBookingComponent(d models.IWorkDay) {
|
||||
|
||||
templ bookingComponent(booking models.Booking) {
|
||||
<div>
|
||||
<p class="text-neutral-500 edit-box">
|
||||
<p class={ "text-neutral-500 edit-box", templ.KV("text-red-500", !booking.Valid) }>
|
||||
<span class="text-black group-[.edit]:hidden inline">{ booking.Timestamp.Format("15:04") }</span>
|
||||
<input disabled name={ "booking_" + strconv.Itoa(booking.CounterId) } type="time" value={ booking.Timestamp.Format("15:04") } class="text-neutral-700 group-[.edit]:inline hidden bg-neutral-100 text-sm px-3 py-2 cursor-pointer"/>
|
||||
{ booking.GetBookingType() }
|
||||
if !booking.Valid {
|
||||
fehlerhafte Buchung, wird nicht zur Berechnung verwendet!
|
||||
}
|
||||
</p>
|
||||
if booking.IsSubmittedAndChecked() {
|
||||
<p>submitted</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ workdayComponent(workDay *models.WorkDay) {
|
||||
if len(workDay.Bookings) < 1 {
|
||||
if workDay.IsEmpty() && !workDay.IsKurzArbeit() {
|
||||
<p class="text group-[.edit]:hidden">Keine Buchung gefunden. Bitte Arbeitsstunden oder Grund der Abwesenheit eingeben!</p>
|
||||
} else {
|
||||
if workDay.IsKurzArbeit() && len(workDay.Bookings) > 0 {
|
||||
if workDay.IsKurzArbeit() {
|
||||
@absenceComponent(workDay.GetKurzArbeit(), true)
|
||||
}
|
||||
for _, booking := range workDay.Bookings {
|
||||
|
||||
@@ -142,8 +142,11 @@ templ defaultDayComponent(day models.IWorkDay) {
|
||||
<input type="hidden" name="action" value="change"/> <!-- default action value for ändern button -->
|
||||
</form>
|
||||
</div>
|
||||
<div class="grid-cell flex flex-row gap-2 items-end">
|
||||
@changeButtonComponent("time-"+day.Date().Format(time.DateOnly), true)
|
||||
<div class="grid-cell flex flex-row gap-2 items-end ">
|
||||
@changeButtonComponent("time-"+day.Date().Format(time.DateOnly), true, day.IsSubmittedAndAccepted())
|
||||
if day.IsSubmittedAndAccepted() {
|
||||
<span class="size-6 my-2 icon-[material-symbols-light--lock]"></span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# cron-timing: 05 01 * * 1
|
||||
container_name="arbeitszeitmessung-main-db-1"
|
||||
filename=backup-$(date '+%d%m%Y').sql
|
||||
filename=backup-$(date '+%Y%m%d').sql
|
||||
backup_folder=__BACKUP_FOLDER__
|
||||
database_name=__DATABASE__
|
||||
docker exec $container_name pg_dump $database_name > /home/pi/arbeitszeitmessung-backup/$filename
|
||||
docker exec $container_name pg_dump $database_name > $backup_folder/$filename
|
||||
echo "created backup file: "$filename
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# cron-timing: 01 00 01 01 *
|
||||
# Calls endpoint to write all public Holidays for the current year inside a database.
|
||||
port=__PORT__
|
||||
curl localhost:$port/auto/feiertage
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e # Exit on error
|
||||
|
||||
echo "Creating PostgreSQL user and setting permissions... $POSTGRES_USER for API user $POSTGRES_API_USER"
|
||||
|
||||
|
||||
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||
CREATE ROLE migrate LOGIN ENCRYPTED PASSWORD '$POSTGRES_PASSWORD';
|
||||
GRANT USAGE, CREATE ON SCHEMA public TO migrate;
|
||||
GRANT CONNECT ON DATABASE arbeitszeitmessung TO migrate;
|
||||
EOSQL
|
||||
|
||||
# psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||
|
||||
# GRANT SELECT, INSERT, UPDATE ON anwesenheit, abwesenheit, user_password, wochen_report, s_feiertage TO $POSTGRES_API_USER;
|
||||
# GRANT DELETE ON abwesenheit TO $POSTGRES_API_USER;
|
||||
# GRANT SELECT ON s_personal_daten, s_abwesenheit_typen, s_anwesenheit_typen, s_feiertage TO $POSTGRES_API_USER;
|
||||
# GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO $POSTGRES_API_USER;
|
||||
# EOSQL
|
||||
|
||||
echo "User creation and permissions setup complete!"
|
||||
|
||||
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||
|
||||
-- privilege roles
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'app_base') THEN
|
||||
CREATE ROLE app_base NOLOGIN;
|
||||
END IF;
|
||||
END
|
||||
\$\$;
|
||||
|
||||
-- dynamic login role
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '$POSTGRES_API_USER') THEN
|
||||
CREATE ROLE $POSTGRES_API_USER
|
||||
LOGIN
|
||||
ENCRYPTED PASSWORD '$POSTGRES_API_PASS';
|
||||
END IF;
|
||||
END
|
||||
\$\$;
|
||||
|
||||
-- grant base privileges
|
||||
GRANT app_base TO $POSTGRES_API_USER;
|
||||
GRANT CONNECT ON DATABASE $POSTGRES_DB TO $POSTGRES_API_USER;
|
||||
GRANT USAGE ON SCHEMA public TO $POSTGRES_API_USER;
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
EOSQL
|
||||
|
||||
# psql -v ON_ERROR_STOP=1 --username root --dbname arbeitszeitmessung
|
||||
@@ -1,12 +1,16 @@
|
||||
POSTGRES_USER=root # Postgres ADMIN Nutzername
|
||||
POSTGRES_USER=root # Postgres ADMIN Nutzername. regex:^\w+$
|
||||
POSTGRES_PASSWORD=very_secure # Postgres ADMIN Passwort
|
||||
POSTGRES_API_USER=api_nutzer # Postgres API Nutzername (für Arbeitszeitmessung)
|
||||
POSTGRES_API_USER=api_nutzer # Postgres API Nutzername (für Arbeitszeitmessung). regex:^\w+$
|
||||
POSTGRES_API_PASS=password # Postgres API Passwort (für Arbeitszeitmessung)
|
||||
POSTGRES_PATH=__ROOT__/DB # Datebank Pfad (relativ zu Docker Ordner oder absoluter pfad mit /...)
|
||||
POSTGRES_DB=arbeitszeitmessung # Postgres Datenbank Name
|
||||
POSTGRES_PORT=127.0.0.1:5432 # Postgres Port normalerweise nicht freigegeben. regex:^[0-9]{1,5}$
|
||||
POSTGRES_DB=arbeitszeitmessung # Postgres Datenbank Name. regex:^[a-z]+$
|
||||
POSTGRES_PORT=5432 # Postgres Port normalerweise nicht freigegeben. regex:^[0-9]{1,5}$
|
||||
TZ=Europe/Berlin # Zeitzone
|
||||
API_TOKEN=dont_access # API Token für ESP Endpoints
|
||||
API_TOKEN=dont_access # API Token für ESP32 Endpoints
|
||||
WEB_PORT=8000 # Port unter welchem Webserver erreichbar ist. regex:^[0-9]{1,5}$
|
||||
LOG_PATH=__ROOT__/logs # Pfad für Audit Logs
|
||||
LOG_LEVEL=warn # Welche Log-Nachrichten werden in der Konsole erscheinen
|
||||
LOG_LEVEL=warn # Welche Log-Nachrichten werden in der Konsole erscheinen. regex:^(debug|info|warn|error)$
|
||||
BACKUP_FOLDER=__ROOT__/backup # Pfad für DB Backup Datein
|
||||
|
||||
BOOKING_OUT_OF_BOUNDS=true # Buchungen außerhalb der festgelegten Arbeitszeit erlauben und auf Arbeitszeit anpassen. regex:^(true|false)$
|
||||
BOOKING_FOR_UNKNOWN_USER=true # Buchungen mit unbekannter CardUID erlauben. regex:^(true|false)$
|
||||
|
||||
208
Readme.md
208
Readme.md
@@ -2,128 +2,148 @@
|
||||
|
||||
[](https://sonar.letsstein.de/dashboard?id=arbeitszeitmessung)
|
||||
|
||||
bis jetzt ein einfaches Backend mit PostgreSQL Datenbank und GO Webserver um Arbeitszeitbuchungen per HTTP PUT einzufügen
|
||||
---
|
||||
|
||||
Eine open-source Software zur Arbeitszeitmessung
|
||||
|
||||
## Features
|
||||
|
||||
- manuelle Korrektur von einzelnen Buchungen
|
||||
- Buchung von benutzerdefinierten Abwesenheiten
|
||||
- automatische gesetzlicher Feiertage
|
||||
- Pflege eigener Feiertage
|
||||
|
||||
- wöchentliches Abrechnungssystem
|
||||
- Kontrolle der Arbeitszeiten durch direkte Führungskraft
|
||||
|
||||
- Ausgabe der Arbeitszeiten je Monat in PDF Format
|
||||
|
||||
- Anwesenheitsübersicht
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://git.letsstein.de/tom/arbeitszeitmessung arbeitszeitmessung
|
||||
|
||||
cd arbeitszeitmessung/Docker
|
||||
# .env Datei anpassen
|
||||
docker compose up -d
|
||||
cd arbeitszeitmessung
|
||||
|
||||
./install.sh
|
||||
```
|
||||
|
||||
## PREVIEW
|
||||
### Konfiguration:
|
||||
|
||||
Zeitverwaltungsansicht (/time):
|
||||
- Datenbank
|
||||
- `POSTGRES_USER` Postgres ADMIN Nutzername
|
||||
- `POSTGRES_PASSWORD` Postgres ADMIN Passwort
|
||||
- `POSTGRES_API_USER` Postgres API Nutzername für Webanwendung
|
||||
- `POSTGRES_API_PASS` Postgres API Passwort für Webanwendung
|
||||
- `POSTGRES_PATH` Datebank Pfad
|
||||
- `POSTGRES_DB` Postgres Datenbank Name
|
||||
- `POSTGRES_PORT` Postgres Port für administration
|
||||
- System
|
||||
- `TZ` Zeitzone
|
||||
- `LOG_LEVEL` Welche Log-Nachrichten werden in der Konsole erscheinen
|
||||
- Web/API
|
||||
- `API_TOKEN` API Token für ESP Endpoints
|
||||
- `WEB_PORT` Port unter welchem Webserver erreichbar ist
|
||||
- Ordnerstruktur
|
||||
- `BACKUP_FOLDER` Pfad für DB Backup Datein
|
||||
- `LOG_PATH` Pfad für Audit Logs
|
||||
|
||||

|
||||
## Administration:
|
||||
|
||||
Ansicht der Führungskraft (/team):
|
||||
### Nutzer erstellen:
|
||||
|
||||

|
||||
Nutzerdaten erstellen:
|
||||
|
||||
Nutzeransicht (/user):
|
||||
|
||||

|
||||
|
||||
## Buchungstypen
|
||||
|
||||
1 - Kommen
|
||||
2 - Gehen
|
||||
3 - Kommen Manuell
|
||||
4 - Gehen Manuell
|
||||
254 - Automatisch abgemeldet
|
||||
|
||||
## API
|
||||
|
||||
Nutzung der API
|
||||
wenn die `dev-docker-compose.yml` Datei gestartet wird, ist direkt ein SwaggerUI Server mit entsprechender Datei inbegriffen.
|
||||
|
||||
### Buchungen [/time]
|
||||
|
||||
#### [GET] Anfrage
|
||||
|
||||
Parameter: cardID (string)
|
||||
Antwort: `200`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"cradID": "test_card",
|
||||
"readerID": "test_reader",
|
||||
"bookingTyp": 2,
|
||||
"loggedTime": "2024-09-05T08:37:53.117641Z",
|
||||
"id": 5
|
||||
},
|
||||
{
|
||||
"cradID": "test_card",
|
||||
"readerID": "mytest",
|
||||
"bookingTyp": 1,
|
||||
"loggedTime": "2024-09-05T08:51:12.670827Z",
|
||||
"id": 6
|
||||
}
|
||||
]
|
||||
```sql
|
||||
INSERT INTO "s_personal_daten"
|
||||
(
|
||||
"personal_nummer",
|
||||
"vorname",
|
||||
"nachname",
|
||||
"card_uid",
|
||||
"geburtsdatum",
|
||||
"geschlecht",
|
||||
"adresse",
|
||||
"plz",
|
||||
"hauptbeschaeftigungs_ort",
|
||||
"aktiv_beschaeftigt",
|
||||
"vorgesetzter_pers_nr",
|
||||
"arbeitszeit_min_start",
|
||||
"arbeitszeit_max_ende",
|
||||
"arbeitszeit_per_tag",
|
||||
"arbeitszeit_per_woche",
|
||||
)
|
||||
VALUES (
|
||||
1,
|
||||
'Max',
|
||||
'Mustermann',
|
||||
'acde-edca',
|
||||
'2003-02-01',
|
||||
1,
|
||||
'Musterstr. 42',
|
||||
'00001',
|
||||
1,
|
||||
true,
|
||||
123,
|
||||
'07:00:00',
|
||||
'20:00:00',
|
||||
8,
|
||||
40
|
||||
);
|
||||
```
|
||||
|
||||
Antwort `500`
|
||||
Serverfehler
|
||||
Nutzerpasswort generieren (kann auch später als Passwort reset genutzt werden):
|
||||
|
||||
#### [PUT] Anfrage
|
||||
|
||||
Parameter: id (int)
|
||||
Body: (veränderte Parameter)
|
||||
|
||||
```json
|
||||
{
|
||||
"cradID": "test_card",
|
||||
"readerID": "mytest",
|
||||
"bookingTyp": 1,
|
||||
"loggedTime": "2024-09-05T08:51:12.670827Z"
|
||||
}
|
||||
```sql
|
||||
INSERT INTO "user_password"
|
||||
("personal_nummer", "pass_hash")
|
||||
VALUES (123, crypt('password', gen_salt('bf')));
|
||||
```
|
||||
|
||||
Antwort `200`
|
||||
### Buchungstypen erstellen:
|
||||
|
||||
```json
|
||||
{
|
||||
"cradID": "test_card",
|
||||
"readerID": "mytest",
|
||||
"bookingTyp": 1,
|
||||
"loggedTime": "2024-09-05T08:51:12.670827Z",
|
||||
"id": 6
|
||||
}
|
||||
Ohne definierte Anwesenheits und Abwesenheitstypen funktioniert die Anwendung nicht!
|
||||
|
||||
Anwesenheiten:
|
||||
|
||||
```sql
|
||||
INSERT INTO "s_anwesenheit_typen"
|
||||
("anwesenheit_id", "anwesenheit_name")
|
||||
VALUES (1, 'Büro');
|
||||
```
|
||||
|
||||
### Neue Buchung [/time/new]
|
||||
Abwesenheiten:
|
||||
|
||||
#### [PUT] Anfrage
|
||||
|
||||
Parameter:
|
||||
|
||||
- cardID (string)
|
||||
- readerID (string)
|
||||
- bookingType (string)
|
||||
|
||||
Antwort `202` Akzeptiert und eingefügt
|
||||
|
||||
```json
|
||||
{
|
||||
"cradID": "test_card",
|
||||
"readerID": "mytest",
|
||||
"bookingTyp": 1,
|
||||
"loggedTime": "2024-09-05T08:51:12.670827Z",
|
||||
"id": 6
|
||||
}
|
||||
```sql
|
||||
INSERT INTO "s_abwesenheit_typen"
|
||||
("abwesenheit_id", "abwesenheit_name", "arbeitszeit_equivalent")
|
||||
VALUES (1, 'Urlaub', 100);
|
||||
```
|
||||
|
||||
Antwort `409` Konflikt
|
||||
Die vorherige Buchung am selben Tag hat den gleichen Buchungstyp
|
||||
### Feiertage erstellen:
|
||||
|
||||
Die gesetzlichen Feiertage für Deutschland/Sachsen werden automatisch mit der Route `auto/feiertage` für das aktuelle Kalenderjahr erzeugt. Um weitere Unternehmensspezifische Feiertage (z.B. 24.12. oder 31.12.) mit in die Liste der Feiertage aufzunehmen, müssen diese manuell erstellt werden.
|
||||
|
||||
```sql
|
||||
INSERT INTO "s_feiertage"
|
||||
("datum", "name", "arbeitszeit_equivalent", "wiederholen")
|
||||
VALUES ('2026-12-24', 'Helligabend', 50, 1);
|
||||
```
|
||||
|
||||
Wenn `wiederholen` == 1 wird der Feiertag automatisch beim Aufruf von `auto/feiertage` mit ins nächste Jahr (am selben Datum) übernommen.
|
||||
|
||||
Das Feld `arbeitszeit_equivalent` `arbeitszeit_equivalent` ist die prozentuelle Zeit am Tag welche durch diesen Eintrag eingenommen wird. (dies gilt auch für die [Buchungstypen](#buchungstypen-erstellen))
|
||||
|
||||
Alle weiteren Tabellen sollte ausschließlich über die Weboberfläche oder per API befüllt werden.
|
||||
|
||||
---
|
||||
|
||||
# Filestrukture
|
||||
|
||||
```
|
||||
|
||||
├── Backend (Webserver)
|
||||
│ ├── doc (Templates for Document Creator --> typst used to create PDF Reports)
|
||||
│ │ ├── static
|
||||
|
||||
485
install.sh
485
install.sh
@@ -1,186 +1,327 @@
|
||||
#!/usr/bin/env bash
|
||||
#©Tom Tröger 2026
|
||||
set -e
|
||||
|
||||
envFile=Docker/.env
|
||||
envBkp=Docker/.env.old
|
||||
envExample=Docker/env.example
|
||||
|
||||
autoBackupScript=Cron/autoBackup.sh
|
||||
autoHolidaysScript=Cron/autoHolidays.sh
|
||||
autoLogoutScript=Cron/autoLogout.sh
|
||||
cronFilePath=Cron
|
||||
customCronFilePath=Docker/config/cron
|
||||
|
||||
echo "Checking Docker installation..."
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Docker not found. Install Docker? [y/N]"
|
||||
read -r install_docker
|
||||
if [[ "$install_docker" =~ ^[Yy]$ ]]; then
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
autoBackupScript=autoBackup.sh
|
||||
autoHolidaysScript=autoHolidays.sh
|
||||
autoLogoutScript=autoLogout.sh
|
||||
|
||||
function checkDocker() {
|
||||
echo "Checking Docker installation..."
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Docker not found. Install Docker? [y/N]"
|
||||
read -r install_docker
|
||||
if [[ "$install_docker" =~ ^[Yy]$ ]]; then
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
else
|
||||
echo "Docker is required. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Docker is already installed."
|
||||
fi
|
||||
|
||||
###########################################################################
|
||||
|
||||
echo "Checking Docker Compose..."
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
echo "Docker Compose plugin missing. You may need to update Docker."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
###########################################################################
|
||||
|
||||
function setupConfig() {
|
||||
local reconfig=false
|
||||
if [ $# -gt 0 ]; then
|
||||
if ask_reconfig $1 "Reconfigure .env File?"
|
||||
then
|
||||
reconfig=true
|
||||
else
|
||||
echo "Docker is required. Exiting."
|
||||
exit 1
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
echo "Docker is already installed."
|
||||
fi
|
||||
|
||||
###########################################################################
|
||||
|
||||
echo "Checking Docker Compose..."
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
echo "Docker Compose plugin missing. You may need to update Docker."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
###########################################################################
|
||||
|
||||
echo "Preparing .env file..."
|
||||
if [ ! -f $envFile ]; then
|
||||
if [ -f $envExample ]; then
|
||||
echo ".env not found. Creating interactively from .env.example."
|
||||
> $envFile
|
||||
|
||||
while IFS= read -r line; do
|
||||
|
||||
#ignore empty lines and comments
|
||||
[[ "$line" =~ ^#.*$ || -z "$line" ]] && continue
|
||||
|
||||
|
||||
key=$(printf "%s" "$line" | cut -d '=' -f 1)
|
||||
rest=$(printf "%s" "$line" | cut -d '=' -f 2-)
|
||||
|
||||
# extract inline comment portion
|
||||
comment=$(printf "%s" "$rest" | sed -n 's/.*# \(.*\)$/\1/p')
|
||||
raw_val=$(printf "%s" "$rest" | sed 's/ *#.*//')
|
||||
default_value=$(printf "%s" "$raw_val" | sed 's/"//g')
|
||||
|
||||
# Replace __ROOT__ with script pwd
|
||||
default_value="${default_value/__ROOT__/$(pwd)}"
|
||||
|
||||
regex=""
|
||||
if [[ "$comment" =~ regex:(.*)$ ]]; then
|
||||
regex="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
|
||||
comment=$(printf "%s" "$comment" | sed 's/ regex:.*//')
|
||||
|
||||
while true; do
|
||||
if [ -z "$comment" ]; then
|
||||
printf "Value for $key - $comment (default: $default_value"
|
||||
else
|
||||
printf "Value for $key (default: $default_value"
|
||||
fi
|
||||
if [ -n "$regex" ]; then
|
||||
printf ", must match: %s" "$regex"
|
||||
fi
|
||||
printf "):\n"
|
||||
|
||||
read user_input < /dev/tty
|
||||
|
||||
# empty input -> take default
|
||||
[ -z "$user_input" ] && user_input="$default_value"
|
||||
|
||||
printf "\e[A$user_input\n"
|
||||
|
||||
# validate
|
||||
if [ -n "$regex" ]; then
|
||||
if [[ "$user_input" =~ $regex ]]; then
|
||||
echo "$key=$user_input" >> $envFile
|
||||
break
|
||||
else
|
||||
printf "Invalid value. Does not match regex: %s\n" "$regex"
|
||||
continue
|
||||
fi
|
||||
else
|
||||
echo "$key=$user_input" >> $envFile
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
done < $envExample
|
||||
|
||||
echo ".env created."
|
||||
else
|
||||
echo "No .env or .env.example found."
|
||||
echo "Creating an empty .env file for manual editing."
|
||||
touch $envFile
|
||||
fi
|
||||
else
|
||||
echo "Using existing .env. (found at $envFile)"
|
||||
fi
|
||||
|
||||
###########################################################################
|
||||
|
||||
LOG_PATH=$(grep -E '^LOG_PATH=' $envFile | cut -d= -f2)
|
||||
if [ -z "$LOG_PATH" ]; then
|
||||
echo "LOG_PATH not found in .env using default $(pwd)/logs"
|
||||
LOG_PATH=$(pwd)/logs
|
||||
else
|
||||
LOG_PATH=Docker/$LOG_PATH
|
||||
fi
|
||||
mkdir -p $LOG_PATH
|
||||
echo "Created logs folder at $LOG_PATH"
|
||||
|
||||
###########################################################################
|
||||
|
||||
echo -e "\n\n"
|
||||
echo "Start containers with docker compose up -d? [y/N]"
|
||||
read -r start_containers
|
||||
if [[ "$start_containers" =~ ^[Yy]$ ]]; then
|
||||
cd Docker
|
||||
docker compose up -d
|
||||
echo "Containers started."
|
||||
else
|
||||
echo "You can start them manually with: docker compose up -d"
|
||||
fi
|
||||
|
||||
###########################################################################
|
||||
|
||||
echo -e "\n\n"
|
||||
echo "Setup Crontab for automatic logout, backup and holiday creation? [y/N]"
|
||||
read -r setup_cron
|
||||
if [[ "$setup_cron" =~ ^[Yy]$ ]]; then
|
||||
WEB_PORT=$(grep -E '^WEB_PORT=' $envFile | cut -d= -f2)
|
||||
if [ -z "$WEB_PORT" ]; then
|
||||
echo "WEB_PORT not found in .env using default 8000"
|
||||
WEB_PORT=8000
|
||||
fi
|
||||
|
||||
POSTGRES_DB=$(grep -E '^POSTGRES_DB=' $envFile | cut -d= -f2)
|
||||
if [ -z "$POSTGRES_DB" ]; then
|
||||
echo "arbeitszeitmessung not found in .env using default arbeitszeitmessung"
|
||||
POSTGRES_DB="arbeitszeitmessung"
|
||||
fi
|
||||
|
||||
sed -i "s/__PORT__/$WEB_PORT/" $autoHolidaysScript
|
||||
sed -i "s/__PORT__/$WEB_PORT/" $autoLogoutScript
|
||||
sed -i "s/__DATABASE__/$POSTGRES_DB/" $autoBackupScript
|
||||
|
||||
chmod +x $autoBackupScript $autoHolidaysScript $autoLogoutScript
|
||||
|
||||
# echo "Scripts build with PORT=$WEB_PORT and DATABSE=$POSTGRES_DB!"
|
||||
echo "Adding rules to crontab."
|
||||
|
||||
cron_commands=$(mktemp /tmp/arbeitszeitmessung-cron.XXX)
|
||||
|
||||
for file in Cron/*; do
|
||||
cron_timing=$(grep -E '^# cron-timing:' "$file" | sed 's/^# cron-timing:[[:space:]]*//')
|
||||
|
||||
if [ -z "$cron_timing" ]; then
|
||||
echo "No cron-timing found in $file, so it's not added to crontab."
|
||||
continue
|
||||
fi
|
||||
echo -e "\r\n==================================================\r\n"
|
||||
echo "Preparing .env file..."
|
||||
if [ ! -f $envFile ] || [ $reconfig == true ]; then
|
||||
if [ -f $envExample ]; then
|
||||
if [ $reconfig == true ]; then
|
||||
echo "Reconfiguring env file. Backup stored at $envBkp"
|
||||
echo "All previous values will be used as defaults!"
|
||||
cp $envFile $envBkp
|
||||
else
|
||||
echo ".env not found. Creating interactively from .env.example."
|
||||
fi
|
||||
> $envFile
|
||||
|
||||
( crontab -l ; echo "$cron_timing $(pwd)/$file" )| awk '!x[$0]++' | crontab -
|
||||
echo "Added entry to crontab: $cron_timing $(pwd)/$file."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if systemctl is-active --quiet cron.service ; then
|
||||
echo "cron.service is running. Everything should be fine now."
|
||||
else
|
||||
echo "cron.service is not running. Please start and enable cron.service."
|
||||
echo "For how to start a service, see: https://wiki.ubuntuusers.de/systemd/systemctl UNITNAME will be cron.service"
|
||||
while IFS= read -r line; do
|
||||
|
||||
#ignore empty lines and comments
|
||||
[[ "$line" =~ ^#.*$ || -z "$line" ]] && continue
|
||||
|
||||
|
||||
local key=$(printf "%s" "$line" | cut -d '=' -f 1)
|
||||
local rest=$(printf "%s" "$line" | cut -d '=' -f 2-)
|
||||
|
||||
# extract inline comment portion
|
||||
local comment=$(printf "%s" "$rest" | sed -n 's/.*# \(.*\)$/\1/p')
|
||||
local raw_val=$(printf "%s" "$rest" | sed 's/ *#.*//')
|
||||
|
||||
local default_value=$(printf "%s" "$raw_val" | sed 's/"//g')
|
||||
|
||||
if [ $reconfig == true ]; then
|
||||
local previous_value=$(grep -E "^$key=" $envBkp | cut -d= -f2)
|
||||
if [ -n "$previous_value" ]; then
|
||||
default_value=$previous_value
|
||||
fi
|
||||
fi
|
||||
|
||||
# Replace __ROOT__ with script pwd
|
||||
local default_value="${default_value/__ROOT__/$(pwd)}"
|
||||
|
||||
regex=""
|
||||
if [[ "$comment" =~ regex:(.*)$ ]]; then
|
||||
regex="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
|
||||
comment=$(printf "%s" "$comment" | sed 's/ regex:.*//')
|
||||
|
||||
while true; do
|
||||
if [ -z "$comment" ]; then
|
||||
printf "Value for $key (default: $default_value"
|
||||
else
|
||||
printf "Value for $key - $comment (default: $default_value"
|
||||
fi
|
||||
if [ -n "$regex" ]; then
|
||||
printf ", must match: %s" "$regex"
|
||||
fi
|
||||
printf "):\n"
|
||||
|
||||
read user_input < /dev/tty
|
||||
|
||||
# empty input -> take default
|
||||
[ -z "$user_input" ] && user_input="$default_value"
|
||||
|
||||
printf "\e[A$user_input\n"
|
||||
|
||||
# validate
|
||||
if [ -n "$regex" ]; then
|
||||
if [[ "$user_input" =~ $regex ]]; then
|
||||
echo "$key=$user_input" >> $envFile
|
||||
break
|
||||
else
|
||||
printf "Invalid value. Does not match regex: %s\n" "$regex"
|
||||
continue
|
||||
fi
|
||||
else
|
||||
echo "$key=$user_input" >> $envFile
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
done < $envExample
|
||||
|
||||
echo ".env created."
|
||||
else
|
||||
echo "No .env or .env.example found."
|
||||
echo "Creating an empty .env file for manual editing."
|
||||
touch $envFile
|
||||
fi
|
||||
else
|
||||
echo "Using existing .env. (found at $envFile)"
|
||||
fi
|
||||
}
|
||||
###########################################################################
|
||||
|
||||
function setupFolders(){
|
||||
if [ $# -gt 0 ]; then
|
||||
if ! ask_reconfig $1 "Recreate Folders?"
|
||||
then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
LOG_PATH=$(grep -E '^LOG_PATH=' $envFile | cut -d= -f2)
|
||||
if [ -z "$LOG_PATH" ]; then
|
||||
echo "LOG_PATH not found in .env using default $(pwd)/logs"
|
||||
LOG_PATH=$(pwd)/logs
|
||||
fi
|
||||
if [ ! -d "$LOG_PATH" ]; then
|
||||
mkdir -p $LOG_PATH
|
||||
echo "Created logs folder at $LOG_PATH"
|
||||
fi
|
||||
|
||||
else
|
||||
echo "Please setup cron manually by executing crontab -e and adding all files from inside the Cron directory!"
|
||||
fi
|
||||
POSTGRES_PATH=$(grep -E '^POSTGRES_PATH=' $envFile | cut -d= -f2)
|
||||
if [ -z "$POSTGRES_PATH" ]; then
|
||||
echo "POSTGRES_PATH not found in .env using default $(pwd)/DB"
|
||||
POSTGRES_PATH=$(pwd)/DB
|
||||
fi
|
||||
if [ ! -d "$POSTGRES_PATH" ]; then
|
||||
mkdir -p $POSTGRES_PATH
|
||||
echo "Created DB folder at $POSTGRES_PATH"
|
||||
fi
|
||||
|
||||
BACKUP_FOLDER=$(grep -E '^BACKUP_FOLDER=' $envFile | cut -d= -f2)
|
||||
if [ -z "$BACKUP_FOLDER" ]; then
|
||||
echo "BACKUP_FOLDER not found in .env using default $(pwd)/backup"
|
||||
BACKUP_FOLDER=$(pwd)/backup
|
||||
fi
|
||||
if [ ! -d "$BACKUP_FOLDER" ]; then
|
||||
mkdir -p $BACKUP_FOLDER
|
||||
echo "Created backup folder at $BACKUP_FOLDER"
|
||||
fi
|
||||
}
|
||||
###########################################################################
|
||||
|
||||
function setupCron(){
|
||||
echo -e "\r\n==================================================\r\n"
|
||||
echo "Setup Crontab for automatic logout, backup and holiday creation? [y/N]"
|
||||
read -r setup_cron
|
||||
if [[ "$setup_cron" =~ ^[Yy]$ ]]; then
|
||||
echo "Copying custom cron files to $customCronFilePath"
|
||||
mkdir -p "$customCronFilePath"
|
||||
|
||||
if [ ! -s "$customCronFilePath/$autoBackupScript" ];then
|
||||
cp "$cronFilePath/$autoBackupScript" "$customCronFilePath/$autoBackupScript"
|
||||
echo "Copied $autoBackupScript"
|
||||
fi
|
||||
|
||||
if [ ! -s "$customCronFilePath/$autoLogoutScript" ];then
|
||||
cp "$cronFilePath/$autoLogoutScript" "$customCronFilePath/$autoLogoutScript"
|
||||
echo "Copied $autoLogoutScript"
|
||||
fi
|
||||
|
||||
if [ ! -s "$customCronFilePath/$autoHolidaysScript" ];then
|
||||
cp "$cronFilePath/$autoHolidaysScript" "$customCronFilePath/$autoHolidaysScript"
|
||||
echo "Copied $autoHolidaysScript"
|
||||
fi
|
||||
|
||||
WEB_PORT=$(grep -E '^WEB_PORT=' $envFile | cut -d= -f2)
|
||||
if [ -z "$WEB_PORT" ]; then
|
||||
echo "WEB_PORT not found in .env using default 8000"
|
||||
WEB_PORT=8000
|
||||
fi
|
||||
|
||||
POSTGRES_DB=$(grep -E '^POSTGRES_DB=' $envFile | cut -d= -f2)
|
||||
if [ -z "$POSTGRES_DB" ]; then
|
||||
echo "POSTGRES_DB not found in .env using default arbeitszeitmessung"
|
||||
POSTGRES_DB="arbeitszeitmessung"
|
||||
fi
|
||||
|
||||
BACKUP_FOLDER=$(grep -E '^BACKUP_FOLDER=' $envFile | cut -d= -f2)
|
||||
if [ -z "$BACKUP_FOLDER" ]; then
|
||||
echo "BACKUP_FOLDER not found in .env using default $(pwd)/backup"
|
||||
BACKUP_FOLDER="$(pwd)/backup"
|
||||
fi
|
||||
|
||||
sed -i "s|__PORT__|$WEB_PORT|" $customCronFilePath/$autoHolidaysScript && \
|
||||
sed -i "s|__PORT__|$WEB_PORT|" $customCronFilePath/$autoLogoutScript && \
|
||||
sed -i "s|__DATABASE__|$POSTGRES_DB|" $customCronFilePath/$autoBackupScript && \
|
||||
sed -i "s|__BACKUP_FOLDER__|$BACKUP_FOLDER|" $customCronFilePath/$autoBackupScript
|
||||
|
||||
chmod +x "$customCronFilePath/$autoBackupScript" "$customCronFilePath/$autoHolidaysScript" "$customCronFilePath/$autoLogoutScript"
|
||||
|
||||
# echo "Scripts build with PORT=$WEB_PORT and DATABSE=$POSTGRES_DB!"
|
||||
echo "Adding rules to crontab."
|
||||
|
||||
cron_commands=$(mktemp /tmp/arbeitszeitmessung-cron.XXX)
|
||||
pwd
|
||||
|
||||
for file in $customCronFilePath/*; do
|
||||
cron_timing=$(grep -E '^# cron-timing:' "$file" | sed 's/^# cron-timing:[[:space:]]*//')
|
||||
|
||||
if [ -z "$cron_timing" ]; then
|
||||
echo "No cron-timing found in $file, so it's not added to crontab."
|
||||
continue
|
||||
fi
|
||||
|
||||
( crontab -l ; echo "$cron_timing $(pwd)/$file" )| awk '!x[$0]++' | crontab -
|
||||
echo "Added entry to crontab: $cron_timing $(pwd)/$file."
|
||||
done
|
||||
|
||||
if systemctl is-active --quiet cron.service ; then
|
||||
echo "cron.service is running. Everything should be fine now."
|
||||
else
|
||||
echo "cron.service is not running. Please start and enable cron.service."
|
||||
echo "For how to start a service, see: https://wiki.ubuntuusers.de/systemd/systemctl UNITNAME will be cron.service"
|
||||
fi
|
||||
|
||||
else
|
||||
echo "Please setup cron manually by executing crontab -e and adding all files from inside the Cron directory!"
|
||||
fi
|
||||
}
|
||||
###########################################################################
|
||||
|
||||
function startContainer(){
|
||||
echo -e "\r\n==================================================\r\n"
|
||||
echo "Start containers with docker compose up -d? [y/N]"
|
||||
read -r start_containers
|
||||
if [[ "$start_containers" =~ ^[Yy]$ ]]; then
|
||||
cd Docker
|
||||
docker compose up -d
|
||||
echo "Containers started."
|
||||
else
|
||||
echo "You can start them manually with: docker compose up -d"
|
||||
fi
|
||||
}
|
||||
###########################################################################
|
||||
|
||||
function help(){
|
||||
echo "Installer Script für Arbeitszeitmessung Software"
|
||||
echo -e "\r\n==================================================\r\n"
|
||||
echo "Nutzung: ./install.sh [options]"
|
||||
echo -e "\r\n==================================================\r\n"
|
||||
echo "Optionen:"
|
||||
echo " -h zeigt diese Übersicht"
|
||||
echo " -c .env Datei bearbeiten/aktualisieren && cron neu configurieren"
|
||||
echo -e "\r\n=================================================="
|
||||
}
|
||||
###########################################################################
|
||||
|
||||
function main(){
|
||||
echo -e "================Arbeitszeitmessung================\r\n"
|
||||
if [ $# -gt 0 ];then
|
||||
if [ $1 == reconfig ]; then
|
||||
echo -e "================Reconfiguring================\r\n"
|
||||
setupConfig $1
|
||||
setupFolders $1
|
||||
setupCron $1
|
||||
fi
|
||||
else
|
||||
checkDocker
|
||||
setupConfig
|
||||
setupFolders
|
||||
setupCron
|
||||
startContainer
|
||||
fi
|
||||
echo "Installation finished, you can re-run the script any time!"
|
||||
}
|
||||
###########################################################################
|
||||
|
||||
function ask_reconfig(){
|
||||
echo -e "\r\n==================================================\r\n"
|
||||
echo "$2 [y/N]"
|
||||
read -r do_reconfig
|
||||
|
||||
[[ "$do_reconfig" =~ ^[Yy]$ ]] && return # true
|
||||
echo "Skipping..."
|
||||
return 1
|
||||
}
|
||||
###########################################################################
|
||||
|
||||
while getopts ":hc" opt; do
|
||||
case $opt in
|
||||
h) help; exit 0 ;;
|
||||
c) main reconfig; exit 0 ;;
|
||||
*) echo "Ungültiges Argument"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
main
|
||||
|
||||
Reference in New Issue
Block a user