# Part of the odoo-at-payroll project. License: LGPL-3. """Lohnzettel Finanz — Lohnnachweis/Lohnbescheinigung L17 (AP17) — DM-ORG 42.7.0, Kapitel E.24 (BEST ``LF``, VSTR ``94``, Satzart ``A1``, Länge 1100, Version 28 — zwingender Einsatz ab 01.03.2026, fachliche Gültigkeit für Zeiträume ab 01.01.2025; PDF-verifizierte ODL-Extraktion 2026-09-16 aus `.firecrawl/ris/elda/DM-ORG_*v42.7.0*.pdf`). Der A1-Satz ist je **Lohnzahlungszeitraum** je Dienstnehmer aufzubauen (D.32: BELZ/ENLZ/JALZ; REFN je AG+Jahr+Dienstverhältnis eindeutig und auch bei Zeiträumsänderung unverändert). Kennzahlen-Semantik laut Prüfkatalog L17 (Stand 12/2025, lokal `.firecrawl/ris/elda/`): **KZ 350 = Bruttobezüge GESAMT**; die Teil-KZs 351 (Zuwendungen neben Arbeitslohn), 352 (Abfertigungen/Abfindungen), 354 (normale ÜStd- Zuschläge), 356/738 (Pensionsabfindungen), UEB, 394, KUR, ALT, SOP, AUS, ZAKV, MAGB sind **Teilmengen** von 350 (Prüfkatalog-Formel: Feld 28 < (Feld 30 + 32 + 34 + 36 + 38 + 50 + 72 + 74 + 76 + 80 + 82 + 95 + 100) = Fehler). KZ 738/739 sind nur für Jahre bis 2017 zulässig (Pensionsabfindung Pensionskasse). KZ-Mapping aus den validierten Läufen (GP3-Kern-Flags, Muster `lohnzettel_l16.py`): - 350: laufende Bezüge + alle sonstigen §-67-Bezüge (gesamtes §-25-Brutto des LZP inkl. 351/352/354) - 351: sonstige Bezüge ohne Abs-3/6-Markierung (Sechstel-Route — Sonderzahlungen und Zuwendungen) - 352: sonstige Bezüge mit ``l10n_at_s67_abs`` = abs3/abs6 (Abfertigung Alt / freiwillige Abfertigung / Abfindungen) - 354: § 68-freie Überstundenzuschläge (``_l10n_at_freibetraege_68``) - 357: einbehaltene SV-Beiträge des laufenden Arbeitslohns (SVDN_KV/PV/AV/AK/WF) - 347: einbehaltene SV-Beiträge für Zuwendungen (SVDN_SZ) - nicht automatisiert (dokumentierter Rest, Grundstellung 0 + Protokoll- Hinweis): 356/738 Pensionsabfindungen, 736/737/739 SV-Zerlegung auf Abfindungs-KZs, BUEB, 358, BSPE, BARB, WERK, UEBMO, ANZAT, TSTAAT, TEPR/MAPR/ZAKV/ANKV/MAGB, KOUN, 394, KUR, ALT, SOP, AUS. Formale Prüfungen nach Prüfkatalog L17: KZ-350-Formel, 357 ≤ 40 % × 350, 347 ≤ 25 % × 351, 354 ≤ 2.400 € (+1 %) je LZP aliquotiert, HOPA ≤ 3 € × HOTA und ≤ 300 € je LZP, DIEN ≤ 99, WERK/UEBMO ≤ 12, ANZAT ≤ 366, HOTA ≤ LZP-Tage.""" from datetime import date, datetime import base64 from odoo import api, fields, models from odoo.exceptions import UserError from .elda import ( L10N_AT_ELDA_IDTEIL, L10nAtEldaBestand, L10nAtEldaDatei, L10nAtEldaSatz, ) from .lohnzettel_l16 import L16_INFORMATIONSSATZ # ------------------------------------------------------------------ # Mitteilungssatz A1 (E.24; Länge 1100) — Datenteil # ------------------------------------------------------------------ L17_SATZ = [ (2, 'FSART', 21, 1, 'a'), # A (3, 'CLADR', 22, 6, 'a'), (4, 'CLDVR', 28, 7, 'a'), (6, 'STNRL', 35, 9, 'n'), # Steuernummer Lohnverrechnungsstelle (7, 'DVRNL', 44, 7, 'n'), (8, 'ARTU', 51, 1, 'a'), (9, 'DTUE', 52, 8, 'n'), # JJJJMMTT, wie I1 (10, 'ZTUE', 60, 6, 'n'), (11, 'REFN', 66, 30, 'a'), # je AG+Jahr+DV eindeutig (12, 'RE12', 96, 2, 'a'), (13, 'FEHL', 98, 20, 'a'), (14, 'STAT', 118, 1, 'a'), (15, 'FIND', 119, 1, 'a'), # 'J' = vom Hersteller für richtig (16, 'BELZ', 120, 4, 'n'), # Beginn LZP TTMM (17, 'ENLZ', 124, 4, 'n'), # Ende LZP TTMM (18, 'JALZ', 128, 4, 'n'), # Jahr LZP (19, 'PENS', 132, 1, 'a'), # J/N (20, 'AVSN', 133, 10, 'n'), # LLLPTTMMJJ (21, 'ANAM', 143, 37, 'a'), (22, 'ATIT', 180, 20, 'a'), (23, 'AADR', 200, 37, 'a'), (24, 'ALKZ', 237, 3, 'a'), (25, 'APLZ', 240, 10, 'a'), (26, 'AORT', 250, 30, 'a'), (27, 'V350', 280, 1, 'a'), (28, 'B350', 281, 10, 'n'), # Bruttobezüge gesamt (KZ 350) (29, 'V351', 291, 1, 'a'), (30, 'B351', 292, 10, 'n'), # Zuwendungen neben Arbeitslohn (31, 'V352', 302, 1, 'a'), (32, 'B352', 303, 10, 'n'), # Abfertigungen und Abfindungen (33, 'V354', 313, 1, 'a'), (34, 'B354', 314, 10, 'n'), # (normale) ÜStd-Zuschläge (35, 'V356', 324, 1, 'a'), (36, 'B356', 325, 10, 'n'), # Pensionsabfindung Arbeitgeber (37, 'V738', 335, 1, 'a'), (38, 'B738', 336, 10, 'n'), # Pensionsabfindung Pensionskasse (39, 'V357', 346, 1, 'a'), (40, 'B357', 347, 10, 'n'), # SV-Beiträge laufender Arbeitslohn (41, 'V347', 357, 1, 'a'), (42, 'B347', 358, 10, 'n'), # SV-Beiträge für Zuwendungen (43, 'V736', 368, 1, 'a'), (44, 'B736', 369, 10, 'n'), # SV-Beiträge für KZ 352 (45, 'V737', 379, 1, 'a'), (46, 'B737', 380, 10, 'n'), # SV-Beiträge für KZ 356 (47, 'V739', 390, 1, 'a'), (48, 'B739', 391, 10, 'n'), # SV-Beiträge für KZ 738 (49, 'VUEB', 401, 1, 'a'), (50, 'BUEB', 402, 10, 'n'), # übrige Bezüge außerhalb Österreichs (51, 'RE51', 412, 1, 'a'), (52, 'RE52', 413, 10, 'a'), (53, 'RE53', 423, 1, 'a'), (54, 'RE54', 424, 10, 'a'), (55, 'V358', 434, 1, 'a'), (56, 'B358', 435, 10, 'n'), # anrechenbare ausländische Steuer (57, 'VSPE', 445, 1, 'a'), (58, 'BSPE', 446, 10, 'n'), # Spesensätze außerhalb KZ 350 (59, 'VARB', 456, 1, 'a'), (60, 'BARB', 457, 10, 'n'), # AG-Beiträge an Pensionskassen (61, 'DIEN', 467, 2, 'n'), # Dienstzeit (Jahre) bei KZ 352 (62, 'STDT', 469, 8, 'n'), # Sterbedatum JJJJMMTT (63, 'OELD', 477, 20, 'a'), # Ordnungsbegriff ELDA (64, 'KORR', 497, 1, 'a'), # 'K'/'S' (65, 'INTE', 498, 11, 'a'), (66, 'WOBD', 509, 12, 'a'), (67, 'ZOBD', 521, 15, 'a'), (68, 'WAHR', 536, 3, 'a'), # Währung (69, 'MUEB', 539, 2, 'n'), # Monate überwiegende Nachtarbeit (70, 'V394', 541, 1, 'a'), (71, 'B394', 542, 10, 'n'), # Sonn-/Feiertags-/Nachtzuschläge (72, 'VKUR', 552, 1, 'a'), (73, 'BKUR', 553, 10, 'n'), # Kurzarbeit (74, 'VALT', 563, 1, 'a'), (75, 'BALT', 564, 10, 'n'), # Altersteilzeit (76, 'RE76', 574, 1, 'a'), (77, 'RE77', 575, 10, 'n'), (78, 'VSOP', 585, 1, 'a'), (79, 'BSOP', 586, 10, 'n'), # Sozialplanzahlungen (80, 'VAUS', 596, 1, 'a'), (81, 'BAUS', 597, 10, 'n'), # begünstigte Auslandstätigkeit (82, 'WERK', 607, 2, 'n'), # Werkverkehr (Monate) (83, 'UEBMO', 609, 2, 'n'), # Überlassung Kfz (Monate) (84, 'ANZAT', 611, 3, 'n'), # Arbeitstage begünstigte Auslandstätigk. (85, 'HOTA', 614, 3, 'n'), # Telearbeitstage (Homeoffice) (86, 'VHOPA', 617, 1, 'a'), (87, 'HOPA', 618, 10, 'n'), # Homeoffice-Pauschale (§ 26 Z 9 lit. a) (88, 'VKOUN', 628, 1, 'a'), (89, 'KOUN', 629, 10, 'n'), # Kostenübernahme § 26 Z 5 lit. b (90, 'TSTAAT', 639, 3, 'a'), # Tätigkeitsstaat (91, 'VTEPR', 642, 1, 'a'), (92, 'TEPR', 643, 10, 'n'), # Teuerungsprämie (§ 124b Z 408) (93, 'VMAPR', 653, 1, 'a'), (94, 'MAPR', 654, 10, 'n'), # Mitarbeiterprämie (§ 124b Z 478) (95, 'VZAKV', 664, 1, 'a'), (96, 'ZAKV', 665, 10, 'n'), # AG-Zuschuss freiw. KV (97, 'ANKV', 675, 1, 'a'), # (freiw.) gesetzlich krankenversichert (98, 'VMAGB', 676, 1, 'a'), (99, 'MAGB', 677, 10, 'n'), # Mitarbeitergewinnbeteiligung (100, 'RE100', 687, 314, 'a'), (101, 'REFN2', 1001, 30, 'a'), # Softwarehersteller-REFN (102, 'RE102', 1031, 70, 'a'), ] class L10nAtPayrollL17Line(models.Model): _name = 'l10n.at.payroll.l17.line' _description = 'L17 — Lohnnachweis je Dienstnehmer und LZP' _order = 'employee_name, date_from' l17_id = fields.Many2one( 'l10n.at.payroll.l17', required=True, ondelete='cascade', index=True) employee_id = fields.Many2one('hr.employee', required=True) employee_name = fields.Char(related='employee_id.name', readonly=True, store=True) date_from = fields.Date(string='LZP von', required=True) date_to = fields.Date(string='LZP bis', required=True) versicherungsnummer = fields.Char() kz350 = fields.Monetary(currency_field='currency_id') kz351 = fields.Monetary(currency_field='currency_id') kz352 = fields.Monetary(currency_field='currency_id') kz354 = fields.Monetary(currency_field='currency_id') kz357 = fields.Monetary(currency_field='currency_id') kz347 = fields.Monetary(currency_field='currency_id') hota = fields.Integer(string='Homeoffice-Tage') hopa = fields.Monetary(currency_field='currency_id') mueb = fields.Integer(string='Monate überwiegende Nachtarbeit') refn = fields.Char() currency_id = fields.Many2one( 'res.currency', related='l17_id.company_id.currency_id') def _l10n_at_pruefungen(self, fehler, warnungen): """Formale Prüfungen nach Prüfkatalog L17 (Stand 12/2025).""" self.ensure_one() lzp_tage = max(1, (self.date_to - self.date_from).days + 1) teilsumme = self.kz351 + self.kz352 + self.kz354 if self.kz350 + 0.02 < teilsumme: fehler.append( '%s (%s): KZ 350 < 351+352+354 (Prüfkatalog-Formel).' % (self.employee_name, self.date_from)) if self.kz357 > self.kz350 * 0.4 + 0.02: fehler.append( '%s (%s): KZ 357 > 40 %% von KZ 350 (Prüfkatalog).' % (self.employee_name, self.date_from)) if self.kz351 and self.kz347 > self.kz351 * 0.25 + 0.02: fehler.append( '%s (%s): KZ 347 > 25 %% von KZ 351 (Prüfkatalog).' % (self.employee_name, self.date_from)) if self.kz354 > 2400.0 * lzp_tage / 365.0 * 1.01 + 0.02: warnungen.append( '%s (%s): KZ 354 übersteigt den aliquotierten ' 'Prüfkatalog-Cap 2.400 € je Jahr (+1 %% Toleranz).' % (self.employee_name, self.date_from)) if self.hota > lzp_tage: fehler.append( '%s (%s): HOTA > LZP-Tage.' % ( self.employee_name, self.date_from)) if self.hopa > self.hota * 3.0 + 0.02: fehler.append( '%s (%s): HOPA > 3 € × HOTA.' % ( self.employee_name, self.date_from)) if self.hopa > 300.0 * lzp_tage / 365.0 * 1.01 + 0.02: warnungen.append( '%s (%s): HOPA übersteigt den aliquotierten ' 'Prüfkatalog-Cap 300 € je Jahr.' % ( self.employee_name, self.date_from)) class L10nAtPayrollL17(models.Model): _name = 'l10n.at.payroll.l17' _description = 'L17 — Lohnnachweis/Lohnbescheinigung (Finanz)' _inherit = ['mail.thread'] _order = 'jahr desc, id desc' company_id = fields.Many2one( 'res.company', required=True, default=lambda self: self.env.company) jahr = fields.Integer(required=True, string='Jahr') line_ids = fields.One2many( 'l10n.at.payroll.l17.line', 'l17_id', string='A1-Sätze') datei = fields.Binary(attachment=True, string='L17-Datei', copy=False) datei_name = fields.Char(copy=False) protokoll = fields.Text( string='Prüfprotokoll', help='Formale Prüfung nach Prüfkatalog L17 (Stand 12/2025) und ' 'dokumentierte Reste der KZ-Belegung.') state = fields.Selection( [('draft', 'Entwurf'), ('done', 'Erzeugt')], default='draft', required=True, tracking=True) _sql_constraints = [( 'l17_company_jahr_uniq', 'unique(company_id, jahr)', 'Für dieses Jahr existiert bereits eine L17-Deklaration.')] def _l10n_at_slips(self): self.ensure_one() return self.env['hr.payslip'].search([ ('company_id', '=', self.company_id.id), ('date_from', '>=', date(self.jahr, 1, 1)), ('date_from', '<=', date(self.jahr, 12, 31)), ('state', 'in', ('validated', 'paid')), ], order='date_from, id') def _l10n_at_hersteller(self): partner = self.company_id.partner_id return {'name': 'odoo-at-payroll', 'kfz': 'A', 'plz': partner.zip, 'ort': partner.city, 'strasse': partner.street} @api.model def _vorzeichen(self, betrag): if not betrag: return None return '+' if betrag > 0 else '-' def _l10n_at_kz_salden(self, slip): """(salden, hota) je Lohnzahlungszeitraum aus dem Lauf — Kennzahlen-Mapping siehe Modul-Doku (GP3-Flags, Muster `_kz_salden` der L16-Zeile).""" self.ensure_one() salden = dict.fromkeys( ('kz350', 'kz351', 'kz352', 'kz354', 'kz357', 'kz347', 'hopa'), 0.0) hota = 0 for line in slip.line_ids: rule = line.salary_rule_id if not line.total: continue if rule.l10n_at_bemessung == 'laufend': salden['kz350'] += line.total if line.code == 'SB_HOMEOFFICE': salden['hopa'] += line.total elif rule.l10n_at_bemessung == 'sonstig': salden['kz350'] += line.total if rule.l10n_at_s67_abs in ('abs3', 'abs6'): salden['kz352'] += line.total else: salden['kz351'] += line.total elif line.code == 'SVDN_SZ': salden['kz347'] += line.total elif line.code in ('SVDN_KV', 'SVDN_PV', 'SVDN_AV', 'SVDN_AK', 'SVDN_WF'): salden['kz357'] += line.total freibe = slip._l10n_at_freibetraege_68() salden['kz354'] = freibe['ue_std'] for eingabe in slip.input_line_ids: if eingabe.input_type_id.code == 'ATP_SB_HOMEOFFICE': hota += int(eingabe.quantity or 0) return salden, hota def _l10n_at_a1_satz(self, line, stnr, heute, jetzt): self.ensure_one() employee = line.employee_id version = employee.version_id satz = L10nAtEldaSatz( L10N_AT_ELDA_IDTEIL + L17_SATZ, 1100, bezeichnung='A1 %s %s' % (employee.name, line.date_from)) satz.setze('SART', 'A1') satz.setze('VSTR', '94') satz.setze('FSART', 'A') satz.setze('STNRL', stnr) satz.setze_datum('DTUE', heute, 'JJJJMMTT') satz.setze_zeit('ZTUE', jetzt) satz.setze('REFN', line.refn) satz.setze('FIND', 'J') satz.setze('BELZ', line.date_from.strftime('%d%m')) satz.setze('ENLZ', line.date_to.strftime('%d%m')) satz.setze('JALZ', self.jahr) satz.setze('PENS', 'N') satz.setze('AVSN', line.versicherungsnummer) satz.setze('ANAM', (employee.name or '')[:37]) satz.setze('AADR', (version.private_street or '')[:37] if version else '') satz.setze('ALKZ', (version.private_country_id.code if version and version.private_country_id else 'AUT')) satz.setze('APLZ', version.private_zip if version else '') satz.setze('AORT', (version.private_city or '')[:30] if version else '') for betrag, vfeld, bfeld, negativ in ( (line.kz350, 'V350', 'B350', False), (line.kz351, 'V351', 'B351', False), (line.kz352, 'V352', 'B352', True), (line.kz354, 'V354', 'B354', True), (line.kz357, 'V357', 'B357', False), (line.kz347, 'V347', 'B347', True)): if betrag < 0 and not negativ: raise UserError( '%s (%s): negativer Betrag in %s — das Vorzeichenfeld ' 'zulässigerweise "-" nur nach DM-ORG-Feldbeschreibung.' % (employee.name, line.date_from, vfeld)) satz.setze(vfeld, self._vorzeichen(betrag)) satz.setze(bfeld, abs(betrag) if betrag else 0, cent=True) if line.mueb: satz.setze('MUEB', line.mueb) if line.hota: satz.setze('HOTA', line.hota) if line.hopa: satz.setze('VHOPA', self._vorzeichen(line.hopa)) satz.setze('HOPA', abs(line.hopa), cent=True) satz.setze('WOBD', 'EMP-%d' % employee.id) satz.setze('REFN2', line.refn) return satz def action_erzeugen(self): """A1 je (Dienstnehmer, Lohnzahlungszeitraum) aus validierten Läufen; formale Prüfungen nach Prüfkatalog L17; Fehler brechen ab (UserError, DmfA-Muster), Warnungen ins Protokoll.""" for deklaration in self: fehler = [] warnungen = [] company = deklaration.company_id if not company.l10n_at_lohnsteuernummer: fehler.append( 'Lohnsteuernummer der Company fehlt ' '(l10n_at_lohnsteuernummer).') deklaration.line_ids.unlink() for slip in deklaration._l10n_at_slips(): version = slip.version_id or slip.employee_id.version_id if not (version and version.ssnid): fehler.append( 'Mitarbeiter %s ohne Versicherungsnummer ' '(hr.version.ssnid).' % slip.employee_id.name) continue if not slip.employee_id.birthday: fehler.append( 'Mitarbeiter %s ohne Geburtsdatum.' % slip.employee_id.name) continue salden, hota = deklaration._l10n_at_kz_salden(slip) refn = 'L17-%d-%d-%d-%s' % ( company.id, deklaration.jahr, slip.employee_id.id, slip.date_from.strftime('%Y%m%d')) line = deklaration.env['l10n.at.payroll.l17.line'].create({ 'l17_id': deklaration.id, 'employee_id': slip.employee_id.id, 'date_from': slip.date_from, 'date_to': slip.date_to, 'versicherungsnummer': version.ssnid, 'kz350': salden['kz350'], 'kz351': salden['kz351'], 'kz352': salden['kz352'], 'kz354': salden['kz354'], 'kz357': salden['kz357'], 'kz347': salden['kz347'], 'hota': hota, 'hopa': salden['hopa'], 'mueb': 0, 'refn': refn, }) line._l10n_at_pruefungen(fehler, warnungen) if deklaration.line_ids: for line in deklaration.line_ids: if line.kz352: warnungen.append( '%s: KZ 352 belegt — die SV-Zerlegung auf ' 'KZ 736 bleibt dokumentierter Rest (v1 = 0); ' 'Prüfkatalog-Grenze 40 %% × KZ 352 prüfen.' % line.employee_name) if fehler: deklaration.protokoll = 'FEHLER:\n%s\nWARNUNGEN:\n%s' % ( '\n'.join(fehler), '\n'.join(warnungen) or '—') raise UserError('L17-Lauf mit Fehlern abgebrochen:\n' + '\n'.join(fehler)) if deklaration.line_ids: heute = date.today() jetzt = datetime.now() partner = company.partner_id stnr = company.l10n_at_lohnsteuernummer informationssatz = L10nAtEldaSatz( L10N_AT_ELDA_IDTEIL + L16_INFORMATIONSSATZ, 1100, bezeichnung='L17-Informationssatz') informationssatz.setze('SART', 'I1') informationssatz.setze('VSTR', '94') informationssatz.setze('FSART', 'I') informationssatz.setze('CLEAR', 'RADAUS') informationssatz.setze('STNRL', stnr) informationssatz.setze('STNRA', stnr) informationssatz.setze('ARTD', 'LZ') informationssatz.setze_datum('DTUE', heute, 'JJJJMMTT') informationssatz.setze_zeit('ZTUE', jetzt) informationssatz.setze('STVE', '03') informationssatz.setze('ANAM', (partner.name or '')[:37]) informationssatz.setze('AADR', (partner.street or '')[:37]) informationssatz.setze('ALKZ', 'AUT') informationssatz.setze('APLZ', partner.zip) informationssatz.setze('AORT', (partner.city or '')[:30]) informationssatz.setze('JAHR', deklaration.jahr) informationssatz.setze('GESA', len(deklaration.line_ids)) informationssatz.setze('ANZA', len(deklaration.line_ids)) datensaetze = [informationssatz] + [ deklaration._l10n_at_a1_satz(line, stnr, heute, jetzt) for line in deklaration.line_ids] bestand = L10nAtEldaBestand( 'LF', '94', '28', hersteller=deklaration ._l10n_at_hersteller(), datensaetze=datensaetze) datei = L10nAtEldaDatei([bestand]) deklaration.write({ 'datei': base64.b64encode(datei.render()), 'datei_name': 'l17_%d_%s.txt' % ( deklaration.jahr, stnr), }) deklaration.protokoll = 'WARNUNGEN:\n%s' % ( '\n'.join(warnungen) or '—') deklaration.state = 'done' return True