Files
odoo-at-payroll/addons/l10n_at_hr_payroll/models/schwerarbeitsmeldung.py
T
fegger ec33dfacf2 [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).
2026-09-16 19:12:08 +02:00

269 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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