Merge pull request #26 from Kenearos/copilot/fix-calculations-and-report

Fix deduction value inconsistency (2.0→1.0) and improve bonus report format
This commit is contained in:
Kenearos 2025-12-12 20:07:10 +01:00 committed by GitHub
commit 96eab8bfad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 67 additions and 64 deletions

View file

@ -448,7 +448,7 @@
<h4>Berechnungsregeln (Variante 2 - Streng)</h4> <h4>Berechnungsregeln (Variante 2 - Streng)</h4>
<p> <p>
<strong>Schwelle:</strong> Gesamter Bonus wird nur gezahlt, wenn WE-Einheiten &ge; 2,0.<br> <strong>Schwelle:</strong> Gesamter Bonus wird nur gezahlt, wenn WE-Einheiten &ge; 2,0.<br>
<strong>Bei Erreichen:</strong> WT = 250 EUR/Einheit, WE = 450 EUR/Einheit (abzgl. 1,0 Einheit Abzug).<br> <strong>Bei Erreichen:</strong> WT = 250 EUR/Einheit, WE = 450 EUR/Einheit (abzgl. 2,0 Einheiten Abzug).<br>
<strong>Unter Schwelle:</strong> Keine Auszahlung (weder WT noch WE).<br> <strong>Unter Schwelle:</strong> Keine Auszahlung (weder WT noch WE).<br>
<strong>WE-Tage:</strong> Fr, Sa, So, Feiertage und Vortage von Feiertagen. <strong>WE-Tage:</strong> Fr, Sa, So, Feiertage und Vortage von Feiertagen.
</p> </p>
@ -576,7 +576,7 @@ const CONFIG = {
RATE_WT: 250, RATE_WT: 250,
RATE_WE: 450, RATE_WE: 450,
THRESHOLD: 2.0, THRESHOLD: 2.0,
DEDUCTION: 1.0, DEDUCTION: 2.0,
TOLERANCE: 0.0001 TOLERANCE: 0.0001
}; };
@ -1071,7 +1071,7 @@ class DienstplanApp {
csv += 'WT;Werktag (Montag-Donnerstag ohne Feiertag/Vortag)\n'; csv += 'WT;Werktag (Montag-Donnerstag ohne Feiertag/Vortag)\n';
csv += 'WE-Tag;"Freitag, Samstag, Sonntag, Feiertag oder Tag vor Feiertag"\n'; csv += 'WE-Tag;"Freitag, Samstag, Sonntag, Feiertag oder Tag vor Feiertag"\n';
csv += 'Schwelle;"Mindestens 2,0 WE-Einheiten für Bonuszahlung erforderlich"\n'; csv += 'Schwelle;"Mindestens 2,0 WE-Einheiten für Bonuszahlung erforderlich"\n';
csv += 'Sätze;"WT = 250 EUR/Einheit, WE = 450 EUR/Einheit (abzgl. 1,0 Abzug)"\n'; csv += 'Sätze;"WT = 250 EUR/Einheit, WE = 450 EUR/Einheit (abzgl. 2,0 Abzug)"\n';
// Download CSV file // Download CSV file
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
@ -1296,20 +1296,23 @@ class DienstplanApp {
totalBonus += bonus; totalBonus += bonus;
// Generate note for this employee // Generate note for this employee - cleaner, more professional format
const safeName = this.sanitizeName(name); const safeName = this.sanitizeName(name);
let note = `<b>${safeName}</b>: `; let note = '';
if (!thresholdReached) { if (!thresholdReached) {
note += `Erreicht das Bonussystem nicht (nur ${we_total.toFixed(1)} WE-Einheiten, mind. 2,0 erforderlich).`; note = `<b>${safeName}</b> erreicht die Mindestschwelle nicht (${we_total.toFixed(1)} von ${CONFIG.THRESHOLD.toFixed(1)} WE-Einheiten) und erhält daher keine Bonuszahlung.`;
} else { } else {
const details = []; const paid_we = we_total - CONFIG.DEDUCTION;
if (data.wt > 0) details.push(`${data.wt.toFixed(1)} WT × 250€`); let breakdown = [];
if (data.we_fr > 0 || data.we_other > 0) { if (data.wt > 0) breakdown.push(`${data.wt.toFixed(1)} WT-Einheiten à ${CONFIG.RATE_WT} €`);
const paid_we = we_total - 1.0; if (paid_we > 0) breakdown.push(`${paid_we.toFixed(1)} WE-Einheiten à ${CONFIG.RATE_WE} €`);
details.push(`${paid_we.toFixed(1)} WE × 450€ (abzgl. 1,0 Abzug von ${deductedFrom})`);
note = `<b>${safeName}</b> erhält eine Bonuszahlung von <span style="color: #28a745; font-weight: bold;">${this.formatCurrency(bonus)}</span>`;
if (breakdown.length > 0) {
note += ` (${breakdown.join(' + ')})`;
} }
note += `Erhält ${this.formatCurrency(bonus)}. ${details.join(', ')}.`; note += '.';
} }
employeeNotes.push(note); employeeNotes.push(note);
@ -1370,7 +1373,7 @@ class DienstplanApp {
<li><strong>Vergütung bei Erreichen der Schwelle:</strong> <li><strong>Vergütung bei Erreichen der Schwelle:</strong>
<ul> <ul>
<li>Werktage (WT): 250 € pro Einheit</li> <li>Werktage (WT): 250 € pro Einheit</li>
<li>WE-Tage: 450 € pro Einheit (abzüglich 1,0 Einheit Abzug, Freitag zuerst)</li> <li>WE-Tage: 450 € pro Einheit (abzüglich 2,0 Einheiten Abzug, Freitag zuerst)</li>
</ul> </ul>
</li> </li>
<li><strong>Unter Schwelle:</strong> Keine Bonuszahlung (weder WT noch WE)</li> <li><strong>Unter Schwelle:</strong> Keine Bonuszahlung (weder WT noch WE)</li>

View file

@ -103,7 +103,7 @@ Die Datei landet in `output/Dienstplan_YYYY_MM_NRW.xlsx`.
- **WE-Tag**: Fr/Sa/So + Feiertag + Vortag Feiertag - **WE-Tag**: Fr/Sa/So + Feiertag + Vortag Feiertag
- **WT-Tag**: Alle anderen Tage (250 € pro Einheit) - **WT-Tag**: Alle anderen Tage (250 € pro Einheit)
- **WE-Vergütung**: Nur wenn Monatssumme ≥ 2,0 WE-Einheiten → 450 €/Einheit, dann Abzug 1,0 (zuerst von Freitag) - **WE-Vergütung**: Nur wenn Monatssumme ≥ 2,0 WE-Einheiten → 450 €/Einheit, dann Abzug 2,0 (zuerst von Freitag)
- **Unter Schwelle**: WE-Dienste = 0 € (nicht als WT vergütet) - **Unter Schwelle**: WE-Dienste = 0 € (nicht als WT vergütet)
Details siehe `SPECIFICATION.md`. Details siehe `SPECIFICATION.md`.

View file

@ -4,7 +4,7 @@ Stand: 14.11.2025 (Deutschland)
## Ziel ## Ziel
Diese README beschreibt vollständig, wie eine Excel-Arbeitsmappe aufgebaut wird, die Monatsdienste erfasst und automatisch die Vergütung ermittelt inkl. Erkennung von Wochenend-/Feiertagsdiensten (inkl. Vortag), Schwellenlogik und Abzug 1,0 WE-Einheit. Variante 2 (streng) ist aktiv: WE-Dienste werden nur vergütet, wenn im Monat mindestens 2,0 WE-Einheiten erreicht werden; sonst 0 €. Wochentage (kein WE) werden stets vergütet. Diese README beschreibt vollständig, wie eine Excel-Arbeitsmappe aufgebaut wird, die Monatsdienste erfasst und automatisch die Vergütung ermittelt inkl. Erkennung von Wochenend-/Feiertagsdiensten (inkl. Vortag), Schwellenlogik und Abzug 2,0 WE-Einheiten nach Erreichen der Schwelle. Variante 2 (streng) ist aktiv: WE-Dienste werden nur vergütet, wenn im Monat mindestens 2,0 WE-Einheiten erreicht werden; sonst 0 €. Wochentage (kein WE) werden ebenfalls nur bei Erreichen der WE-Schwelle vergütet.
Hinweise: Hinweise:
- Region: Deutschland, Bundesland wählbar (steuert Feiertage). - Region: Deutschland, Bundesland wählbar (steuert Feiertage).
@ -279,17 +279,17 @@ Beispiel-Formel (als hilfsweise Matrix in Checks):
A hat 1,75 WE und 1,0 WT → Auszahlung_WE = 0 €; Auszahlung_WT = 0 €; Auszahlung_Gesamt = 0 €. A hat 1,75 WE und 1,0 WT → Auszahlung_WE = 0 €; Auszahlung_WT = 0 €; Auszahlung_Gesamt = 0 €.
2) **Genau Schwelle**: 2) **Genau Schwelle**:
A hat 2,0 WE (Fr 1,0 + Sa 1,0) → Abzug 1,0 (zuerst Fr) → WE_bezahlt = 1,0 → 450 €. A hat 2,0 WE (Fr 1,0 + Sa 1,0) → Abzug 2,0 (zuerst Fr) → WE_bezahlt = 0,0 → 0 €.
3) **Über Schwelle ohne Freitag**: 3) **Über Schwelle ohne Freitag**:
A hat 2,0 WE (nur Sa+So) → Abzug 1,0 aus „Andere" → WE_bezahlt = 1,0 → 450 €. A hat 2,0 WE (nur Sa+So) → Abzug 2,0 aus „Andere" → WE_bezahlt = 0,0 → 0 €.
4) **Starke Überdeckung**: 4) **Starke Überdeckung**:
A hat 3,5 WE → Abzug 1,0 → WE_bezahlt = 2,5 → 2,5×450 €. A hat 3,5 WE → Abzug 2,0 → WE_bezahlt = 1,5 → 1,5×450 € = 675 €.
5) **Splits rund um 2,0**: 5) **Splits rund um 2,0**:
A hat Fr 0,4 + Sa 0,6 + So 1,0 → Summe 2,0 → Abzug 1,0 A hat Fr 0,4 + Sa 0,6 + So 1,0 → Summe 2,0 → Abzug 2,0
(0,4 von Fr, 0,6 von Andere) → WE_bezahlt = 1,0 → 450 €. (0,4 von Fr, 1,6 von Andere) → WE_bezahlt = 0,0 → 0 €.
6) **Unter Schwelle, nur WE-Tage**: 6) **Unter Schwelle, nur WE-Tage**:
A hat 1,0 WE, 0 WT → Auszahlung_WE = 0 €; Auszahlung_Gesamt = 0 €. A hat 1,0 WE, 0 WT → Auszahlung_WE = 0 €; Auszahlung_Gesamt = 0 €.
@ -300,9 +300,9 @@ Beispiel-Formel (als hilfsweise Matrix in Checks):
## Edge-Cases und Präzisierungen ## Edge-Cases und Präzisierungen
- Abzug nur einmal pro Person/Monat (fix 1,0), und nur wenn Schwelle erreicht. - Abzug nur einmal pro Person/Monat (fix 2,0), und nur wenn Schwelle erreicht.
- Der Vortag eines Feiertags ist WE-Tag unabhängig davon, welcher Wochentag er ist. - Der Vortag eines Feiertags ist WE-Tag unabhängig davon, welcher Wochentag er ist.
- Wenn WE_Freitag < 1,0, wird der restliche Abzug (bis 1,0) von WE_Andere genommen. - Wenn WE_Freitag < 2,0, wird der restliche Abzug (bis 2,0) von WE_Andere genommen.
- Monatswechsel: Daten genau per >=Monat_Auswahl und <=EOMONAT(Monat_Auswahl;0) filtern. - Monatswechsel: Daten genau per >=Monat_Auswahl und <=EOMONAT(Monat_Auswahl;0) filtern.
- Rundungstoleranz 1e-4 bei Schwelle und Datumssummen (Splits wie 0,33/0,67). - Rundungstoleranz 1e-4 bei Schwelle und Datumssummen (Splits wie 0,33/0,67).
- Tabellen-Namen („tblPlan", „tblFeiertage", „tblAuswertung") konsequent verwenden. - Tabellen-Namen („tblPlan", „tblFeiertage", „tblAuswertung") konsequent verwenden.
@ -324,11 +324,11 @@ Lieferumfang (empfohlen):
- 18.11.2025: Korrektur Variante 2: **Gesamter Bonus (WT + WE) wird nur gezahlt, wenn WE_Summe ≥ 2,0**. - 18.11.2025: Korrektur Variante 2: **Gesamter Bonus (WT + WE) wird nur gezahlt, wenn WE_Summe ≥ 2,0**.
Unter Schwelle: Auszahlung_Gesamt = 0 € (weder WT noch WE). Unter Schwelle: Auszahlung_Gesamt = 0 € (weder WT noch WE).
- 14.11.2025: Umstellung auf Variante 2 (streng). WE-Vergütung nur bei WE_Summe ≥ 2,0, - 14.11.2025: Umstellung auf Variante 2 (streng). WE-Vergütung nur bei WE_Summe ≥ 2,0,
anschließend Abzug 1,0 (Freitag zuerst). Unterhalb der Schwelle: WE-Auszahlung = 0 €. anschließend Abzug 2,0 (Freitag zuerst). Unterhalb der Schwelle: WE-Auszahlung = 0 €.
- 13.11.2025: Vorversion (Variante 1) mit WE-Auszahlung ab erstem WE-Dienst und Abzug nach Schwelle (ersetzt). - 13.11.2025: Vorversion (Variante 1) mit WE-Auszahlung ab erstem WE-Dienst und Abzug nach Schwelle (ersetzt).
## Kurztext (für Blatt „Regeln" als Readme-Hinweis) ## Kurztext (für Blatt „Regeln" als Readme-Hinweis)
„WE-Tag = Fr/Sa/So/Feiertag/Vortag (BL-abhängig). Variante 2 (streng): Gesamter Bonus (WT + WE) wird nur gezahlt, wenn im Monat ≥ 2,0 WE-Einheiten erreicht werden. Bei Erreichen der Schwelle: WT 250 €/Einheit, WE 450 €/Einheit mit Abzug 1,0 (Freitag zuerst). Unter Schwelle: 0 € Auszahlung. Splits anteilig. Monat und Bundesland oben wählen." „WE-Tag = Fr/Sa/So/Feiertag/Vortag (BL-abhängig). Variante 2 (streng): Gesamter Bonus (WT + WE) wird nur gezahlt, wenn im Monat ≥ 2,0 WE-Einheiten erreicht werden. Bei Erreichen der Schwelle: WT 250 €/Einheit, WE 450 €/Einheit mit Abzug 2,0 (Freitag zuerst). Unter Schwelle: 0 € Auszahlung. Splits anteilig. Monat und Bundesland oben wählen."
— Ende der README — — Ende der README —

View file

@ -78,8 +78,8 @@ Implements NRW Variante 2 (streng) rules:
#### Unit Tests (PayrollCalculatorTest.kt) #### Unit Tests (PayrollCalculatorTest.kt)
Comprehensive test coverage including: Comprehensive test coverage including:
1. **Under threshold test**: 1.75 WE + 1.0 WT → WE payout 0€, WT payout 250€ 1. **Under threshold test**: 1.75 WE + 1.0 WT → WE payout 0€, WT payout 250€
2. **Exactly at threshold test**: 2.0 WE → WE payout 450€ (1.0 unit after deduction) 2. **Exactly at threshold test**: 2.0 WE → WE payout 0€ (0.0 units after 2.0 deduction)
3. **Over threshold test**: 3.5 WE → WE payout 1125€ (2.5 units after deduction) 3. **Over threshold test**: 3.5 WE → WE payout 675€ (1.5 units after 2.0 deduction)
4. **Friday deduction priority test**: Verifies deduction comes from Friday first 4. **Friday deduction priority test**: Verifies deduction comes from Friday first
5. **Multiple employees test**: Separate calculations per employee 5. **Multiple employees test**: Separate calculations per employee

View file

@ -12,11 +12,11 @@ import kotlin.math.min
* Business rules: * Business rules:
* - WE-Tag (Weekend/Holiday): Friday, Saturday, Sunday, public holiday, day before public holiday * - WE-Tag (Weekend/Holiday): Friday, Saturday, Sunday, public holiday, day before public holiday
* - WT-Tag (Weekday): All other days * - WT-Tag (Weekday): All other days
* - WT compensation: Always 250 per unit * - WT compensation: 250 per unit (only if threshold reached)
* - WE compensation: Only paid if monthly total >= 2.0 WE units (threshold) * - WE compensation: Only paid if monthly total >= 2.0 WE units (threshold)
* - If threshold reached: 450 per WE unit, then deduct exactly 2.0 WE units * - If threshold reached: 450 per WE unit, then deduct exactly 2.0 WE units
* - Deduction priority: Friday first, then other WE days * - Deduction priority: Friday first, then other WE days
* - Below threshold: 0 for WE shifts (NOT converted to WT) * - Below threshold: 0 for all shifts (neither WT nor WE)
*/ */
class PayrollCalculator { class PayrollCalculator {

View file

@ -52,7 +52,7 @@ class PayrollCalculatorTest {
/** /**
* Test Case 2: Exactly at threshold (2.0 WE) * Test Case 2: Exactly at threshold (2.0 WE)
* Expected: WE payout = 0 (0.0 units after deduction), threshold reached * Expected: WE payout = 0 (0.0 units after 2.0 deduction), threshold reached
*/ */
@Test @Test
fun testExactlyAtThreshold() { fun testExactlyAtThreshold() {
@ -74,7 +74,7 @@ class PayrollCalculatorTest {
/** /**
* Test Case 3: Over threshold (3.5 WE) * Test Case 3: Over threshold (3.5 WE)
* Expected: WE payout = 675 (1.5 units after deduction) * Expected: WE payout = 675 (1.5 units after 2.0 deduction)
*/ */
@Test @Test
fun testOverThreshold() { fun testOverThreshold() {
@ -117,6 +117,7 @@ class PayrollCalculatorTest {
assertEquals(0.4, result.deductionFriday, 0.001) // All Friday deducted first assertEquals(0.4, result.deductionFriday, 0.001) // All Friday deducted first
assertEquals(1.6, result.deductionOther, 0.001) // Rest from other (1.6 to reach 2.0 total) assertEquals(1.6, result.deductionOther, 0.001) // Rest from other (1.6 to reach 2.0 total)
assertEquals(0.0, result.wePaid, 0.001) assertEquals(0.0, result.wePaid, 0.001)
assertEquals(0.0, result.payoutWE, 0.001)
} }
/** /**
@ -144,7 +145,7 @@ class PayrollCalculatorTest {
assertFalse(resultA.thresholdReached) assertFalse(resultA.thresholdReached)
assertEquals(0.0, resultA.payoutWE, 0.001) assertEquals(0.0, resultA.payoutWE, 0.001)
// B: above threshold // B: above threshold (2.5 WE - 2.0 deduction = 0.5 paid)
assertTrue(resultB.thresholdReached) assertTrue(resultB.thresholdReached)
assertEquals(2.5, resultB.weTotal, 0.001) assertEquals(2.5, resultB.weTotal, 0.001)
assertEquals(0.5, resultB.wePaid, 0.001) assertEquals(0.5, resultB.wePaid, 0.001)

View file

@ -53,11 +53,11 @@ Die ältere Implementierung nutzt eine andere Logik:
- **WE-Tag** (Weekend): Fr-So + Feiertag + Vortag Feiertag - **WE-Tag** (Weekend): Fr-So + Feiertag + Vortag Feiertag
2. **Bonusberechnung**: 2. **Bonusberechnung**:
- **WT-Tage** werden **immer** mit 250€ vergütet - **WT-Tage** werden bei Erreichen der Schwelle mit 250€ vergütet
- **WE-Tage** nur vergütet wenn ≥ 2.0 WE-Einheiten: - **WE-Tage** nur vergütet wenn ≥ 2.0 WE-Einheiten:
- Bei Erreichen: 450€ pro WE-Tag - Bei Erreichen: 450€ pro WE-Tag
- Dann Abzug von 2.0 WE-Einheiten (Freitag-Priorität) - Dann Abzug von 2.0 WE-Einheiten (Freitag-Priorität)
- Unter Schwellenwert: WE-Dienste = 0€ (nicht als WT vergütet) - Unter Schwellenwert: Keine Bonuszahlung (weder WE noch WT)
### Wichtiger Unterschied - Beispiel ### Wichtiger Unterschied - Beispiel
@ -203,18 +203,13 @@ this.RATE_WEEKEND = 500; // Statt 450
``` ```
### Abzug ändern (Web-App) ### Abzug ändern (Web-App)
Aktuell ist der Abzug fest auf 2.0 kodiert in `webapp/calculator.js`, Zeile 112: Der Abzug ist als Konstante in `webapp/calculator.js` definiert:
```javascript
qualifyingDaysDeducted = 2.0;
```
Um dies flexibel zu machen, könnte man hinzufügen:
```javascript ```javascript
this.DEDUCTION_AMOUNT = 2.0; // Im Constructor this.DEDUCTION_AMOUNT = 2.0; // Im Constructor
// Dann verwenden:
qualifyingDaysDeducted = this.DEDUCTION_AMOUNT;
``` ```
Um den Abzugswert zu ändern, einfach diesen Wert anpassen.
## Code-Architektur ## Code-Architektur
### Web-App (MVC-ähnlich) ### Web-App (MVC-ähnlich)

View file

@ -61,7 +61,7 @@ def _populate_readme(ws):
rules = [ rules = [
"WE-Tag = Fr/Sa/So/Feiertag/Vortag (BL-abhängig).", "WE-Tag = Fr/Sa/So/Feiertag/Vortag (BL-abhängig).",
"Variante 2 (streng): WE werden nur vergütet, wenn im Monat ≥ 2,0 WE-Einheiten erreicht werden;", "Variante 2 (streng): WE werden nur vergütet, wenn im Monat ≥ 2,0 WE-Einheiten erreicht werden;",
"dann 450 €/WE und Abzug 2,0 (Freitag zuerst). WT werden immer mit 250 € vergütet.", "dann 450 €/WE und Abzug 2,0 (Freitag zuerst). WT werden bei Erreichen der WE-Schwelle mit 250 € vergütet.",
"Splits anteilig. Monat und Bundesland in 'Regeln' wählen.", "Splits anteilig. Monat und Bundesland in 'Regeln' wählen.",
"", "",
"Schritte:", "Schritte:",

View file

@ -21,7 +21,7 @@ Eine Web-Anwendung zur Berechnung von Bonuszahlungen für Wochenend- und Feierta
### Bonusberechnung ### Bonusberechnung
1. **Schwellenwert**: Mindestens **2.0 qualifizierende Tage** im Monat erforderlich 1. **Schwellenwert**: Mindestens **2.0 qualifizierende Tage** im Monat erforderlich
2. **Abzug**: Bei Erreichen des Schwellenwerts werden **2.0 qualifizierende Tage** abgezogen 2. **Abzug**: Bei Erreichen des Schwellenwerts werden **2.0 qualifizierende Tage** abgezogen (Freitag-Priorität)
3. **Vergütung**: 3. **Vergütung**:
- Normale Tage: **250€** pro Tag - Normale Tage: **250€** pro Tag
- Qualifizierende Tage (WE/Feiertag): **450€** pro Tag - Qualifizierende Tage (WE/Feiertag): **450€** pro Tag

View file

@ -110,8 +110,8 @@ Dienste:
Erwartung: Erwartung:
- Normale Tage: 0.5 × 250€ = 125€ - Normale Tage: 0.5 × 250€ = 125€
- Qualifizierende Tage: (2.5 - 1.0) × 450€ = 675€ - Qualifizierende Tage: (2.5 - 2.0) × 450€ = 225€
- Gesamt: 800€ - Gesamt: 350€
``` ```
## Tests erweitern ## Tests erweitern

View file

@ -526,7 +526,7 @@ class DienstplanApp {
csv += 'WE/Feiertag Tage;"Freitag, Samstag, Sonntag, Feiertag oder Tag vor Feiertag"\n'; csv += 'WE/Feiertag Tage;"Freitag, Samstag, Sonntag, Feiertag oder Tag vor Feiertag"\n';
csv += 'Schwelle;"Mindestens 2,0 WE-Einheiten für Bonuszahlung erforderlich"\n'; csv += 'Schwelle;"Mindestens 2,0 WE-Einheiten für Bonuszahlung erforderlich"\n';
csv += 'Sätze;"Normale Tage = 250 EUR/Einheit, WE/Feiertag = 450 EUR/Einheit"\n'; csv += 'Sätze;"Normale Tage = 250 EUR/Einheit, WE/Feiertag = 450 EUR/Einheit"\n';
csv += 'Abzug;"Bei Erreichen der Schwelle wird 1,0 WE-Einheit abgezogen"\n'; csv += 'Abzug;"Bei Erreichen der Schwelle werden 2,0 WE-Einheiten abgezogen"\n';
// Download CSV file // Download CSV file
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
@ -738,7 +738,7 @@ class DienstplanApp {
if (thresholdReached) { if (thresholdReached) {
const wt_pay = data.wt * this.calculator.RATE_NORMAL; const wt_pay = data.wt * this.calculator.RATE_NORMAL;
let deduct = 1.0; let deduct = this.calculator.DEDUCTION_AMOUNT;
const deduct_fr = Math.min(deduct, data.we_fr); const deduct_fr = Math.min(deduct, data.we_fr);
const deduct_other = Math.max(0, deduct - deduct_fr); const deduct_other = Math.max(0, deduct - deduct_fr);
const paid_fr = Math.max(0, data.we_fr - deduct_fr); const paid_fr = Math.max(0, data.we_fr - deduct_fr);
@ -757,20 +757,23 @@ class DienstplanApp {
totalBonus += bonus; totalBonus += bonus;
// Generate note // Generate note - cleaner, more professional format
const safeName = escapeHtml(name); const safeName = escapeHtml(name);
let note = `<b>${safeName}</b>: `; let note = '';
if (!thresholdReached) { if (!thresholdReached) {
note += `Erreicht das Bonussystem nicht (nur ${we_total.toFixed(1)} WE-Einheiten, mind. 2,0 erforderlich).`; note = `<b>${safeName}</b> erreicht die Mindestschwelle nicht (${we_total.toFixed(1)} von ${this.calculator.MIN_QUALIFYING_DAYS.toFixed(1)} WE-Einheiten) und erhält daher keine Bonuszahlung.`;
} else { } else {
const details = []; const paid_we = we_total - this.calculator.DEDUCTION_AMOUNT;
if (data.wt > 0) details.push(`${data.wt.toFixed(1)} WT × 250€`); let breakdown = [];
if (data.we_fr > 0 || data.we_other > 0) { if (data.wt > 0) breakdown.push(`${data.wt.toFixed(1)} WT-Einheiten à ${this.calculator.RATE_NORMAL}`);
const paid_we = we_total - 1.0; if (paid_we > 0) breakdown.push(`${paid_we.toFixed(1)} WE-Einheiten à ${this.calculator.RATE_WEEKEND}`);
details.push(`${paid_we.toFixed(1)} WE × 450€ (abzgl. 1,0 Abzug von ${deductedFrom})`);
note = `<b>${safeName}</b> erhält eine Bonuszahlung von <span style="color: #28a745; font-weight: bold;">${this.calculator.formatCurrency(bonus)}</span>`;
if (breakdown.length > 0) {
note += ` (${breakdown.join(' + ')})`;
} }
note += `Erhält ${this.calculator.formatCurrency(bonus)}. ${details.join(', ')}.`; note += '.';
} }
employeeNotes.push(note); employeeNotes.push(note);
@ -832,7 +835,7 @@ class DienstplanApp {
<li><strong>Vergütung bei Erreichen der Schwelle:</strong> <li><strong>Vergütung bei Erreichen der Schwelle:</strong>
<ul> <ul>
<li>Werktage (WT): 250 pro Einheit</li> <li>Werktage (WT): 250 pro Einheit</li>
<li>WE-Tage: 450 pro Einheit (abzüglich 1,0 Einheit Abzug, Freitag zuerst)</li> <li>WE-Tage: 450 pro Einheit (abzüglich 2,0 Einheiten Abzug, Freitag zuerst)</li>
</ul> </ul>
</li> </li>
<li><strong>Unter Schwelle:</strong> Keine Bonuszahlung (weder WT noch WE)</li> <li><strong>Unter Schwelle:</strong> Keine Bonuszahlung (weder WT noch WE)</li>

View file

@ -8,6 +8,7 @@ class BonusCalculator {
this.RATE_NORMAL = 250; // Normal day rate (not weekend/holiday) this.RATE_NORMAL = 250; // Normal day rate (not weekend/holiday)
this.RATE_WEEKEND = 450; // Weekend/holiday rate this.RATE_WEEKEND = 450; // Weekend/holiday rate
this.MIN_QUALIFYING_DAYS = 2.0; // Minimum qualifying days to trigger bonus this.MIN_QUALIFYING_DAYS = 2.0; // Minimum qualifying days to trigger bonus
this.DEDUCTION_AMOUNT = 2.0; // Deduction after reaching threshold
} }
/** /**
@ -108,8 +109,8 @@ class BonusCalculator {
let totalDeduction = 0; let totalDeduction = 0;
if (thresholdReached) { if (thresholdReached) {
// Deduct 2.0 qualifying days with Friday priority // Deduct qualifying days with Friday priority
totalDeduction = 2.0; totalDeduction = this.DEDUCTION_AMOUNT;
// First deduct from Friday // First deduct from Friday
deductionFromFriday = Math.min(totalDeduction, qualifyingDaysFriday); deductionFromFriday = Math.min(totalDeduction, qualifyingDaysFriday);

View file

@ -191,7 +191,7 @@ runner.test('Berechnung: Unter Schwellenwert (1.0 WE-Tag) = 0€', (t) => {
t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein'); t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein');
}); });
runner.test('Berechnung: Genau 2.0 WE-Tage = 450€', (t) => { runner.test('Berechnung: Genau 2.0 WE-Tage = 0€', (t) => {
const holidays = new HolidayProvider(); const holidays = new HolidayProvider();
const calculator = new BonusCalculator(holidays); const calculator = new BonusCalculator(holidays);
@ -209,7 +209,7 @@ runner.test('Berechnung: Genau 2.0 WE-Tage = 450€', (t) => {
t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein'); t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein');
}); });
runner.test('Berechnung: 2x halbe WE-Dienste = 0€ (genau Schwelle, aber nach Abzug nichts)', (t) => { runner.test('Berechnung: 2x halbe WE-Dienste = 0€ (genau Schwelle, nach Abzug 2.0)', (t) => {
const holidays = new HolidayProvider(); const holidays = new HolidayProvider();
const calculator = new BonusCalculator(holidays); const calculator = new BonusCalculator(holidays);
@ -241,7 +241,7 @@ runner.test('Berechnung: 3 WE-Tage = 450€', (t) => {
const result = calculator.calculateMonthlyBonus(duties); const result = calculator.calculateMonthlyBonus(duties);
t.assertEqual(result.qualifyingDays, 3.0, 'Sollte 3.0 qualifizierende Tage haben'); t.assertEqual(result.qualifyingDays, 3.0, 'Sollte 3.0 qualifizierende Tage haben');
t.assertEqual(result.qualifyingDaysPaid, 1.0, 'Sollte 1.0 Tag bezahlen (3-2)'); t.assertEqual(result.qualifyingDaysPaid, 1.0, 'Sollte 1.0 Tage bezahlen (3-2)');
t.assertEqual(result.totalBonus, 450, 'Bonus sollte 450€ sein (1×450€)'); t.assertEqual(result.totalBonus, 450, 'Bonus sollte 450€ sein (1×450€)');
}); });
@ -261,7 +261,7 @@ runner.test('Berechnung: Normale Tage + WE-Tage gemischt', (t) => {
t.assertEqual(result.normalDays, 2.0, 'Sollte 2.0 normale Tage haben'); t.assertEqual(result.normalDays, 2.0, 'Sollte 2.0 normale Tage haben');
t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben'); t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben');
t.assertEqual(result.normalDaysPaid, 2.0, 'Sollte 2.0 normale Tage bezahlen'); t.assertEqual(result.normalDaysPaid, 2.0, 'Sollte 2.0 normale Tage bezahlen');
t.assertEqual(result.qualifyingDaysPaid, 0.0, 'Sollte 0.0 qualifizierenden Tag bezahlen'); t.assertEqual(result.qualifyingDaysPaid, 0.0, 'Sollte 0.0 qualifizierende Tage bezahlen');
t.assertEqual(result.bonusNormalDays, 500, 'Normale Tage: 2×250€ = 500€'); t.assertEqual(result.bonusNormalDays, 500, 'Normale Tage: 2×250€ = 500€');
t.assertEqual(result.bonusQualifyingDays, 0, 'WE-Tage: 0×450€ = 0€'); t.assertEqual(result.bonusQualifyingDays, 0, 'WE-Tage: 0×450€ = 0€');
t.assertEqual(result.totalBonus, 500, 'Gesamt: 500€'); t.assertEqual(result.totalBonus, 500, 'Gesamt: 500€');
@ -301,7 +301,7 @@ runner.test('Berechnung: Feiertag + Vortag', (t) => {
t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben'); t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben');
t.assertTrue(result.thresholdReached, 'Schwellenwert sollte erreicht sein'); t.assertTrue(result.thresholdReached, 'Schwellenwert sollte erreicht sein');
t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein'); t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein (2.0 - 2.0 = 0.0 × 450€)');
}); });
runner.test('Berechnung: Keine Dienste = 0€', (t) => { runner.test('Berechnung: Keine Dienste = 0€', (t) => {