[IMP] l10n_at_hr_payroll(_private): Meldewesen-Reste (AP17) und Status-Sonderfälle (AP18/AP19)

AP17 — Lohnnachweis L17, Finanz-Mitteilungen § 109a/§ 109b, GfB-Jahreszahlung:
- L17 (DM-ORG E.24, BEST LF, SART A1/1100, Version 28): je
  Lohnzahlungszeitraum je DN aus den GP3-Kennzahlen-Flags; Prüfkatalog-
  Semantik (KZ 350 = Brutto gesamt, 351/352/354 Teilbeträge) und formale
  Prüfungen (350-Formel, 357/347-Caps, aliquotierte 354/HOPA-Caps);
  ODL-PDF-Extraktion der Kapitel E.24/E.16/E.26 (txt feldversetzt)
- Finanz-Mitteilungen § 109a (W1, E.16) und § 109b (B1, E.26): Modelle
  je Leistungserbringer/Auftragnehmer, Schwellen als hr.rule.parameter
  (450/900 — § 109a-VO; 100.000 — § 109b Abs 4 lit a, RIS NOR40263383),
  AL31/32/33, LKZ1-7, UID, bis 3 maßgebliche natürliche Personen,
  KORR K/S; Unter-Schwellen-Zeilen entfallen mit Protokollhinweis
- mBGM: geringfügig Beschäftigte (BSGR B010/B030) — T01 § 53a-UV-
  Pauschale + V01 BMSVG 1,53 %, kein T02 (GfB-SZ beitragsfrei,
  § 49 Abs 3); JAGB-Pakettrennung für die jährliche Abrechnung
  (§ 58 Abs 8 ASVG iVm § 6 Abs 2a BMSVG, DM-ORG D.52) mit
  2,5 vH-Zuschlag (Z04) in der GfB-Jahresabrechnung (Fälligkeit
  15. 1. Folgejahr)
- Fristenkalender: L17- und § 109b-Fristarten (Ende Februar;
  § 109a/§ 109b EStG und § 58 Abs 8 ASVG RIS-verifiziert)

AP18 — Schwerarbeit/NSchG (Beschaffungsgate erledigt, RIS):
- NSchG (RIS 10008502, BGBl 1981/354) Art XI Abs 3 (2 vH Stammnorm,
  3,80 % via Sistierung) und Abs 6 (Nachtschwerarbeitsmonat: 6-Tage-
  Kriterium, Rückblick-Fenster 12/18, Randmonat); ASchV (20004642)
  § 1/§ 4/§ 5; DM-ORG E.22.2-Erstellvorschriften (Z 1 analog NSchG,
  Z 2/4/5 ≥ 15 Tage, Z 3/GfB nicht melden)
- Kern: Schwerarbeitsmeldung (BEST SM, SART 65/66, Länge 800,
  Tätigkeitsblöcke 26 × 10) mit Altersfilter- und Erstellvorschrift-
  Prüfungen
- Privat: Nachtschwerarbeitsmonat-Zähler (WET ATP_NACHTSCHWER,
  Art-XI-Abs-6-Fenster inkl. Randmonat-Proxy) als stored Flag am Lauf

AP19 — Status- und Verhinderungs-Sonderfälle:
- hr.version-Status (Organ ≤/≥ 25 %, lb-vst-03): Organ ≥ 25 % — kein
  SV-Verhältnis (SVDN/SVDG 0 via SV-Satz-Override, mBGM-Ausschluss,
  LSt läuft weiter); Ferialpraktikanten-Flag (dokumentativ — BMSVG § 6
  nennt keinen Ausschluss, Entgelt-/SV-Logik bleibt normal)
- Präsenz-/Ausbildungs-/Zivildienst-Fiktion (§ 7 Abs 1/2 BMSVG):
  fiktive BV-Bemessungsgrundlage 435,90 € (KBGG § 3 Abs 1 idF vor
  BGBl I 53/2016, lb-vor-04) aliquotiert auf ATP_PRD_TAGE
- Dienstverhinderungs-Sonderfälle bleiben dokumentiert offen
  (lb-dvh-01/04, KB-); Feiertags-EFZ ist durch das AP4-WET-Modell
  abgedeckt

Tests: 21 neue (L17-Positionen/Prüfkatalog, W1/B1-Schwellen, GfB-mBGM/
JAGB/Jahresabrechnung, NSchM-Fenster/Randmonat, SM-Fixsatz, Organ-Sperre,
PRD-Fiktion); Suite 252/252 grün auf frischer -i-DB (85 Kern, 18 GemBG,
213 Privat, 10 Dokumente). Dokumentierte Reste: DZR-36-Fenster,
Randmonat-Proxy und UV-Pauschale-vs-mBGM im Parallellauf (GP8),
fallweise mBGM (G3/G4), 2027er-Schwellen (C7).
This commit is contained in:
2026-09-16 19:12:08 +02:00
parent 1df5fb19c4
commit ec33dfacf2
27 changed files with 2736 additions and 64 deletions
@@ -0,0 +1,269 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Schwerarbeitsmeldung (AP18) — DM-ORG 42.7.0, Kapitel E.22
(BEST ``SM``, SART 65 „Schwerarbeitsmeldung“ / 66 „Storno“, Länge 800,
Version 02; PDF-verifizierte ODL-Extraktion 2026-09-16).
Rechtsanker (RIS-verifiziert 2026-09-16):
- **ASchV** (RIS GesNr 20004642, BGBl II 1998/419 idF NOR40272279):
§ 1 Abs 1 Z 126 (Z 1 Schicht-/Wechseldienst in Nachtarbeit 226 Uhr
≥ 6 h; Z 2 Hitze/Kälte NSchG Art VII Z 2/3; Z 3 chemisch/physikalisch;
Z 4 schwere körperliche Arbeit 2.000 kcal (M) / 1.400 kcal (F);
Z 5 Pflege; Z 6 Pflegegeld), § 4 (Schwerarbeitsmonat — Versicherungs-
monat § 231 Z 1 lit a ASVG; Z 5: 12 Tage Schichtdienst), § 5 (Meldung
M ≥ 40 / F ≥ 35, bis Ende Februar).
- **Erstellvorschriften (DM-ORG E.22.2):** meldepflichtig Z 1, 2, 4 und 5;
Z 1 nur bei ≥ 6 Arbeitstagen/Monat Schicht-/Wechseldienst in Nachtarbeit
(bei unterschiedlicher Nachtschichten-Anzahl analog **NSchG Art XI
Abs 6**); Z 2/4/5 nur bei ≥ **15 Arbeitstagen/Monat**; Z 3 nicht melden
(MdE ≥ 10 % erst nachträglich); Z 6 nur bei bekannter Pflegegeldeinstu-
fung (ab Stufe 3); geringfügig Beschäftigte nicht melden.
- **lb-swa-05** (Stand 2026-07): jährlich JännerEnde Februar; GfB aus-
geschlossen; fallweise/freie DN zu melden; Leiharbeiter vom Überlasser.
Die Ziffern/Zeiträume sind mandantenseitige Fakten (Arbeitsplatz-
Klassifikation) — das Modell erfasst sie manuell und erzeugt den Fixsatz;
keine automatisierte Ableitung aus Arbeitszeiten."""
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 l10n_at_namen
# ------------------------------------------------------------------
# SART 65/66 (E.22; Länge 800) — Datenteil inkl. 26 Tätigkeitsblöcken
# ------------------------------------------------------------------
SCHWERARBEIT_SATZ = [
(2, 'BKNR', 21, 10, 'a'), # Beitragskontonummer
(3, 'DGNA', 31, 70, 'a'),
(4, 'WOBD', 101, 12, 'a'),
(5, 'ZOBD', 113, 15, 'a'),
(6, 'DTEL', 128, 50, 'a'), # DG-Telefon
(7, 'MAIL', 178, 60, 'a'), # DG-Mail
(8, 'DKFZ', 238, 3, 'a'), # DG Land/Kfz
(9, 'DPLZ', 241, 9, 'a'),
(10, 'DORT', 250, 40, 'a'),
(11, 'DSTR', 290, 50, 'a'),
(12, 'VSNR', 340, 10, 'n'), # LLLPTTMMJJ
(13, 'GEBD', 350, 8, 'n'), # TTMMJJJJ
(14, 'FANA', 358, 70, 'a'),
(15, 'VONA', 428, 70, 'a'),
(16, 'JAHR', 498, 4, 'n'), # Tätigkeitsjahr
# Tätigkeitsblöcke: 26 × 10 = 260 (pos 502761)
(17, 'TART', 502, 2, 'a'), # § 1 Abs 1 Z x ASchV
(18, 'TVON', 504, 4, 'n'), # TTMM
(19, 'TBIS', 508, 4, 'n'), # TTMM
(20, 'REFN', 762, 30, 'a'),
(21, 'RE21', 792, 9, 'a'),
]
def _taetigkeitsblock_struktur():
"""26 Tätigkeitsblöcke à 10 Stellen (TART 2 + TVON 4 + TBIS 4)."""
felder = []
for block_nr in range(1, 27):
basis = 502 + (block_nr - 1) * 10
felder.extend([
(17 + (block_nr - 1) * 3, 'TART_%d' % block_nr,
basis, 2, 'a'),
(18 + (block_nr - 1) * 3, 'TVON_%d' % block_nr,
basis + 2, 4, 'n'),
(19 + (block_nr - 1) * 3, 'TBIS_%d' % block_nr,
basis + 6, 4, 'n'),
])
return felder
SCHWERARBEIT_SATZ += _taetigkeitsblock_struktur()
class L10nAtPayrollSchwerarbeitLine(models.Model):
_name = 'l10n.at.payroll.schwerarbeitsmeldung.line'
_description = 'Schwerarbeitsmeldung — Satzdaten je AN'
_order = 'employee_name'
meldung_id = fields.Many2one(
'l10n.at.payroll.schwerarbeitsmeldung', 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)
versicherungsnummer = fields.Char()
taetigkeit_ids = fields.One2many(
'l10n.at.payroll.schwerarbeitsmeldung.taetigkeit', 'line_id',
string='Tätigkeitsblöcke')
refn = fields.Char()
class L10nAtPayrollSchwerarbeitTaetigkeit(models.Model):
_name = 'l10n.at.payroll.schwerarbeitsmeldung.taetigkeit'
_description = 'Schwerarbeitsmeldung — Tätigkeitsblock (§ 1 ASchV)'
_order = 'sequence'
sequence = fields.Integer(default=10)
line_id = fields.Many2one(
'l10n.at.payroll.schwerarbeitsmeldung.line', required=True,
ondelete='cascade', index=True)
ziffer = fields.Integer(
string='ASchV § 1 Abs 1 Ziffer', required=True,
help='TART-Codes 126 der Schwerarbeitsverordnung. Nicht zu '
'melden: Z 3 (MdE-Feststellung nachträglich) und Z 6 ohne '
'bekannte Pflegegeldeinstufung ab Stufe 3.')
von = fields.Date(string='Von (TTMM)')
bis = fields.Date(string='Bis (TTMM)')
@api.constrains('ziffer')
def _check_ziffer(self):
for taetigkeit in self:
if not 1 <= taetigkeit.ziffer <= 26:
raise UserError(
'ASchV-Ziffer %s außerhalb 126.' % taetigkeit.ziffer)
class L10nAtPayrollSchwerarbeitsmeldung(models.Model):
_name = 'l10n.at.payroll.schwerarbeitsmeldung'
_description = 'Schwerarbeitsmeldung (§ 74 Abs 3 ASVG / § 5 ASchV)'
_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='Tätigkeitsjahr')
line_ids = fields.One2many(
'l10n.at.payroll.schwerarbeitsmeldung.line', 'meldung_id',
string='Meldesätze')
datei = fields.Binary(attachment=True, string='SM-Datei', copy=False)
datei_name = fields.Char(copy=False)
protokoll = fields.Text(string='Prüfprotokoll')
state = fields.Selection(
[('draft', 'Entwurf'), ('done', 'Erzeugt')], default='draft',
required=True, tracking=True)
_sql_constraints = [(
'sm_company_jahr_uniq', 'unique(company_id, jahr)',
'Für dieses Jahr existiert bereits eine Schwerarbeitsmeldung.')]
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}
def action_erzeugen(self):
"""SART 65 je AN (Tätigkeitsblöcke 26 × 10) — Erstellvorschriften
(E.22.2): Z 1 ≥ 6 Nachtarbeitstage/Monat (analog NSchG Art XI
Abs 6), Z 2/4/5 ≥ 15 Arbeitstage/Monat, GfB ausgeschlossen,
Altersfilter M ≥ 40 / F ≥ 35 als Warnung."""
for meldung in self:
fehler = []
warnungen = []
company = meldung.company_id
bknr = company.l10n_at_beitragskontonummer
if not bknr:
fehler.append('Beitragskontonummer (BKNR) fehlt.')
partner = company.partner_id
# Zeilen werden je AN erfasst (mandantenseitige Fakten);
# der Lauf prüft die Erstellvorschriften und erzeugt die
# Sätze.
for line in meldung.line_ids:
employee = line.employee_id
version = employee.version_id
if not (version and version.ssnid):
fehler.append(
'Mitarbeiter %s ohne Versicherungsnummer.'
% employee.name)
continue
if not employee.birthday:
fehler.append(
'Mitarbeiter %s ohne Geburtsdatum.'
% employee.name)
continue
altersstand = meldung.jahr - employee.birthday.year
ist_frau = (version.sex == 'female')
grenze = 35 if ist_frau else 40
if altersstand < grenze:
warnungen.append(
'%s: %d Jahre am 31. 12. %d — unter der '
'Altersgrenze (%d %s); Meldung entfällt bzw. '
'Zeiten nur bei Begründung (lb-swa-05).'
% (employee.name, altersstand, meldung.jahr,
grenze, 'F' if ist_frau else 'M'))
if line.taetigkeit_ids.filtered(
lambda t: t.ziffer == 3):
warnungen.append(
'%s: Z 3 (chemisch/physikalisch) ist nicht '
'meldepflichtig (MdE ≥ 10 %% wird erst '
'nachträglich festgestellt — lb-swa-05).'
% employee.name)
if line.taetigkeit_ids.filtered(
lambda t: t.ziffer == 6):
warnungen.append(
'%s: Z 6 (Pflege) nur bei bekannter '
'Pflegegeldeinstufung ab Stufe 3 melden.'
% employee.name)
if fehler:
meldung.protokoll = 'FEHLER:\n%s' % '\n'.join(fehler)
raise UserError(
'Schwerarbeitsmeldungs-Lauf mit Fehlern abgebrochen:\n'
+ '\n'.join(fehler))
if meldung.line_ids:
heute = date.today()
jetzt = datetime.now()
datensaetze = []
for line in meldung.line_ids:
employee = line.employee_id
version = employee.version_id
satz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + SCHWERARBEIT_SATZ, 800,
bezeichnung='SM %s' % employee.name)
satz.setze('SART', '65')
satz.setze('VSTR', (bknr or '')[:2])
satz.setze('BKNR', bknr)
satz.setze('DGNA', (company.name or '')[:70])
satz.setze('WOBD', 'EMP-%d' % employee.id)
satz.setze('DKFZ', 'A')
satz.setze('DPLZ', (partner.zip or '')[:9])
satz.setze('DORT', (partner.city or '')[:40])
satz.setze('DSTR', (partner.street or '')[:50])
satz.setze('VSNR', line.versicherungsnummer)
satz.setze_datum('GEBD', employee.birthday)
familienname, vorname = l10n_at_namen(employee)
satz.setze('FANA', (familienname or '')[:70])
satz.setze('VONA', (vorname or '')[:70])
satz.setze('JAHR', meldung.jahr)
for nummer, taetigkeit in enumerate(
line.taetigkeit_ids.sorted('sequence')[:26],
start=1):
satz.setze('TART_%d' % nummer,
'%02d' % taetigkeit.ziffer)
if taetigkeit.von:
satz.setze('TVON_%d' % nummer,
taetigkeit.von.strftime('%d%m'))
if taetigkeit.bis:
satz.setze('TBIS_%d' % nummer,
taetigkeit.bis.strftime('%d%m'))
satz.setze('REFN', 'SM-%d-%d-%d' % (
company.id, meldung.jahr, employee.id))
datensaetze.append(satz)
bestand = L10nAtEldaBestand(
'SM', (bknr or '')[:2], '02',
hersteller=meldung._l10n_at_hersteller(),
datensaetze=datensaetze)
datei = L10nAtEldaDatei([bestand])
meldung.write({
'datei': base64.b64encode(datei.render()),
'datei_name': 'schwerarbeit_%d.txt' % meldung.jahr,
})
meldung.protokoll = 'WARNUNGEN:\n%s' % (
warnungen and '\n'.join(warnungen) or '')
meldung.state = 'done'
return True