Files
arbeitszeitmessung/Backend/endpoints/pdf-create.go
tom 82eb8018a6
Some checks failed
Tests / Run Go Tests (push) Failing after 1m47s
updated pdf renderer to support zipped output
2025-12-16 07:02:17 +01:00

298 lines
9.8 KiB
Go

package endpoints
import (
"arbeitszeitmessung/helper"
"arbeitszeitmessung/helper/paramParser"
"arbeitszeitmessung/models"
"archive/zip"
"bytes"
"fmt"
"log"
"log/slog"
"net/http"
"time"
"github.com/Dadido3/go-typst"
)
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
if day.IsWorkDay() {
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")
typstDayPart.BookingTo = workDay.Bookings[i+1].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,
})
}
if workdayAbsence := workDay.GetWorktimeAbsence(); (workdayAbsence != models.Absence{}) {
typstDayParts = append(typstDayParts, typstDayPart{IsWorkDay: false, WorkType: workdayAbsence.AbwesenheitTyp.Name})
}
} else {
absentDay, _ := day.(*models.Absence)
typstDayParts = append(typstDayParts, typstDayPart{IsWorkDay: false, WorkType: absentDay.AbwesenheitTyp.Name})
}
return typstDayParts
}
func PDFCreateController(w http.ResponseWriter, r *http.Request) {
helper.RequiresLogin(Session, w, r)
switch r.Method {
case http.MethodGet:
user, err := models.GetUserFromSession(Session, r.Context())
if err != nil {
log.Println("Error getting user!")
return
}
pp := paramParser.New(r.URL.Query())
startDate := pp.ParseTimestampFallback("start_date", time.DateOnly, time.Now())
personalNumbers := pp.ParseIntListFallback("employe_list", ",", make([]int, 0))
employes, err := models.GetUserByPersonalNrMulti(personalNumbers)
if err != nil {
slog.Warn("Error getting employes!", slog.Any("Error", err))
return
}
n := 0
for _, e := range employes {
if user.IsSuperior(e) {
employes[n] = e
n++
}
}
employes = employes[:n]
reportData := createReports(employes, startDate)
switch pp.ParseStringFallback("output", "render") {
case "render":
output, err := renderPDFSingle(reportData)
if err != nil {
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)))
output.WriteTo(w)
w.WriteHeader(http.StatusOK)
case "download":
pdfReports, err := renderPDFMulti(reportData)
if err != nil {
slog.Warn("Could not create pdf report", slog.Any("Error", err))
w.WriteHeader(http.StatusInternalServerError)
}
output, err := zipPfd(pdfReports, &reportData)
if err != nil {
slog.Warn("Could not create pdf report", 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)))
output.WriteTo(w)
w.WriteHeader(http.StatusOK)
}
default:
http.Error(w, "Method not allowed!", http.StatusMethodNotAllowed)
}
}
func createReports(employes []models.User, startDate time.Time) []typstData {
startDate = helper.GetFirstOfMonth(startDate)
endDate := startDate.AddDate(0, 1, -1)
var employeData []typstData
for _, employe := range employes {
if data, err := createEmployeReport(employe, startDate, endDate); err != nil {
slog.Warn("Error when creating employeReport", slog.Any("user", employe), slog.Any("error", err))
} else {
employeData = append(employeData, data)
}
}
return employeData
}
func createEmployeReport(employee models.User, startDate, endDate time.Time) (typstData, error) {
targetHoursThisMonth := employee.ArbeitszeitProWocheFrac(.2) * time.Duration(helper.GetWorkingDays(startDate, endDate))
workDaysThisMonth := models.GetDays(employee, startDate, endDate.AddDate(0, 0, 1), false)
slog.Debug("Baseline Working hours", "targetHours", targetHoursThisMonth.Hours())
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)
if err != nil {
slog.Warn("Failed to convert to days", slog.Any("error", err))
return typstData{}, 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),
WorkTime: helper.FormatDurationFill(workHours, true),
Kurzarbeit: helper.FormatDurationFill(kurzarbeitHours, true),
OvertimeTotal: "",
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
}
func renderPDFSingle(data []typstData) (bytes.Buffer, error) {
var markup bytes.Buffer
var output bytes.Buffer
typstCLI := typst.DockerExec{
ContainerName: helper.GetEnv("TYPST_CONTAINER", "arbeitszeitmessung-doc-creator"),
}
if err := typst.InjectValues(&markup, map[string]any{"data": data}); err != nil {
return output, err
}
// Import the template and invoke the template function with the custom data.
// Show is used to replace the current document with whatever content the template function in `template.typ` returns.
markup.WriteString(`
#import "templates/abrechnung.typ": abrechnung
#for d in data {
abrechnung(d.Meta, d.Days)
}
`)
// Compile the prepared markup with Typst and write the result it into `output.pdf`.
if err := typstCLI.Compile(&markup, &output, nil); err != nil {
return output, err
}
return output, nil
}
func renderPDFMulti(data []typstData) ([]bytes.Buffer, error) {
var outputMulti []bytes.Buffer
typstRender := typst.DockerExec{
ContainerName: helper.GetEnv("TYPST_CONTAINER", "arbeitszeitmessung-doc-creator"),
}
for _, d := range data {
var markup bytes.Buffer
var outputSingle bytes.Buffer
if err := typst.InjectValues(&markup, map[string]any{"meta": d.Meta, "days": d.Days}); err != nil {
return outputMulti, err
}
markup.WriteString(`
#import "templates/abrechnung.typ": abrechnung
#abrechnung(meta, days)
`)
if err := typstRender.Compile(&markup, &outputSingle, nil); err != nil {
return outputMulti, err
}
outputMulti = append(outputMulti, outputSingle)
}
return outputMulti, nil
}
func zipPfd(pdfReports []bytes.Buffer, reportData *[]typstData) (bytes.Buffer, error) {
var zipOutput bytes.Buffer
zipWriter := zip.NewWriter(&zipOutput)
for index, report := range pdfReports {
zipFile, err := zipWriter.Create((*reportData)[index].FileName)
if err != nil {
fmt.Println(err)
}
_, err = zipFile.Write(report.Bytes())
if err != nil {
fmt.Println(err)
}
}
// Make sure to check the error on Close.
err := zipWriter.Close()
return zipOutput, err
}
type typstMetadata struct {
TimeRange string `json:"time-range"`
EmployeeName string `json:"employee-name"`
WorkTime string `json:"worktime"`
Kurzarbeit string `json:"kurzarbeit"`
Overtime string `json:"overtime"`
OvertimeTotal string `json:"overtime-total"`
CurrentTimestamp string `json:"current-timestamp"`
}
type typstDayPart struct {
BookingFrom string `json:"booking-from"`
BookingTo string `json:"booking-to"`
WorkType string `json:"worktype"`
IsWorkDay bool `json:"is-workday"`
}
type typstDay struct {
Date string `json:"date"`
DayParts []typstDayPart `json:"day-parts"`
Worktime string `json:"worktime"`
Pausetime string `json:"pausetime"`
Overtime string `json:"overtime"`
Kurzarbeit string `json:"kurzarbeit"`
IsFriday bool `json:"is-weekend"`
}
type typstData struct {
Meta typstMetadata `json:"meta"`
Days []typstDay `json:"days"`
FileName string
}