# Part of the odoo-at-payroll project. License: LGPL-3. from datetime import date, timedelta from odoo import api, fields, models class HrWorkEntryType(models.Model): _inherit = 'hr.work.entry.type' l10n_at_nacht_sv = fields.Boolean( string='AT Nacht-Sondervergütung (22–6)', help='Stunden dieses Typs fallen ins KV-Nachtfenster 22–6 Uhr ' 'und gebühren der Nacht-Sondervergütung nach KV § 6 ' '(zuschlagsunabhängig, kumulativ).') class HrVersionPrivate(models.Model): _inherit = 'hr.version' # All-in / Überstundenpauschale § 2g AVRAG (Deckungsrechnung GP2): # zwingender Grundlohnausweis im Dienstzettel; Pauschale gilt bei # Unterdeckung nur als Akontozahlung (Deckungsprüfbericht). l10n_at_ue_pauschale_monatlich = fields.Monetary( string='ÜStd-Pauschale/Monat (All-in § 2g AVRAG)', currency_field='currency_id', help='Monatliche Überstundenpauschale lt. Arbeitsvertrag; ' '0 = kein All-in.') l10n_at_grundlohn_ausweis = fields.Monetary( string='Grundlohn lt. Dienstzettel (§ 2g AVRAG)', currency_field='currency_id', help='Zwingend ausgewiesener Grundlohn (ohne ÜStd-Anteil) im ' 'Arbeitsvertrag/Dienstzettel.') class HrPayslip(models.Model): _inherit = 'hr.payslip' # ------------------------------------------------------------------ # Arbeitstage (resource_calendar) — § 17 Abs 2 KV, user convention: # Werktage des KV werden als Arbeitstage aus dem Ressourcenkalender # gelesen (Mo–Fr-Kalender: Faktor = AT Dienstspanne / AT Zeitraum; # für Mo–Sa-Kalender identisch mit ÷26 × Werktage). # ------------------------------------------------------------------ def _at_arbeitstage(self, von, bis): """Arbeitstage (Kalender-Arbeitstage) im Intervall [von, bis].""" self.ensure_one() calendar = self.version_id.resource_calendar_id if not calendar or not von or not bis or von > bis: return 0 days = { int(att.dayofweek) for att in calendar.attendance_ids } if not days: return 0 count = 0 tag = von while tag <= bis: if tag.weekday() in days: count += 1 tag += timedelta(days=1) return count def _at_arbeitstage_faktor(self): """Prorate-Faktor Ein-/Austritt: Arbeitstage der Beschäftigungs- spanne (contract_date_*) im Abrechnungszeitraum ÷ Arbeitstage des vollen Zeitraums.""" self.ensure_one() version = self.version_id von = max(self.date_from, version.contract_date_start) \ if version.contract_date_start else self.date_from bis = min(self.date_to, version.contract_date_end) \ if version.contract_date_end else self.date_to if von > bis: return 0.0 total = self._at_arbeitstage(self.date_from, self.date_to) if not total: return 1.0 return self._at_arbeitstage(von, bis) / float(total) # ------------------------------------------------------------------ # KV-Basis / Grundstunden (Teilungsfaktoren § 5 Abs 6 KV) # ------------------------------------------------------------------ def _at_kv_gehalt_basis(self, datum=None): """Volles Monatsgehalt: Überzahlung überschreibt Katalog; Teilzeit über work_time_rate (Enterprise-Fracht: Bruchteil 0..1, compute aus Versionskalender ÷ Firmenzalender — D4-verifiziert).""" self.ensure_one() version = self.version_id datum = datum or self.date_to if version.ueberzahlung: gehalt = version.ueberzahlung else: pos = self.employee_id.kv_gruppe_id._get_gehalt( self.employee_id.erfahrungsstufe, datum) gehalt = pos.gehalt if pos else 0.0 return gehalt * (version.work_time_rate or 1.0) def _at_grundstunde_ue(self): """ÜStd-Grundvergütung (und Zuschlagsbasis) = Monatsgehalt ÷ Teilungsfaktor ÜStd (KV § 5 Abs 6: 2203 ÷ 150, 2748 ÷ 143).""" self.ensure_one() teiler = self.employee_id.kv_id._wert( 'teilungsfaktor_ue', self.date_to, raise_if_not_found=False) if not teiler: return 0.0 return self._at_kv_gehalt_basis() / float(teiler) def _at_grundstunde_norm(self): """Normalarbeitsstunde/Mehrarbeit = Monatsgehalt ÷ Teilungsfaktor Normalstunde (2748: ÷ 167) bzw. Fallback ÷ (4,33 × KV-Wochenstunden) ohne KV-Teilungsfaktor.""" self.ensure_one() kv = self.employee_id.kv_id teiler = kv._wert( 'teilungsfaktor_norm', self.date_to, raise_if_not_found=False) basis = self._at_kv_gehalt_basis() if teiler: return basis / float(teiler) naz = kv._wert('naz', self.date_to, raise_if_not_found=False) or 40 return basis / (4.33 * float(naz)) def _at_nacht_sv_stunden(self): """Stunden der Nacht-Sondervergütung: Work-Entry-Types mit Nacht-Flag (22–6-Fenster), unabhängig vom Zuschlagsstatus.""" self.ensure_one() return sum( (wd.number_of_hours or 0.0) for wd in self.worked_days_line_ids if wd.work_entry_type_id.l10n_at_nacht_sv) # ------------------------------------------------------------------ # 13./14. Bezug (§ 11 KV) — Basis, Fälligkeit, Aliquotierung # ------------------------------------------------------------------ def _at_sz_basis_datum(self, code): """Stichtag der B13-/B14-Basis: 'november' → 30. 11. des Jahres (2748 B13), sonst Auszahlungsmonat = payslip-Stichtag.""" self.ensure_one() methode = self.employee_id.kv_id._wert( code, self.date_to, raise_if_not_found=False) or 'auszahlung' if methode == 'november': return date(self.date_to.year, 11, 30) return self.date_to def _at_jahres_versionen(self): """Versionen des Mitarbeiters mit Überlappung im Kalenderjahr der Abrechnung (Mischberechnung Beschäftigungsausmaß, 2203 § 11 Abs 2).""" self.ensure_one() jahr = self.date_to.year von, bis = date(jahr, 1, 1), date(jahr, 12, 31) versionen = [] for version in self.env['hr.version'].search([ ('employee_id', '=', self.employee_id.id), ]): v_von = version.date_start or von v_bis = version.date_end or bis if max(v_von, von) <= min(v_bis, bis): versionen.append((version, max(v_von, von), min(v_bis, bis))) return versionen def _at_sz_basis(self, datum): """B13-/B14-Basis zum Stichtag: einheitliches Beschäftigungs- ausmaß = aktuelles Monatsgehalt; Änderungen des Ausmaßes im Kalenderjahr → Kalendertag-gewichtete Mischung über die Jahres-Versionen (Konvention GP2, s. docs/rechenfaelle/b13b14.md).""" self.ensure_one() versionen = self._at_jahres_versionen() if len(versionen) <= 1: return self._at_kv_gehalt_basis(datum) if len({(v.work_time_rate or 100.0) for v, _, _ in versionen}) == 1: return self._at_kv_gehalt_basis(datum) # Mischberechnung: Kalendertag-Anteile je Version im Jahr jahr_von, jahr_bis = date(self.date_to.year, 1, 1), date(self.date_to.year, 12, 31) tage_jahr = (jahr_bis - jahr_von).days + 1 summe_anteile, summe_gehaelter = 0.0, 0.0 for version, v_von, v_bis in versionen: anteil = ((v_bis - v_von).days + 1) / float(tage_jahr) if version.ueberzahlung: gehalt = version.ueberzahlung else: pos = version.kv_gruppe_id._get_gehalt( version.erfahrungsstufe, v_bis) gehalt = pos.gehalt if pos else 0.0 summe_anteile += anteil summe_gehaelter += gehalt * anteil \ * (version.work_time_rate or 1.0) if not summe_anteile: return 0.0 return summe_gehaelter / summe_anteile def _at_sz_dienstzeit_faktor(self): """Aliquote Ein-/Austritt (§ 11 Abs 4 KV): Methode je `sz_aliquote_methode` — 'kalendertage' (Default, user decision: Gehalt/365 × Kalendertage Dienstzeit) oder 'monate' (volle Monate ÷ 12, anteilige Monate taggenau).""" self.ensure_one() jahr = self.date_to.year jahr_von, jahr_bis = date(jahr, 1, 1), date(jahr, 12, 31) methode = self.employee_id.kv_id._wert( 'sz_aliquote_methode', self.date_to, raise_if_not_found=False) or 'kalendertage' kt_gesamt, monate_gesamt = 0, 0.0 for version, v_von, v_bis in self._at_jahres_versionen(): kt_gesamt += (v_bis - v_von).days + 1 tag = date(jahr, 1, 1) while tag <= date(jahr, 12, 31): if v_von <= tag <= v_bis: monate_gesamt += 1.0 / ((tag.replace(day=28) + timedelta(days=4)).day) tag += timedelta(days=1) if methode == 'monate': return min(monate_gesamt / 12.0, 1.0) return min(kt_gesamt / 365.0, 1.0) def _at_sz_bereits_bezahlt(self, code): """Summe bereits bezahlter B13-/B14-Linien im Kalenderjahr (andere, bestätigte Läufe).""" self.ensure_one() jahr = self.date_to.year linien = self.env['hr.payslip.line'].search([ ('employee_id', '=', self.employee_id.id), ('code', '=', code), ('date_from', '>=', date(jahr, 1, 1)), ('date_from', '<=', date(jahr, 12, 31)), ]) linien = linien.filtered( lambda l: l.slip_id.state not in ('draft', 'cancel') and l.slip_id.id != self.id) return sum(linien.mapped('total')) def _at_urlaubstage_jahr(self): """Urlaubs-Work-Entries (is_leave) des Kalenderjahres.""" self.ensure_one() jahr = self.date_to.year eintraege = self.env['hr.work.entry'].search([ ('version_id', 'in', self.employee_id.version_ids.ids), ('work_entry_type_id.is_leave', '=', True), ]) return sum( 1 for we in eintraege if we.date.year == jahr) def _at_b14_faellig(self): """B14-Fälligkeit § 11 Abs 3 KV: 'urlaub' → bei Urlaubsantritt (längster Teil — R1: Auszahlung im erstfälligen Urlaubsmonat, sofern nicht bereits bezahlt); ohne angetretenen Urlaub im Dezember (`sz14_no_urlaub_monat`); bei Austritt aliquote Endabrechnung. Fallback ohne KV-Code: Juni-Abrechnung (user decision).""" self.ensure_one() kv = self.employee_id.kv_id faelligkeit = kv._wert( 'sz14_faelligkeit', self.date_to, raise_if_not_found=False) if faelligkeit != 'urlaub': return self.date_to.month == 6 # Fallback Juni-Abrechnung if self._at_sz_bereits_bezahlt('B14'): return False austritt = ( self.version_id.contract_date_end and self.date_from <= self.version_id.contract_date_end <= self.date_to) urlaub_im_zeitraum = any( wd.work_entry_type_id.is_leave for wd in self.worked_days_line_ids) if urlaub_im_zeitraum or austritt: return True no_urlaub_monat = int(kv._wert( 'sz14_no_urlaub_monat', self.date_to, raise_if_not_found=False) or 12) return (self.date_to.month == no_urlaub_monat and not self._at_urlaubstage_jahr()) def _at_b13_faellig(self): """B13-Fälligkeit: Auszahlmonat je KV (`sz13_monat`) bzw. bei Austritt aliquote Endabrechnung (§ 11 Abs 4 KV).""" self.ensure_one() if self._at_sz_bereits_bezahlt('B13'): return False monat = int(self.employee_id.kv_id._wert( 'sz13_monat', self.date_to, raise_if_not_found=False) or 12) austritt = ( self.version_id.contract_date_end and self.date_from <= self.version_id.contract_date_end <= self.date_to) return self.date_to.month == monat or austritt # ------------------------------------------------------------------ # Urlaubsentgelt — regelmäßige Überstunden (§ 2 Abs 2 GKV; # 7-von-12-Regel: KV § 5 Abs 11 (2203) / Abs 10 (2748)) # ------------------------------------------------------------------ def _at_urlaub_ue_avg(self): """(regelmaessig, durchschnittlicher monatlicher ÜStd-Betrag der letzten 12 Monate) — nur ÜStd-Regeln, ohne Mehrarbeit. Achtung: Lohnzettel-Lines tragen Regel-Codes (UE_*), nicht WET-Codes (ATP_*).""" self.ensure_one() codes = ('UE_STD', 'UE_NACHT', 'UE_NACHT_FRUEH', 'UE_SOFEI', 'UE_SOFEI_NACHT') von = fields.Date.subtract(self.date_from, months=12) linien = self.env['hr.payslip.line'].search([ ('employee_id', '=', self.employee_id.id), ('code', 'in', list(codes)), ('date_from', '>=', von), ]).filtered(lambda l: l.slip_id.state not in ('draft', 'cancel')) if not linien: return False, 0.0 monate = {l.slip_id.id for l in linien} return len(monate) >= 7, sum(l.total for l in linien) / 12.0 # ------------------------------------------------------------------ # Lohnnebenkosten FLAF-DG-Beitrag / KommSt / DZ (GP4; § 41 FLAG, # § 5+§ 9 KommStG, §§ 122/126 WKG — Werte/Staffeln im Kern) # ------------------------------------------------------------------ def _at_lnk_basis(self, steuer, result_rules=None): """LNK-Bemessungsgrundlage nach Freibetragsstaffel: monatlich gezahlte Arbeitslöhne (laufend + im Monat gezahlte §-67-Bezüge, Zuflussprinzip; kein SV-Abzug), ohne §-67-Abs-3/6-Bezüge (Abfertigung/Abfindung). 'flaf'/'dz' zusätzlich ohne lit-f-AN (Arbeitslöhne ab Folgemonat nach Vollendung des 60. Lebensjahres, § 41 Abs 4 lit f FLAG — die Kommunalsteuer hat KEINE lit-f- Befreiung). Freibetrag: ≤ 1.095 € keine Abgabe, bis 1.460 € → Freibetrag 1.095 €, darüber volle BG (at_lnk_freibetrag).""" self.ensure_one() basis = (self._l10n_at_brutto('laufend', result_rules) + self._l10n_at_brutto('sonstig', result_rules)) for rule in self.struct_id.rule_ids: if rule.l10n_at_s67_abs in ('abs3', 'abs6'): if result_rules is not None and rule.code in result_rules: basis -= result_rules[rule.code]['total'] elif result_rules is None: linie = self.line_ids.filtered( lambda l: l.code == rule.code) basis -= sum(linie.mapped('total')) basis = max(basis, 0.0) if steuer in ('flaf', 'dz'): version = self.version_id if (version.l10n_at_lnkp_frei_ab and self.date_from >= version.l10n_at_lnkp_frei_ab): return 0.0 freibetrag = self.env['hr.rule.parameter']._get_parameter_from_code( 'at_lnk_freibetrag', self.date_to, raise_if_not_found=False) or {} grenze = float(freibetrag.get('grenze', 0.0)) if basis <= 0.0: return 0.0 if basis <= grenze: basis -= float(freibetrag.get('freibetrag', 0.0)) return max(basis, 0.0) def _at_flaf_db(self, result_rules=None): """FLAF-Dienstgeberbeitrag § 41 FLAG (3,7 % ab 2025, 2,7 % ab 2028 — at_flaf_db; BG s. _at_lnk_basis).""" self.ensure_one() basis = self._at_lnk_basis('flaf', result_rules) satz = float(self.env['hr.rule.parameter']._get_parameter_from_code( 'at_flaf_db', self.date_to, raise_if_not_found=False) or 0.0) return basis * satz / 100.0 def _at_kommst(self, result_rules=None): """Kommunalsteuer § 9 KommStG 1993 (3 % — at_kommst_satz; BG § 5 Abs 2: ohne §-67-Abs-3/6-Bezüge, ohne lit-f-Befreiung).""" self.ensure_one() basis = self._at_lnk_basis('kommst', result_rules) satz = float(self.env['hr.rule.parameter']._get_parameter_from_code( 'at_kommst_satz', self.date_to, raise_if_not_found=False) or 0.0) return basis * satz / 100.0 def _at_dz(self, result_rules=None): """Zuschlag zum Dienstgeberbeitrag (§§ 122/126 WKG) für WKO- Mitgliedsbetriebe: Landeskammersatz je Betriebsstätten-Bundesland inkl. Bundeskammeranteil (at_dz_vektor; ⚠ WKO-Verifikation 8.11); BG identisch FLAF-DB inkl. lit f (lb-lnk-07).""" self.ensure_one() if not self.company_id.l10n_at_wk_mitglied: return 0.0 basis = self._at_lnk_basis('dz', result_rules) vektor = self.env['hr.rule.parameter']._get_parameter_from_code( 'at_dz_vektor', self.date_to, raise_if_not_found=False) or {} satz = float((vektor.get('laender') or {}).get( self.company_id.l10n_at_bundesland or '', 0.0)) return basis * satz / 100.0 # ------------------------------------------------------------------ # All-in-Deckungsrechnung (§ 2g AVRAG) — minimale GP2-Umsetzung: # Jahresvergleich geschuldete ÜStd-Vergütung vs. erhaltene Pauschale # ------------------------------------------------------------------ def _at_allin_deckung(self): """Deckungsrechnung für Verträge mit ÜStd-Pauschale: {'pauschale': x, 'geschuldet': y, 'unterdeckung': z}.""" self.ensure_one() version = self.version_id pauschale = version.l10n_at_ue_pauschale_monatlich or 0.0 if not pauschale: return {'pauschale': 0.0, 'geschuldet': 0.0, 'unterdeckung': 0.0} jahr = self.date_to.year codes = ('UE_STD', 'UE_NACHT', 'UE_NACHT_FRUEH', 'UE_SOFEI', 'UE_SOFEI_NACHT') linien = self.env['hr.payslip.line'].search([ ('employee_id', '=', self.employee_id.id), ('code', 'in', list(codes)), ('date_from', '>=', date(jahr, 1, 1)), ('date_from', '<=', date(jahr, 12, 31)), ]).filtered(lambda l: l.slip_id.state not in ('draft', 'cancel')) geschuldet = sum(linien.mapped('total')) monate = 12 \ if not version.contract_date_end \ or version.contract_date_end.year > jahr \ else version.contract_date_end.month erhaltene = pauschale * monate unterdeckung = max(geschuldet - erhaltene, 0.0) return { 'pauschale': erhaltene, 'geschuldet': geschuldet, 'unterdeckung': unterdeckung, }