From c201a39155c3ad1f49a94cf5461321470813341a Mon Sep 17 00:00:00 2001 From: martinscoding Date: Mon, 15 Jun 2026 10:50:19 +0200 Subject: [PATCH 1/3] feat(hr): add hour budgets payroll and break violations --- README.md | 64 +- .../billing/controller/BillingController.java | 39 + .../dto/CreatePayrollStatementRequest.java | 14 + .../billing/dto/PayrollStatementResponse.java | 44 + .../billing/model/PayrollStatement.java | 77 + .../billing/model/PayrollStatus.java | 8 + .../PayrollStatementRepository.java | 18 + .../billing/service/BillingService.java | 191 +- .../controller/PlanningController.java | 20 +- .../planning/dto/CreateHourBudgetRequest.java | 13 + .../planning/dto/CreateWorkPlanRequest.java | 8 +- .../planning/dto/HourBudgetResponse.java | 33 + .../planning/dto/UpdateWorkPlanRequest.java | 5 +- .../planning/dto/WorkPlanResponse.java | 2 + .../workforce/planning/model/HourBudget.java | 67 + .../workforce/planning/model/WorkPlan.java | 4 + .../repository/HourBudgetRepository.java | 17 + .../planning/service/PlanningService.java | 120 +- .../time/controller/TimeController.java | 16 + .../time/dto/BreakViolationResponse.java | 36 + .../time/repository/TimeEntryRepository.java | 8 + .../workforce/time/service/TimeService.java | 41 + database/mysql/init.sql | 39 +- frontend/hr-web/src/App.jsx | 4 + frontend/hr-web/src/pages/DashboardPage.jsx | 6 +- frontend/hr-web/src/pages/HourBudgetsPage.jsx | 164 ++ frontend/hr-web/src/pages/PayrollPage.jsx | 212 ++ frontend/hr-web/src/pages/TimePage.jsx | 50 +- .../shiftlead-web/src/pages/PlanningPage.jsx | 51 +- frontend/shiftlead-web/src/pages/TimePage.jsx | 52 +- hr_budget_payroll_breaks_fresh.patch | 2113 +++++++++++++++++ tests/api-test.js | 30 +- 32 files changed, 3435 insertions(+), 131 deletions(-) create mode 100644 backend/billing-service/src/main/java/com/workforce/billing/dto/CreatePayrollStatementRequest.java create mode 100644 backend/billing-service/src/main/java/com/workforce/billing/dto/PayrollStatementResponse.java create mode 100644 backend/billing-service/src/main/java/com/workforce/billing/model/PayrollStatement.java create mode 100644 backend/billing-service/src/main/java/com/workforce/billing/model/PayrollStatus.java create mode 100644 backend/billing-service/src/main/java/com/workforce/billing/repository/PayrollStatementRepository.java create mode 100644 backend/planning-service/src/main/java/com/workforce/planning/dto/CreateHourBudgetRequest.java create mode 100644 backend/planning-service/src/main/java/com/workforce/planning/dto/HourBudgetResponse.java create mode 100644 backend/planning-service/src/main/java/com/workforce/planning/model/HourBudget.java create mode 100644 backend/planning-service/src/main/java/com/workforce/planning/repository/HourBudgetRepository.java create mode 100644 backend/time-service/src/main/java/com/workforce/time/dto/BreakViolationResponse.java create mode 100644 frontend/hr-web/src/pages/HourBudgetsPage.jsx create mode 100644 frontend/hr-web/src/pages/PayrollPage.jsx create mode 100644 hr_budget_payroll_breaks_fresh.patch diff --git a/README.md b/README.md index f5a0912..3e4e531 100644 --- a/README.md +++ b/README.md @@ -107,17 +107,19 @@ Alle Frontends verwenden denselben Login-Endpunkt über den API-Gateway (`localh | Seite | URL | Funktion | |---|---|---| | Benutzerverwaltung | `/users` | Schichtleiter/Mitarbeiter anlegen, bearbeiten, deaktivieren | -| Stundenübersicht | `/time` | Gesamtstunden pro Zeitraum, Monatsdetail pro Mitarbeiter | +| Stundenübersicht | `/time` | Gesamtstunden, Monatsdetail und Pausenverstösse prüfen | +| Stundenfreigabe | `/hour-budgets` | Monatliche HR-Stundenkontingente für Schichtleiter festlegen | | Rechnungen | `/invoices` | Rechnungen erstellen, versenden, als bezahlt markieren | +| Lohnauszüge | `/payroll` | Monatslohn aus Stunden, Rate, Zuschlägen und Abzügen berechnen | | Absenzen & Ferien | `/absences` | Ferienanträge genehmigen/ablehnen, Absenzen erfassen | **Schichtleiter** → http://localhost:3003 | Seite | URL | Funktion | |---|---|---| -| Arbeitsplanung | `/planning` | Arbeitspläne erstellen, Schichten hinzufügen, veröffentlichen | +| Arbeitsplanung | `/planning` | Arbeitspläne mit HR-Stundenfreigabe erstellen, Schichten hinzufügen, veröffentlichen | | Aufträge | `/orders` | Zugewiesene Aufträge aus dem Order Service einsehen und Status ändern | -| Arbeitszeiten | `/time` | Gesamtstunden und Monatsdetails der Mitarbeiter aus dem Time Service einsehen | +| Arbeitszeiten | `/time` | Gesamtstunden, Monatsdetails und Pausenverstösse der Mitarbeiter einsehen | **Flutter Mobile App** (Login: `emp.meier` / `password`) @@ -498,25 +500,44 @@ Content-Type: application/json ### 6.5 Planning Service · Port 8004 -**Aufgabe:** Schichtleiter erstellt Arbeitspläne für sein Team. Mitarbeiter sehen ihren Arbeitskalender. Der Service verwaltet HR-Stundenkontingente, geplante Schichten, Warnungen bei Über-/Unterplanung und den veröffentlichten Kalender für die Mobile App. +**Aufgabe:** HR gibt monatliche Stundenkontingente pro Schichtleiter frei. Schichtleiter erstellen darauf basierend Arbeitspläne für ihr Team. Mitarbeiter sehen veröffentlichte Schichten im Mobile-Kalender. **Implementiert:** -- `POST /api/planning/workplans` – Arbeitsplan mit HR-Stundenkontingent erstellen +- `POST /api/planning/hour-budgets` – HR-Stundenkontingent pro Schichtleiter und Monat erstellen/aktualisieren +- `GET /api/planning/hour-budgets` – HR-Stundenkontingente auflisten, optional mit `?shiftLeadId=` filtern +- `POST /api/planning/workplans` – Arbeitsplan erstellen und HR-Stundenkontingent automatisch übernehmen - `GET /api/planning/workplans` – Arbeitspläne auflisten, optional mit `?shiftLeadId=` filtern - `GET /api/planning/workplans/{id}` – Arbeitsplan inkl. Schichten und Stundenübersicht anzeigen - `PUT /api/planning/workplans/{id}` – Arbeitsplan-Entwurf bearbeiten - `POST /api/planning/workplans/{id}/shifts` – Schicht hinzufügen, optional mit `orderId` - `PUT /api/planning/workplans/{id}/publish` – Arbeitsplan veröffentlichen - `GET /api/planning/calendar/{employeeId}` – veröffentlichte Kalenderschichten eines Mitarbeiters anzeigen -- Entities: `WorkPlan`, `Shift`, `WorkPlanStatus` +- Entities: `HourBudget`, `WorkPlan`, `Shift`, `WorkPlanStatus` **Stundenlogik:** -- `approvedHours` enthält die von HR freigegebenen Monats-/Planstunden. +- `approvedHours` wird aus der HR-Stundenfreigabe übernommen und nicht mehr vom Schichtleiter eingegeben. - `plannedHours` wird aus allen Schichten eines Arbeitsplans berechnet. - `remainingHours` zeigt die Differenz zwischen freigegebenen und geplanten Stunden. - `overLimit` wird `true`, wenn mehr als das HR-Kontingent geplant wurde. - `underPlanned` wird `true`, wenn weniger als 95 % des HR-Kontingents geplant wurden. +**Beispiel: HR-Stundenfreigabe erstellen** + +```http +POST http://localhost:8000/api/planning/hour-budgets +Authorization: Bearer +Content-Type: application/json + +{ + "shiftLeadId": 3, + "year": 2026, + "month": 6, + "approvedHours": 1000, + "createdBy": 2, + "notes": "Sommermonat Juni" +} +``` + **Beispiel: Arbeitsplan erstellen** ```http @@ -528,8 +549,7 @@ Content-Type: application/json "title": "Monatsplan Juni", "shiftLeadId": 3, "startDate": "2026-06-01", - "endDate": "2026-06-30", - "approvedHours": 1000 + "endDate": "2026-06-30" } ``` @@ -565,9 +585,11 @@ Content-Type: application/json - `GET /api/time/month/{employeeId}?month=&year=` – Monatsauswertung pro Mitarbeiter - `GET /api/time/total?from=&to=` – Gesamtstunden aller Mitarbeiter im Zeitraum - `GET /api/time/total/{employeeId}?from=&to=` – Gesamtstunden eines Mitarbeiters im Zeitraum +- `GET /api/time/break-violations?from=&to=&employeeId=` – Pausenverstösse auswerten - Rollen: HR/Admin für Auswertungen, Schichtleiter für Team-Übersicht, Mitarbeiter für eigenen Check-in/out - Entities: `TimeEntry` - Berechnung: Netto-Stunden aus Check-in, Check-out und Pausenzeit +- Pausenregel: mehr als 6 Stunden Brutto-Arbeitszeit → mindestens 30 Minuten Pause; mehr als 9 Stunden → mindestens 45 Minuten Pause --- @@ -592,16 +614,26 @@ Content-Type: application/json ### 6.8 Billing Service · Port 8007 -**Aufgabe:** HR erstellt Rechnungen basierend auf erfassten Arbeitsstunden. +**Aufgabe:** HR erstellt Rechnungen und monatliche Lohnauszüge basierend auf erfassten Arbeitsstunden. -**Noch zu implementieren:** +**Implementiert Rechnungen:** - `POST /api/billing/invoices` – Rechnung erstellen - `GET /api/billing/invoices` – alle Rechnungen - `GET /api/billing/invoices/{id}` – Rechnungsdetail - `PUT /api/billing/invoices/{id}/send` – Rechnung versenden +- `PUT /api/billing/invoices/{id}/pay` – Rechnung als bezahlt markieren - Entities: `Invoice`, `InvoicePosition` - Status-Enum: `DRAFT`, `SENT`, `PAID` -- Stundendaten kommen vom Time Service + +**Implementiert Lohnauszüge:** +- `POST /api/billing/payroll-statements` – Lohnauszug aus Monatsstunden, Stundenrate, Zuschlägen und Abzügen erstellen oder neu berechnen +- `GET /api/billing/payroll-statements` – Lohnauszüge auflisten, optional mit `?status=` filtern +- `GET /api/billing/payroll-statements/{id}` – Lohnauszug anzeigen +- `PUT /api/billing/payroll-statements/{id}/approve` – Lohnauszug freigeben +- `PUT /api/billing/payroll-statements/{id}/pay` – Lohnauszug als bezahlt markieren +- Entities: `PayrollStatement`, `PayrollStatus` +- Status-Enum: `DRAFT`, `APPROVED`, `PAID` +- Stundendaten kommen aus `time_entries` des Time Service --- @@ -669,8 +701,10 @@ note=Rapportfoto Eingang A **Implementiert:** - Login mit Rollenprüfung `HR` - Benutzerverwaltung (`/users`): Schichtleiter/Mitarbeiter anlegen, bearbeiten, deaktivieren -- Stundenübersicht (`/time`): Gesamtstunden aller Mitarbeiter, Monatsdetail pro Mitarbeiter +- Stundenübersicht (`/time`): Gesamtstunden, Monatsdetail und Pausenverstösse prüfen +- Stundenfreigabe (`/hour-budgets`): Monatskontingente pro Schichtleiter freigeben - Rechnungen (`/invoices`): Erstellen, versenden, als bezahlt markieren (DRAFT → SENT → PAID) +- Lohnauszüge (`/payroll`): Monatslohn aus Stunden, Stundenrate, Zuschlägen und Abzügen berechnen (DRAFT → APPROVED → PAID) - Absenzen & Ferien (`/absences`): Ferienanfragen genehmigen/ablehnen, Absenzen erfassen und verwalten **Noch zu implementieren:** @@ -681,10 +715,10 @@ note=Rapportfoto Eingang A **Implementiert:** - Login mit Rollenprüfung `SHIFT_LEAD` und Speicherung von `userId` - Dashboard mit Kacheln für Planung, Aufträge und Notizen -- Arbeitsplan-Erstellung (`/planning`) +- Arbeitsplan-Erstellung (`/planning`) mit automatisch übernommener HR-Stundenfreigabe - Schichten hinzufügen inklusive Mitarbeiter-Auswahl, optionaler Auftrag-ID und Notiz - Stundenübersicht mit HR-Kontingent, geplanten Stunden, Reststunden und Warnungen -- Arbeitszeiten-Seite (`/time`) lädt Gesamtstunden und Monatsdetails aus dem Time Service +- Arbeitszeiten-Seite (`/time`) lädt Gesamtstunden, Monatsdetails und Pausenverstösse aus dem Time Service - Arbeitsplan veröffentlichen, damit Mitarbeiter die Schichten im Mobile-Kalender sehen **Implementiert zusätzlich:** diff --git a/backend/billing-service/src/main/java/com/workforce/billing/controller/BillingController.java b/backend/billing-service/src/main/java/com/workforce/billing/controller/BillingController.java index 5100cad..67bb17e 100644 --- a/backend/billing-service/src/main/java/com/workforce/billing/controller/BillingController.java +++ b/backend/billing-service/src/main/java/com/workforce/billing/controller/BillingController.java @@ -1,7 +1,9 @@ package com.workforce.billing.controller; import com.workforce.billing.dto.CreateInvoiceRequest; +import com.workforce.billing.dto.CreatePayrollStatementRequest; import com.workforce.billing.dto.InvoiceResponse; +import com.workforce.billing.dto.PayrollStatementResponse; import com.workforce.billing.service.BillingService; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -94,4 +96,41 @@ public ResponseEntity send(@PathVariable Long id) { public ResponseEntity pay(@PathVariable Long id) { return ResponseEntity.ok(billingService.markPaid(id)); } + /** Gibt alle Lohnauszüge zurück, optional gefiltert nach Status. */ + @GetMapping("/payroll-statements") + @PreAuthorize("hasAnyRole('HR', 'ADMIN')") + public ResponseEntity> getPayrollStatements( + @RequestParam(required = false) String status) { + return ResponseEntity.ok(billingService.getPayrollStatements(status)); + } + + /** Gibt einen einzelnen Lohnauszug zurück. */ + @GetMapping("/payroll-statements/{id}") + @PreAuthorize("hasAnyRole('HR', 'ADMIN')") + public ResponseEntity getPayrollStatement(@PathVariable Long id) { + return ResponseEntity.ok(billingService.getPayrollStatement(id)); + } + + /** Erstellt oder berechnet einen monatlichen Lohnauszug aus den erfassten Arbeitsstunden. */ + @PostMapping("/payroll-statements") + @PreAuthorize("hasAnyRole('HR', 'ADMIN')") + public ResponseEntity createPayrollStatement( + @RequestBody CreatePayrollStatementRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(billingService.createPayrollStatement(request)); + } + + /** Gibt einen Lohnauszug frei. */ + @PutMapping("/payroll-statements/{id}/approve") + @PreAuthorize("hasAnyRole('HR', 'ADMIN')") + public ResponseEntity approvePayrollStatement(@PathVariable Long id) { + return ResponseEntity.ok(billingService.approvePayrollStatement(id)); + } + + /** Markiert einen Lohnauszug als bezahlt. */ + @PutMapping("/payroll-statements/{id}/pay") + @PreAuthorize("hasAnyRole('HR', 'ADMIN')") + public ResponseEntity payPayrollStatement(@PathVariable Long id) { + return ResponseEntity.ok(billingService.payPayrollStatement(id)); + } + } diff --git a/backend/billing-service/src/main/java/com/workforce/billing/dto/CreatePayrollStatementRequest.java b/backend/billing-service/src/main/java/com/workforce/billing/dto/CreatePayrollStatementRequest.java new file mode 100644 index 0000000..40ad939 --- /dev/null +++ b/backend/billing-service/src/main/java/com/workforce/billing/dto/CreatePayrollStatementRequest.java @@ -0,0 +1,14 @@ +package com.workforce.billing.dto; + +import java.math.BigDecimal; + +/** DTO zum Erstellen oder Neuberechnen eines monatlichen Lohnauszugs. */ +public record CreatePayrollStatementRequest( + Long employeeId, + Integer year, + Integer month, + BigDecimal hourlyRate, + BigDecimal bonusAmount, + BigDecimal deductionAmount, + Long createdBy +) {} diff --git a/backend/billing-service/src/main/java/com/workforce/billing/dto/PayrollStatementResponse.java b/backend/billing-service/src/main/java/com/workforce/billing/dto/PayrollStatementResponse.java new file mode 100644 index 0000000..3a5ce43 --- /dev/null +++ b/backend/billing-service/src/main/java/com/workforce/billing/dto/PayrollStatementResponse.java @@ -0,0 +1,44 @@ +package com.workforce.billing.dto; + +import com.workforce.billing.model.PayrollStatement; +import com.workforce.billing.model.PayrollStatus; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** Rückgabe-DTO für monatliche Lohnauszüge. */ +public record PayrollStatementResponse( + Long id, + Long employeeId, + Integer year, + Integer month, + BigDecimal hourlyRate, + BigDecimal totalHours, + BigDecimal grossAmount, + BigDecimal bonusAmount, + BigDecimal deductionAmount, + BigDecimal netAmount, + PayrollStatus status, + Long createdBy, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static PayrollStatementResponse from(PayrollStatement statement) { + return new PayrollStatementResponse( + statement.getId(), + statement.getEmployeeId(), + statement.getYear(), + statement.getMonth(), + statement.getHourlyRate(), + statement.getTotalHours(), + statement.getGrossAmount(), + statement.getBonusAmount(), + statement.getDeductionAmount(), + statement.getNetAmount(), + statement.getStatus(), + statement.getCreatedBy(), + statement.getCreatedAt(), + statement.getUpdatedAt() + ); + } +} diff --git a/backend/billing-service/src/main/java/com/workforce/billing/model/PayrollStatement.java b/backend/billing-service/src/main/java/com/workforce/billing/model/PayrollStatement.java new file mode 100644 index 0000000..bc4f6bb --- /dev/null +++ b/backend/billing-service/src/main/java/com/workforce/billing/model/PayrollStatement.java @@ -0,0 +1,77 @@ +package com.workforce.billing.model; + +import jakarta.persistence.*; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** JPA-Entity für monatliche Lohnauszüge von Mitarbeitern. */ +@Data +@Entity +@Table( + name = "payroll_statements", + uniqueConstraints = @UniqueConstraint( + name = "uk_payroll_employee_month", + columnNames = {"employee_id", "payroll_year", "payroll_month"} + ) +) +public class PayrollStatement { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "employee_id", nullable = false) + private Long employeeId; + + @Column(name = "payroll_year", nullable = false) + private Integer year; + + @Column(name = "payroll_month", nullable = false) + private Integer month; + + @Column(name = "hourly_rate", nullable = false, precision = 8, scale = 2) + private BigDecimal hourlyRate = BigDecimal.ZERO; + + @Column(name = "total_hours", nullable = false, precision = 8, scale = 2) + private BigDecimal totalHours = BigDecimal.ZERO; + + @Column(name = "gross_amount", nullable = false, precision = 10, scale = 2) + private BigDecimal grossAmount = BigDecimal.ZERO; + + @Column(name = "bonus_amount", nullable = false, precision = 10, scale = 2) + private BigDecimal bonusAmount = BigDecimal.ZERO; + + @Column(name = "deduction_amount", nullable = false, precision = 10, scale = 2) + private BigDecimal deductionAmount = BigDecimal.ZERO; + + @Column(name = "net_amount", nullable = false, precision = 10, scale = 2) + private BigDecimal netAmount = BigDecimal.ZERO; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private PayrollStatus status = PayrollStatus.DRAFT; + + @Column(name = "created_by") + private Long createdBy; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + void prePersist() { + LocalDateTime now = LocalDateTime.now(); + if (createdAt == null) createdAt = now; + if (updatedAt == null) updatedAt = now; + if (status == null) status = PayrollStatus.DRAFT; + } + + @PreUpdate + void preUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/billing-service/src/main/java/com/workforce/billing/model/PayrollStatus.java b/backend/billing-service/src/main/java/com/workforce/billing/model/PayrollStatus.java new file mode 100644 index 0000000..0bd6a0f --- /dev/null +++ b/backend/billing-service/src/main/java/com/workforce/billing/model/PayrollStatus.java @@ -0,0 +1,8 @@ +package com.workforce.billing.model; + +/** Status eines monatlichen Lohnauszugs. */ +public enum PayrollStatus { + DRAFT, + APPROVED, + PAID +} diff --git a/backend/billing-service/src/main/java/com/workforce/billing/repository/PayrollStatementRepository.java b/backend/billing-service/src/main/java/com/workforce/billing/repository/PayrollStatementRepository.java new file mode 100644 index 0000000..64e22b4 --- /dev/null +++ b/backend/billing-service/src/main/java/com/workforce/billing/repository/PayrollStatementRepository.java @@ -0,0 +1,18 @@ +package com.workforce.billing.repository; + +import com.workforce.billing.model.PayrollStatement; +import com.workforce.billing.model.PayrollStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +/** Spring Data JPA Repository für Lohnauszüge. */ +public interface PayrollStatementRepository extends JpaRepository { + + Optional findByEmployeeIdAndYearAndMonth(Long employeeId, Integer year, Integer month); + + List findByStatusOrderByYearDescMonthDescEmployeeIdAsc(PayrollStatus status); + + List findAllByOrderByYearDescMonthDescEmployeeIdAsc(); +} diff --git a/backend/billing-service/src/main/java/com/workforce/billing/service/BillingService.java b/backend/billing-service/src/main/java/com/workforce/billing/service/BillingService.java index d89ab5d..47f9016 100644 --- a/backend/billing-service/src/main/java/com/workforce/billing/service/BillingService.java +++ b/backend/billing-service/src/main/java/com/workforce/billing/service/BillingService.java @@ -1,42 +1,39 @@ package com.workforce.billing.service; import com.workforce.billing.dto.CreateInvoiceRequest; +import com.workforce.billing.dto.CreatePayrollStatementRequest; import com.workforce.billing.dto.InvoiceResponse; +import com.workforce.billing.dto.PayrollStatementResponse; import com.workforce.billing.exception.ResourceNotFoundException; import com.workforce.billing.model.Invoice; import com.workforce.billing.model.InvoicePosition; import com.workforce.billing.model.InvoiceStatus; +import com.workforce.billing.model.PayrollStatement; +import com.workforce.billing.model.PayrollStatus; import com.workforce.billing.repository.InvoiceRepository; +import com.workforce.billing.repository.PayrollStatementRepository; import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; +import java.time.YearMonth; import java.util.List; /** - * Service-Klasse für das Rechnungswesen. - * - *

Implementiert folgende User Stories: - *

    - *
  • US-HR-06: Rechnung erstellen
  • - *
  • US-HR-07: Rechnungen verwalten (Status-Übergänge)
  • - *
+ * Service-Klasse für Rechnungen und Lohnauszüge. */ @Service @RequiredArgsConstructor public class BillingService { private final InvoiceRepository invoiceRepository; + private final PayrollStatementRepository payrollStatementRepository; + private final JdbcTemplate jdbcTemplate; - /** - * Gibt alle Rechnungen zurück, optional gefiltert nach Status. - * Implementiert US-HR-07 (Rechnungsübersicht). - * - * @param status Statusfilter (optional, z.B. "DRAFT", "SENT", "PAID") - * @return Liste der Rechnungen, neueste zuerst - */ + /** Gibt alle Rechnungen zurück, optional gefiltert nach Status. */ @Transactional(readOnly = true) public List getAll(String status) { List invoices = status != null @@ -46,27 +43,13 @@ public List getAll(String status) { return invoices.stream().map(InvoiceResponse::from).toList(); } - /** - * Gibt eine einzelne Rechnung anhand ihrer ID zurück. - * - * @param id Rechnungs-ID - * @return {@link InvoiceResponse} - * @throws ResourceNotFoundException wenn keine Rechnung mit dieser ID existiert - */ + /** Gibt eine einzelne Rechnung anhand ihrer ID zurück. */ @Transactional(readOnly = true) public InvoiceResponse getById(Long id) { return InvoiceResponse.from(findOrThrow(id)); } - /** - * Erstellt eine neue Rechnung als Entwurf (Status: DRAFT). - * Berechnet pro Position die Zwischensumme (hours * rate) - * und summiert alle Positionen zum Gesamtbetrag. - * Implementiert US-HR-06 (Rechnung erstellen). - * - * @param request DTO mit Auftrags-ID, HR-Person und Rechnungspositionen - * @return neu erstellter {@link InvoiceResponse} mit Status DRAFT - */ + /** Erstellt eine neue Rechnung als Entwurf. */ @Transactional public InvoiceResponse create(CreateInvoiceRequest request) { Invoice invoice = new Invoice(); @@ -102,15 +85,7 @@ public InvoiceResponse create(CreateInvoiceRequest request) { return InvoiceResponse.from(invoiceRepository.save(invoice)); } - /** - * Setzt den Status einer Rechnung auf SENT. - * Implementiert US-HR-07 (Rechnung versenden). - * - * @param id Rechnungs-ID - * @return aktualisierter {@link InvoiceResponse} - * @throws ResourceNotFoundException wenn die Rechnung nicht existiert - * @throws IllegalStateException wenn die Rechnung nicht im Status DRAFT ist - */ + /** Setzt den Status einer Rechnung auf SENT. */ @Transactional public InvoiceResponse send(Long id) { Invoice invoice = findOrThrow(id); @@ -123,15 +98,7 @@ public InvoiceResponse send(Long id) { return InvoiceResponse.from(invoiceRepository.save(invoice)); } - /** - * Markiert eine Rechnung als bezahlt (Status: PAID). - * Implementiert US-HR-07 (Rechnung als bezahlt markieren). - * - * @param id Rechnungs-ID - * @return aktualisierter {@link InvoiceResponse} - * @throws ResourceNotFoundException wenn die Rechnung nicht existiert - * @throws IllegalStateException wenn die Rechnung nicht im Status SENT ist - */ + /** Markiert eine Rechnung als bezahlt. */ @Transactional public InvoiceResponse markPaid(Long id) { Invoice invoice = findOrThrow(id); @@ -143,16 +110,130 @@ public InvoiceResponse markPaid(Long id) { return InvoiceResponse.from(invoiceRepository.save(invoice)); } - /** - * Hilfsmethode: sucht eine Rechnung und wirft Exception wenn nicht gefunden. - * - * @param id Rechnungs-ID - * @return die gefundene {@link Invoice}-Entity - * @throws ResourceNotFoundException wenn nicht gefunden - */ + /** Gibt alle Lohnauszüge zurück, optional gefiltert nach Status. */ + @Transactional(readOnly = true) + public List getPayrollStatements(String status) { + List statements = status != null && !status.isBlank() + ? payrollStatementRepository.findByStatusOrderByYearDescMonthDescEmployeeIdAsc(PayrollStatus.valueOf(status)) + : payrollStatementRepository.findAllByOrderByYearDescMonthDescEmployeeIdAsc(); + + return statements.stream().map(PayrollStatementResponse::from).toList(); + } + + /** Gibt einen Lohnauszug anhand seiner ID zurück. */ + @Transactional(readOnly = true) + public PayrollStatementResponse getPayrollStatement(Long id) { + return PayrollStatementResponse.from(findPayrollOrThrow(id)); + } + + /** Erstellt oder aktualisiert einen monatlichen Lohnauszug anhand der gespeicherten Time Entries. */ + @Transactional + public PayrollStatementResponse createPayrollStatement(CreatePayrollStatementRequest request) { + validatePayrollRequest(request); + YearMonth period = YearMonth.of(request.year(), request.month()); + BigDecimal totalHours = loadEmployeeHours(request.employeeId(), period); + BigDecimal hourlyRate = normalizeMoney(request.hourlyRate()); + BigDecimal bonus = normalizeMoney(request.bonusAmount()); + BigDecimal deductions = normalizeMoney(request.deductionAmount()); + + BigDecimal gross = totalHours.multiply(hourlyRate).setScale(2, RoundingMode.HALF_UP).add(bonus); + BigDecimal net = gross.subtract(deductions).setScale(2, RoundingMode.HALF_UP); + + PayrollStatement statement = payrollStatementRepository + .findByEmployeeIdAndYearAndMonth(request.employeeId(), request.year(), request.month()) + .orElseGet(PayrollStatement::new); + + if (statement.getStatus() == PayrollStatus.PAID) { + throw new IllegalStateException("Bezahlte Lohnauszüge können nicht neu berechnet werden"); + } + + statement.setEmployeeId(request.employeeId()); + statement.setYear(request.year()); + statement.setMonth(request.month()); + statement.setHourlyRate(hourlyRate); + statement.setTotalHours(totalHours); + statement.setBonusAmount(bonus); + statement.setDeductionAmount(deductions); + statement.setGrossAmount(gross); + statement.setNetAmount(net); + statement.setCreatedBy(request.createdBy()); + statement.setStatus(PayrollStatus.DRAFT); + + return PayrollStatementResponse.from(payrollStatementRepository.save(statement)); + } + + /** Gibt einen Lohnauszug frei. */ + @Transactional + public PayrollStatementResponse approvePayrollStatement(Long id) { + PayrollStatement statement = findPayrollOrThrow(id); + if (statement.getStatus() != PayrollStatus.DRAFT) { + throw new IllegalStateException("Nur Lohnauszüge im Status DRAFT können freigegeben werden"); + } + statement.setStatus(PayrollStatus.APPROVED); + return PayrollStatementResponse.from(payrollStatementRepository.save(statement)); + } + + /** Markiert einen Lohnauszug als bezahlt. */ + @Transactional + public PayrollStatementResponse payPayrollStatement(Long id) { + PayrollStatement statement = findPayrollOrThrow(id); + if (statement.getStatus() != PayrollStatus.APPROVED) { + throw new IllegalStateException("Nur freigegebene Lohnauszüge können bezahlt werden"); + } + statement.setStatus(PayrollStatus.PAID); + return PayrollStatementResponse.from(payrollStatementRepository.save(statement)); + } + + private BigDecimal loadEmployeeHours(Long employeeId, YearMonth period) { + BigDecimal sum = jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(total_hours), 0) FROM time_entries " + + "WHERE employee_id = ? AND entry_date BETWEEN ? AND ?", + BigDecimal.class, + employeeId, + period.atDay(1), + period.atEndOfMonth() + ); + return sum == null ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP) : sum.setScale(2, RoundingMode.HALF_UP); + } + + private void validatePayrollRequest(CreatePayrollStatementRequest request) { + if (request == null) { + throw new IllegalArgumentException("Request darf nicht leer sein"); + } + if (request.employeeId() == null || request.employeeId() <= 0) { + throw new IllegalArgumentException("employeeId ist erforderlich"); + } + if (request.year() == null || request.year() < 2000) { + throw new IllegalArgumentException("year ist erforderlich und muss gültig sein"); + } + if (request.month() == null || request.month() < 1 || request.month() > 12) { + throw new IllegalArgumentException("month muss zwischen 1 und 12 liegen"); + } + if (request.hourlyRate() == null || request.hourlyRate().compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("hourlyRate ist erforderlich und darf nicht negativ sein"); + } + if (request.bonusAmount() != null && request.bonusAmount().compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("bonusAmount darf nicht negativ sein"); + } + if (request.deductionAmount() != null && request.deductionAmount().compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("deductionAmount darf nicht negativ sein"); + } + } + + private BigDecimal normalizeMoney(BigDecimal value) { + return value == null ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP) : value.setScale(2, RoundingMode.HALF_UP); + } + + /** Hilfsmethode: sucht eine Rechnung und wirft Exception wenn nicht gefunden. */ private Invoice findOrThrow(Long id) { return invoiceRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException( "Rechnung mit ID " + id + " nicht gefunden")); } + + private PayrollStatement findPayrollOrThrow(Long id) { + return payrollStatementRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException( + "Lohnauszug mit ID " + id + " nicht gefunden")); + } } diff --git a/backend/planning-service/src/main/java/com/workforce/planning/controller/PlanningController.java b/backend/planning-service/src/main/java/com/workforce/planning/controller/PlanningController.java index 58d5c8c..c8107c1 100644 --- a/backend/planning-service/src/main/java/com/workforce/planning/controller/PlanningController.java +++ b/backend/planning-service/src/main/java/com/workforce/planning/controller/PlanningController.java @@ -1,7 +1,9 @@ package com.workforce.planning.controller; +import com.workforce.planning.dto.CreateHourBudgetRequest; import com.workforce.planning.dto.CreateShiftRequest; import com.workforce.planning.dto.CreateWorkPlanRequest; +import com.workforce.planning.dto.HourBudgetResponse; import com.workforce.planning.dto.ShiftResponse; import com.workforce.planning.dto.UpdateWorkPlanRequest; import com.workforce.planning.dto.WorkPlanResponse; @@ -37,7 +39,23 @@ public class PlanningController { private final PlanningService planningService; - /** Erstellt einen neuen Arbeitsplan mit HR-Stundenkontingent. */ + + /** Erstellt oder aktualisiert eine monatliche HR-Stundenfreigabe für einen Schichtleiter. */ + @PostMapping("/hour-budgets") + @PreAuthorize("hasAnyRole('HR', 'ADMIN')") + public ResponseEntity saveHourBudget(@RequestBody CreateHourBudgetRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(planningService.saveHourBudget(request)); + } + + /** Gibt HR-Stundenfreigaben zurück; optional gefiltert nach Schichtleiter-ID. */ + @GetMapping("/hour-budgets") + @PreAuthorize("hasAnyRole('HR', 'ADMIN', 'SHIFT_LEAD')") + public ResponseEntity> getHourBudgets( + @RequestParam(required = false) Long shiftLeadId) { + return ResponseEntity.ok(planningService.getHourBudgets(shiftLeadId)); + } + + /** Erstellt einen neuen Arbeitsplan mit automatisch übernommener HR-Stundenfreigabe. */ @PostMapping("/workplans") @PreAuthorize("hasAnyRole('SHIFT_LEAD', 'HR', 'ADMIN')") public ResponseEntity createWorkPlan(@RequestBody CreateWorkPlanRequest request) { diff --git a/backend/planning-service/src/main/java/com/workforce/planning/dto/CreateHourBudgetRequest.java b/backend/planning-service/src/main/java/com/workforce/planning/dto/CreateHourBudgetRequest.java new file mode 100644 index 0000000..d39c483 --- /dev/null +++ b/backend/planning-service/src/main/java/com/workforce/planning/dto/CreateHourBudgetRequest.java @@ -0,0 +1,13 @@ +package com.workforce.planning.dto; + +import java.math.BigDecimal; + +/** DTO zum Erstellen oder Aktualisieren einer HR-Stundenfreigabe. */ +public record CreateHourBudgetRequest( + Long shiftLeadId, + Integer year, + Integer month, + BigDecimal approvedHours, + Long createdBy, + String notes +) {} diff --git a/backend/planning-service/src/main/java/com/workforce/planning/dto/CreateWorkPlanRequest.java b/backend/planning-service/src/main/java/com/workforce/planning/dto/CreateWorkPlanRequest.java index cfb3671..bc5674a 100644 --- a/backend/planning-service/src/main/java/com/workforce/planning/dto/CreateWorkPlanRequest.java +++ b/backend/planning-service/src/main/java/com/workforce/planning/dto/CreateWorkPlanRequest.java @@ -6,11 +6,9 @@ /** * DTO zum Erstellen eines Arbeitsplans. * - * @param title Titel des Arbeitsplans - * @param shiftLeadId ID des verantwortlichen Schichtleiters - * @param startDate Startdatum des Planungszeitraums - * @param endDate Enddatum des Planungszeitraums - * @param approvedHours von HR freigegebenes Stundenkontingent + *

Das Stundenkontingent wird nicht vom Schichtleiter bestimmt, sondern anhand der + * HR-Stundenfreigabe für {@code shiftLeadId + Monat/Jahr} automatisch übernommen. + * {@code approvedHours} bleibt nur aus Abwärtskompatibilitätsgründen im DTO und wird ignoriert.

*/ public record CreateWorkPlanRequest( String title, diff --git a/backend/planning-service/src/main/java/com/workforce/planning/dto/HourBudgetResponse.java b/backend/planning-service/src/main/java/com/workforce/planning/dto/HourBudgetResponse.java new file mode 100644 index 0000000..6277198 --- /dev/null +++ b/backend/planning-service/src/main/java/com/workforce/planning/dto/HourBudgetResponse.java @@ -0,0 +1,33 @@ +package com.workforce.planning.dto; + +import com.workforce.planning.model.HourBudget; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** Rückgabe-DTO für HR-Stundenfreigaben. */ +public record HourBudgetResponse( + Long id, + Long shiftLeadId, + Integer year, + Integer month, + BigDecimal approvedHours, + Long createdBy, + String notes, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static HourBudgetResponse from(HourBudget budget) { + return new HourBudgetResponse( + budget.getId(), + budget.getShiftLeadId(), + budget.getYear(), + budget.getMonth(), + budget.getApprovedHours(), + budget.getCreatedBy(), + budget.getNotes(), + budget.getCreatedAt(), + budget.getUpdatedAt() + ); + } +} diff --git a/backend/planning-service/src/main/java/com/workforce/planning/dto/UpdateWorkPlanRequest.java b/backend/planning-service/src/main/java/com/workforce/planning/dto/UpdateWorkPlanRequest.java index 6fc0f24..61f5c0b 100644 --- a/backend/planning-service/src/main/java/com/workforce/planning/dto/UpdateWorkPlanRequest.java +++ b/backend/planning-service/src/main/java/com/workforce/planning/dto/UpdateWorkPlanRequest.java @@ -3,7 +3,10 @@ import java.math.BigDecimal; import java.time.LocalDate; -/** DTO zum Bearbeiten eines Arbeitsplan-Entwurfs. */ +/** + * DTO zum Bearbeiten eines Arbeitsplan-Entwurfs. + * approvedHours wird ignoriert, weil das Kontingent aus der HR-Stundenfreigabe kommt. + */ public record UpdateWorkPlanRequest( String title, LocalDate startDate, diff --git a/backend/planning-service/src/main/java/com/workforce/planning/dto/WorkPlanResponse.java b/backend/planning-service/src/main/java/com/workforce/planning/dto/WorkPlanResponse.java index 01aedd2..6b01a4f 100644 --- a/backend/planning-service/src/main/java/com/workforce/planning/dto/WorkPlanResponse.java +++ b/backend/planning-service/src/main/java/com/workforce/planning/dto/WorkPlanResponse.java @@ -13,6 +13,7 @@ public record WorkPlanResponse( Long id, String title, Long shiftLeadId, + Long hourBudgetId, LocalDate startDate, LocalDate endDate, BigDecimal approvedHours, @@ -37,6 +38,7 @@ public static WorkPlanResponse from( workPlan.getId(), workPlan.getTitle(), workPlan.getShiftLeadId(), + workPlan.getHourBudgetId(), workPlan.getStartDate(), workPlan.getEndDate(), workPlan.getApprovedHours(), diff --git a/backend/planning-service/src/main/java/com/workforce/planning/model/HourBudget.java b/backend/planning-service/src/main/java/com/workforce/planning/model/HourBudget.java new file mode 100644 index 0000000..03bc27f --- /dev/null +++ b/backend/planning-service/src/main/java/com/workforce/planning/model/HourBudget.java @@ -0,0 +1,67 @@ +package com.workforce.planning.model; + +import jakarta.persistence.*; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * HR-Stundenfreigabe pro Schichtleiter und Monat. + * Schichtleiter dürfen diese Werte nicht selbst setzen, sondern nutzen sie beim Erstellen von Arbeitsplänen. + */ +@Data +@Entity +@Table( + name = "hour_budgets", + uniqueConstraints = @UniqueConstraint( + name = "uk_hour_budget_shiftlead_month", + columnNames = {"shift_lead_id", "budget_year", "budget_month"} + ) +) +public class HourBudget { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "shift_lead_id", nullable = false) + private Long shiftLeadId; + + @Column(name = "budget_year", nullable = false) + private Integer year; + + @Column(name = "budget_month", nullable = false) + private Integer month; + + @Column(name = "approved_hours", nullable = false, precision = 8, scale = 2) + private BigDecimal approvedHours = BigDecimal.ZERO; + + @Column(name = "created_by") + private Long createdBy; + + @Column(length = 500) + private String notes; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + void prePersist() { + LocalDateTime now = LocalDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + } + + @PreUpdate + void preUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/planning-service/src/main/java/com/workforce/planning/model/WorkPlan.java b/backend/planning-service/src/main/java/com/workforce/planning/model/WorkPlan.java index e688162..04dbe71 100644 --- a/backend/planning-service/src/main/java/com/workforce/planning/model/WorkPlan.java +++ b/backend/planning-service/src/main/java/com/workforce/planning/model/WorkPlan.java @@ -39,6 +39,10 @@ public class WorkPlan { @Column(name = "end_date", nullable = false) private LocalDate endDate; + /** FK zur HR-Stundenfreigabe, aus der das Kontingent übernommen wurde. */ + @Column(name = "hour_budget_id") + private Long hourBudgetId; + /** Von HR freigegebene Stunden für diesen Arbeitsplan/Monat. */ @Column(name = "approved_hours", nullable = false, precision = 8, scale = 2) private BigDecimal approvedHours = BigDecimal.ZERO; diff --git a/backend/planning-service/src/main/java/com/workforce/planning/repository/HourBudgetRepository.java b/backend/planning-service/src/main/java/com/workforce/planning/repository/HourBudgetRepository.java new file mode 100644 index 0000000..466c08d --- /dev/null +++ b/backend/planning-service/src/main/java/com/workforce/planning/repository/HourBudgetRepository.java @@ -0,0 +1,17 @@ +package com.workforce.planning.repository; + +import com.workforce.planning.model.HourBudget; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +/** Spring Data JPA Repository für HR-Stundenfreigaben. */ +public interface HourBudgetRepository extends JpaRepository { + + Optional findByShiftLeadIdAndYearAndMonth(Long shiftLeadId, Integer year, Integer month); + + List findByShiftLeadIdOrderByYearDescMonthDesc(Long shiftLeadId); + + List findAllByOrderByYearDescMonthDescShiftLeadIdAsc(); +} diff --git a/backend/planning-service/src/main/java/com/workforce/planning/service/PlanningService.java b/backend/planning-service/src/main/java/com/workforce/planning/service/PlanningService.java index 9e3f8c8..bc1a20b 100644 --- a/backend/planning-service/src/main/java/com/workforce/planning/service/PlanningService.java +++ b/backend/planning-service/src/main/java/com/workforce/planning/service/PlanningService.java @@ -1,14 +1,18 @@ package com.workforce.planning.service; +import com.workforce.planning.dto.CreateHourBudgetRequest; import com.workforce.planning.dto.CreateShiftRequest; import com.workforce.planning.dto.CreateWorkPlanRequest; +import com.workforce.planning.dto.HourBudgetResponse; import com.workforce.planning.dto.ShiftResponse; import com.workforce.planning.dto.UpdateWorkPlanRequest; import com.workforce.planning.dto.WorkPlanResponse; import com.workforce.planning.exception.ResourceNotFoundException; +import com.workforce.planning.model.HourBudget; import com.workforce.planning.model.Shift; import com.workforce.planning.model.WorkPlan; import com.workforce.planning.model.WorkPlanStatus; +import com.workforce.planning.repository.HourBudgetRepository; import com.workforce.planning.repository.ShiftRepository; import com.workforce.planning.repository.WorkPlanRepository; import lombok.RequiredArgsConstructor; @@ -24,11 +28,11 @@ import java.util.List; /** - * Service-Klasse für Arbeitspläne und Schichten. + * Service-Klasse für Arbeitspläne, Schichten und HR-Stundenfreigaben. * - *

Implementiert die besprochenen Kernfunktionen: - * Arbeitsplan erstellen, Schichten hinzufügen, geplante Stunden berechnen, - * Warnungen bei Über-/Unterplanung und Mitarbeiter-Kalender bereitstellen.

+ *

HR erstellt zuerst ein Monatskontingent. Schichtleiter erstellen danach Arbeitspläne, + * die automatisch dieses freigegebene Kontingent übernehmen. Dadurch kann der Schichtleiter + * nicht mehr selbst bestimmen, wie viele Stunden freigegeben sind.

*/ @Service @RequiredArgsConstructor @@ -38,18 +42,50 @@ public class PlanningService { private final WorkPlanRepository workPlanRepository; private final ShiftRepository shiftRepository; + private final HourBudgetRepository hourBudgetRepository; - /** Erstellt einen neuen Arbeitsplan mit HR-Stundenkontingent. */ + /** Erstellt oder aktualisiert eine HR-Stundenfreigabe für einen Schichtleiter und Monat. */ + @Transactional + public HourBudgetResponse saveHourBudget(CreateHourBudgetRequest request) { + validateHourBudgetRequest(request); + + HourBudget budget = hourBudgetRepository + .findByShiftLeadIdAndYearAndMonth(request.shiftLeadId(), request.year(), request.month()) + .orElseGet(HourBudget::new); + + budget.setShiftLeadId(request.shiftLeadId()); + budget.setYear(request.year()); + budget.setMonth(request.month()); + budget.setApprovedHours(normalizeHours(request.approvedHours())); + budget.setCreatedBy(request.createdBy()); + budget.setNotes(normalizeText(request.notes())); + + return HourBudgetResponse.from(hourBudgetRepository.save(budget)); + } + + /** Gibt HR-Stundenfreigaben zurück; optional gefiltert nach Schichtleiter. */ + @Transactional(readOnly = true) + public List getHourBudgets(Long shiftLeadId) { + List budgets = shiftLeadId != null + ? hourBudgetRepository.findByShiftLeadIdOrderByYearDescMonthDesc(shiftLeadId) + : hourBudgetRepository.findAllByOrderByYearDescMonthDescShiftLeadIdAsc(); + + return budgets.stream().map(HourBudgetResponse::from).toList(); + } + + /** Erstellt einen neuen Arbeitsplan und übernimmt das HR-Stundenkontingent automatisch. */ @Transactional public WorkPlanResponse createWorkPlan(CreateWorkPlanRequest request) { validateCreateWorkPlanRequest(request); + HourBudget budget = findMatchingBudget(request.shiftLeadId(), request.startDate(), request.endDate()); WorkPlan workPlan = new WorkPlan(); workPlan.setTitle(request.title().trim()); workPlan.setShiftLeadId(request.shiftLeadId()); + workPlan.setHourBudgetId(budget.getId()); workPlan.setStartDate(request.startDate()); workPlan.setEndDate(request.endDate()); - workPlan.setApprovedHours(normalizeHours(request.approvedHours())); + workPlan.setApprovedHours(normalizeHours(budget.getApprovedHours())); workPlan.setStatus(WorkPlanStatus.DRAFT); WorkPlan saved = workPlanRepository.save(workPlan); @@ -73,17 +109,19 @@ public WorkPlanResponse getWorkPlan(Long id) { return toResponse(workPlan); } - /** Bearbeitet Metadaten eines Arbeitsplan-Entwurfs. */ + /** Bearbeitet Metadaten eines Arbeitsplan-Entwurfs und übernimmt erneut das passende HR-Kontingent. */ @Transactional public WorkPlanResponse updateWorkPlan(Long workPlanId, UpdateWorkPlanRequest request) { WorkPlan workPlan = findWorkPlan(workPlanId); ensureDraft(workPlan); validateUpdateWorkPlanRequest(request); + HourBudget budget = findMatchingBudget(workPlan.getShiftLeadId(), request.startDate(), request.endDate()); workPlan.setTitle(request.title().trim()); workPlan.setStartDate(request.startDate()); workPlan.setEndDate(request.endDate()); - workPlan.setApprovedHours(normalizeHours(request.approvedHours())); + workPlan.setHourBudgetId(budget.getId()); + workPlan.setApprovedHours(normalizeHours(budget.getApprovedHours())); validateExistingShiftsStillFit(workPlan); return toResponse(workPlanRepository.save(workPlan)); @@ -134,25 +172,41 @@ public List getEmployeeCalendar(Long employeeId, LocalDate from, throw new IllegalArgumentException("employeeId ist erforderlich"); } - LocalDate start = from != null ? from : YearMonth.now().atDay(1); - LocalDate end = to != null ? to : YearMonth.now().atEndOfMonth(); + YearMonth currentMonth = YearMonth.now(); + LocalDate effectiveFrom = from != null ? from : currentMonth.atDay(1); + LocalDate effectiveTo = to != null ? to : currentMonth.atEndOfMonth(); - if (end.isBefore(start)) { + if (effectiveTo.isBefore(effectiveFrom)) { throw new IllegalArgumentException("to darf nicht vor from liegen"); } - return shiftRepository.findCalendarShifts(employeeId, start, end, WorkPlanStatus.PUBLISHED) + return shiftRepository + .findCalendarShifts(employeeId, effectiveFrom, effectiveTo, WorkPlanStatus.PUBLISHED) .stream() .map(shift -> ShiftResponse.from(shift, calculateShiftHours(shift))) .toList(); } private WorkPlan findWorkPlan(Long id) { - if (id == null) { - throw new IllegalArgumentException("workPlanId ist erforderlich"); - } return workPlanRepository.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Arbeitsplan " + id + " wurde nicht gefunden")); + .orElseThrow(() -> new ResourceNotFoundException("Arbeitsplan mit ID " + id + " nicht gefunden")); + } + + private HourBudget findMatchingBudget(Long shiftLeadId, LocalDate startDate, LocalDate endDate) { + YearMonth period = validateMonthlyPeriod(startDate, endDate); + return hourBudgetRepository + .findByShiftLeadIdAndYearAndMonth(shiftLeadId, period.getYear(), period.getMonthValue()) + .orElseThrow(() -> new IllegalStateException( + "Kein HR-Stundenkontingent für Schichtleiter " + shiftLeadId + " und Monat " + period + " freigegeben")); + } + + private YearMonth validateMonthlyPeriod(LocalDate startDate, LocalDate endDate) { + YearMonth startMonth = YearMonth.from(startDate); + YearMonth endMonth = YearMonth.from(endDate); + if (!startMonth.equals(endMonth)) { + throw new IllegalArgumentException("Arbeitspläne müssen innerhalb eines freigegebenen Monats liegen"); + } + return startMonth; } private WorkPlanResponse toResponse(WorkPlan workPlan) { @@ -161,8 +215,8 @@ private WorkPlanResponse toResponse(WorkPlan workPlan) { .map(shift -> ShiftResponse.from(shift, calculateShiftHours(shift))) .toList(); - BigDecimal plannedHours = shifts.stream() - .map(this::calculateShiftHours) + BigDecimal plannedHours = shiftResponses.stream() + .map(ShiftResponse::plannedHours) .reduce(BigDecimal.ZERO, BigDecimal::add) .setScale(2, RoundingMode.HALF_UP); @@ -191,6 +245,24 @@ private BigDecimal calculateShiftHours(Shift shift) { .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP); } + private void validateHourBudgetRequest(CreateHourBudgetRequest request) { + if (request == null) { + throw new IllegalArgumentException("Request darf nicht leer sein"); + } + if (request.shiftLeadId() == null) { + throw new IllegalArgumentException("shiftLeadId ist erforderlich"); + } + if (request.year() == null || request.year() < 2000) { + throw new IllegalArgumentException("year ist erforderlich und muss gültig sein"); + } + if (request.month() == null || request.month() < 1 || request.month() > 12) { + throw new IllegalArgumentException("month muss zwischen 1 und 12 liegen"); + } + if (request.approvedHours() == null || request.approvedHours().compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("approvedHours ist erforderlich und darf nicht negativ sein"); + } + } + private void validateCreateWorkPlanRequest(CreateWorkPlanRequest request) { if (request == null) { throw new IllegalArgumentException("Request darf nicht leer sein"); @@ -198,20 +270,17 @@ private void validateCreateWorkPlanRequest(CreateWorkPlanRequest request) { if (request.shiftLeadId() == null) { throw new IllegalArgumentException("shiftLeadId ist erforderlich"); } - validateWorkPlanFields(request.title(), request.startDate(), request.endDate(), request.approvedHours()); + validateWorkPlanFields(request.title(), request.startDate(), request.endDate()); } private void validateUpdateWorkPlanRequest(UpdateWorkPlanRequest request) { if (request == null) { throw new IllegalArgumentException("Request darf nicht leer sein"); } - validateWorkPlanFields(request.title(), request.startDate(), request.endDate(), request.approvedHours()); + validateWorkPlanFields(request.title(), request.startDate(), request.endDate()); } - private void validateWorkPlanFields(String title, - LocalDate startDate, - LocalDate endDate, - BigDecimal approvedHours) { + private void validateWorkPlanFields(String title, LocalDate startDate, LocalDate endDate) { if (title == null || title.isBlank()) { throw new IllegalArgumentException("Titel ist erforderlich"); } @@ -221,9 +290,6 @@ private void validateWorkPlanFields(String title, if (endDate.isBefore(startDate)) { throw new IllegalArgumentException("endDate darf nicht vor startDate liegen"); } - if (approvedHours != null && approvedHours.compareTo(BigDecimal.ZERO) < 0) { - throw new IllegalArgumentException("approvedHours darf nicht negativ sein"); - } } private void validateCreateShiftRequest(WorkPlan workPlan, CreateShiftRequest request) { diff --git a/backend/time-service/src/main/java/com/workforce/time/controller/TimeController.java b/backend/time-service/src/main/java/com/workforce/time/controller/TimeController.java index b02c80c..5c73dc8 100644 --- a/backend/time-service/src/main/java/com/workforce/time/controller/TimeController.java +++ b/backend/time-service/src/main/java/com/workforce/time/controller/TimeController.java @@ -100,6 +100,22 @@ public ResponseEntity> getMonthlyEntries( return ResponseEntity.ok(timeService.getMonthlyEntries(employeeId, m, y)); } + + /** Gibt erkannte Pausenverstösse in einem Zeitraum zurück. */ + @GetMapping("/break-violations") + @PreAuthorize("hasAnyRole('HR', 'ADMIN', 'SHIFT_LEAD')") + public ResponseEntity> getBreakViolations( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @RequestParam(required = false) Long employeeId) { + + LocalDate today = LocalDate.now(BUSINESS_ZONE); + LocalDate start = from != null ? from : today.withDayOfMonth(1); + LocalDate end = to != null ? to : today; + + return ResponseEntity.ok(timeService.getBreakViolations(start, end, employeeId)); + } + @GetMapping("/current/{employeeId}") @PreAuthorize("hasAnyRole('EMPLOYEE', 'SHIFT_LEAD', 'HR', 'ADMIN')") public ResponseEntity getCurrentEntry(@PathVariable Long employeeId) { diff --git a/backend/time-service/src/main/java/com/workforce/time/dto/BreakViolationResponse.java b/backend/time-service/src/main/java/com/workforce/time/dto/BreakViolationResponse.java new file mode 100644 index 0000000..e8aed8e --- /dev/null +++ b/backend/time-service/src/main/java/com/workforce/time/dto/BreakViolationResponse.java @@ -0,0 +1,36 @@ +package com.workforce.time.dto; + +import com.workforce.time.model.TimeEntry; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** Rückgabe-DTO für erkannte Pausenverstösse. */ +public record BreakViolationResponse( + Long id, + Long employeeId, + LocalDate entryDate, + LocalDateTime checkIn, + LocalDateTime checkOut, + Integer breakMinutes, + Integer requiredBreakMinutes, + BigDecimal totalHours, + String message +) { + public static BreakViolationResponse from(TimeEntry entry, int requiredBreakMinutes) { + int actualBreak = entry.getBreakMinutes() == null ? 0 : entry.getBreakMinutes(); + return new BreakViolationResponse( + entry.getId(), + entry.getEmployeeId(), + entry.getEntryDate(), + entry.getCheckIn(), + entry.getCheckOut(), + actualBreak, + requiredBreakMinutes, + entry.getTotalHours(), + "Erfasste Pause " + actualBreak + " Min. liegt unter der erforderlichen Pause von " + + requiredBreakMinutes + " Min." + ); + } +} diff --git a/backend/time-service/src/main/java/com/workforce/time/repository/TimeEntryRepository.java b/backend/time-service/src/main/java/com/workforce/time/repository/TimeEntryRepository.java index 4e1cd65..758e44b 100644 --- a/backend/time-service/src/main/java/com/workforce/time/repository/TimeEntryRepository.java +++ b/backend/time-service/src/main/java/com/workforce/time/repository/TimeEntryRepository.java @@ -54,6 +54,14 @@ Optional findByEmployeeIdAndEntryDateAndCheckOutIsNull( Optional findFirstByEmployeeIdAndEntryDateOrderByCheckInDesc( Long employeeId, LocalDate entryDate); + /** Gibt abgeschlossene Zeiteinträge in einem Datumsbereich zurück. */ + List findByEntryDateBetweenAndCheckOutIsNotNullOrderByEntryDateDesc( + LocalDate from, LocalDate to); + + /** Gibt abgeschlossene Zeiteinträge eines Mitarbeiters in einem Datumsbereich zurück. */ + List findByEmployeeIdAndEntryDateBetweenAndCheckOutIsNotNullOrderByEntryDateDesc( + Long employeeId, LocalDate from, LocalDate to); + /** * Berechnet die Gesamtstunden aller Mitarbeiter in einem Datumsbereich. * Gibt pro Mitarbeiter eine Zeile mit der Summer der Stunden zurück. diff --git a/backend/time-service/src/main/java/com/workforce/time/service/TimeService.java b/backend/time-service/src/main/java/com/workforce/time/service/TimeService.java index f7301c7..c7186f0 100644 --- a/backend/time-service/src/main/java/com/workforce/time/service/TimeService.java +++ b/backend/time-service/src/main/java/com/workforce/time/service/TimeService.java @@ -112,6 +112,47 @@ public Optional getLatestEntry(Long employeeId) { .map(TimeEntryResponse::from); } + + /** + * Gibt erkannte Pausenverstösse in einem Datumsbereich zurück. + * Regel: > 6h Brutto-Arbeitszeit = mind. 30 Min Pause, > 9h = mind. 45 Min Pause. + */ + @Transactional(readOnly = true) + public List getBreakViolations(LocalDate from, LocalDate to, Long employeeId) { + if (to.isBefore(from)) { + throw new IllegalArgumentException("to darf nicht vor from liegen"); + } + + List entries = employeeId != null + ? timeEntryRepository.findByEmployeeIdAndEntryDateBetweenAndCheckOutIsNotNullOrderByEntryDateDesc(employeeId, from, to) + : timeEntryRepository.findByEntryDateBetweenAndCheckOutIsNotNullOrderByEntryDateDesc(from, to); + + return entries.stream() + .filter(this::hasBreakViolation) + .map(entry -> BreakViolationResponse.from(entry, requiredBreakMinutes(entry))) + .toList(); + } + + private boolean hasBreakViolation(TimeEntry entry) { + int required = requiredBreakMinutes(entry); + int actual = entry.getBreakMinutes() == null ? 0 : entry.getBreakMinutes(); + return required > 0 && actual < required; + } + + private int requiredBreakMinutes(TimeEntry entry) { + if (entry.getCheckIn() == null || entry.getCheckOut() == null) { + return 0; + } + long grossMinutes = ChronoUnit.MINUTES.between(entry.getCheckIn(), entry.getCheckOut()); + if (grossMinutes > 9 * 60) { + return 45; + } + if (grossMinutes > 6 * 60) { + return 30; + } + return 0; + } + /** * Erfasst einen Check-in für einen Mitarbeiter. * Es darf nur ein offener Check-in pro Tag existieren. diff --git a/database/mysql/init.sql b/database/mysql/init.sql index 962bc68..30a061a 100644 --- a/database/mysql/init.sql +++ b/database/mysql/init.sql @@ -81,17 +81,34 @@ CREATE TABLE order_employees ( -- ── Planning / Shifts ──────────────────────────────────────────────────────── +CREATE TABLE hour_budgets ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + shift_lead_id BIGINT NOT NULL, + budget_year INT NOT NULL, + budget_month INT NOT NULL, + approved_hours DECIMAL(8,2) NOT NULL DEFAULT 0.00, + created_by BIGINT, + notes VARCHAR(500), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_hour_budget_shiftlead_month (shift_lead_id, budget_year, budget_month), + FOREIGN KEY (shift_lead_id) REFERENCES users(id), + FOREIGN KEY (created_by) REFERENCES users(id) +); + CREATE TABLE work_plans ( id BIGINT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, shift_lead_id BIGINT NOT NULL, + hour_budget_id BIGINT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, approved_hours DECIMAL(8,2) NOT NULL DEFAULT 0.00, status ENUM('DRAFT','PUBLISHED') NOT NULL DEFAULT 'DRAFT', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, published_at TIMESTAMP NULL, - FOREIGN KEY (shift_lead_id) REFERENCES users(id) + FOREIGN KEY (shift_lead_id) REFERENCES users(id), + FOREIGN KEY (hour_budget_id) REFERENCES hour_budgets(id) ); CREATE TABLE shifts ( @@ -162,6 +179,26 @@ CREATE TABLE invoice_positions ( FOREIGN KEY (invoice_id) REFERENCES invoices(id) ); +CREATE TABLE payroll_statements ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + employee_id BIGINT NOT NULL, + payroll_year INT NOT NULL, + payroll_month INT NOT NULL, + hourly_rate DECIMAL(8,2) NOT NULL, + total_hours DECIMAL(8,2) NOT NULL DEFAULT 0.00, + gross_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, + bonus_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, + deduction_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, + net_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, + status ENUM('DRAFT','APPROVED','PAID') NOT NULL DEFAULT 'DRAFT', + created_by BIGINT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_payroll_employee_month (employee_id, payroll_year, payroll_month), + FOREIGN KEY (employee_id) REFERENCES users(id), + FOREIGN KEY (created_by) REFERENCES users(id) +); + -- ── Seed: Roles ────────────────────────────────────────────────────────────── INSERT INTO roles (name) VALUES ('ADMIN'), ('HR'), ('SHIFT_LEAD'), ('EMPLOYEE'); diff --git a/frontend/hr-web/src/App.jsx b/frontend/hr-web/src/App.jsx index 0a11288..fab8825 100644 --- a/frontend/hr-web/src/App.jsx +++ b/frontend/hr-web/src/App.jsx @@ -4,6 +4,8 @@ import DashboardPage from './pages/DashboardPage'; import UsersPage from './pages/UsersPage'; import TimePage from './pages/TimePage'; import InvoicesPage from './pages/InvoicesPage'; +import HourBudgetsPage from './pages/HourBudgetsPage'; +import PayrollPage from './pages/PayrollPage'; import AbsencesPage from './pages/AbsencesPage'; function ProtectedRoute({ children }) { @@ -22,6 +24,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/frontend/hr-web/src/pages/DashboardPage.jsx b/frontend/hr-web/src/pages/DashboardPage.jsx index eeb595b..d03b3bc 100644 --- a/frontend/hr-web/src/pages/DashboardPage.jsx +++ b/frontend/hr-web/src/pages/DashboardPage.jsx @@ -2,8 +2,10 @@ import { useNavigate, Link } from 'react-router-dom'; const CARDS = [ { to: '/users', mark: 'HR', title: 'Benutzerverwaltung', desc: 'Mitarbeiter und Rollen verwalten' }, - { to: '/time', mark: 'Zeit', title: 'Stundenübersicht', desc: 'Check-in/out und Monatsstunden prüfen' }, - { to: '/invoices', mark: 'CHF', title: 'Rechnungen', desc: 'Erstellen, versenden und abrechnen' }, + { to: '/time', mark: 'Zeit', title: 'Stundenübersicht', desc: 'Check-in/out, Monatsstunden und Pausenverstösse prüfen' }, + { to: '/hour-budgets', mark: 'Std', title: 'Stundenfreigabe', desc: 'Monatliche Stundenkontingente für Schichtleiter festlegen' }, + { to: '/invoices', mark: 'CHF', title: 'Rechnungen', desc: 'Rechnungen erstellen, versenden und abrechnen' }, + { to: '/payroll', mark: 'Lohn', title: 'Lohnauszüge', desc: 'Monatslohn aus Stunden, Rate, Zuschlägen und Abzügen berechnen' }, { to: '/absences', mark: 'Abw', title: 'Absenzen & Ferien', desc: 'Anträge prüfen und genehmigen' }, ]; diff --git a/frontend/hr-web/src/pages/HourBudgetsPage.jsx b/frontend/hr-web/src/pages/HourBudgetsPage.jsx new file mode 100644 index 0000000..2699d7c --- /dev/null +++ b/frontend/hr-web/src/pages/HourBudgetsPage.jsx @@ -0,0 +1,164 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import api from '../services/api'; + +const now = new Date(); + +const emptyForm = { + shiftLeadId: '', + year: now.getFullYear(), + month: now.getMonth() + 1, + approvedHours: '1000', + notes: '', +}; + +export default function HourBudgetsPage() { + const [budgets, setBudgets] = useState([]); + const [shiftLeads, setShiftLeads] = useState([]); + const [form, setForm] = useState(emptyForm); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const shiftLeadNameById = useMemo(() => { + const map = new Map(); + shiftLeads.forEach(user => map.set(Number(user.id), `${user.firstName} ${user.lastName}`)); + return map; + }, [shiftLeads]); + + const loadData = async () => { + setLoading(true); + setError(''); + try { + const [budgetsRes, leadsRes] = await Promise.all([ + api.get('/api/planning/hour-budgets'), + api.get('/api/users', { params: { role: 'SHIFT_LEAD' } }), + ]); + setBudgets(budgetsRes.data || []); + const activeLeads = (leadsRes.data || []).filter(user => user.active); + setShiftLeads(activeLeads); + setForm(current => current.shiftLeadId || activeLeads.length === 0 + ? current + : { ...current, shiftLeadId: String(activeLeads[0].id) }); + } catch (err) { + setError(err.response?.data?.message || 'Stundenfreigaben konnten nicht geladen werden.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadData(); }, []); + + const submit = async (event) => { + event.preventDefault(); + setSaving(true); + setError(''); + setSuccess(''); + try { + const payload = { + shiftLeadId: Number(form.shiftLeadId), + year: Number(form.year), + month: Number(form.month), + approvedHours: Number(form.approvedHours), + createdBy: Number(localStorage.getItem('userId') || 0) || null, + notes: form.notes.trim() || null, + }; + await api.post('/api/planning/hour-budgets', payload); + setSuccess('Stundenfreigabe wurde gespeichert.'); + setForm(previous => ({ ...previous, notes: '' })); + await loadData(); + } catch (err) { + setError(err.response?.data?.message || 'Stundenfreigabe konnte nicht gespeichert werden.'); + } finally { + setSaving(false); + } + }; + + return ( +
+ Zurück zum Dashboard +

HR-Stundenfreigabe

+

+ Lege pro Schichtleiter und Monat fest, wie viele Arbeitsstunden geplant werden dürfen. +

+ + {error &&

{error}

} + {success &&

{success}

} + +
+

Kontingent speichern

+
+
+ + + + +
+