mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 08:52:45 +00:00
[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:
@@ -61,4 +61,11 @@
|
||||
<field name="name">Reisekosten — Nettoausgleich Überhang</field>
|
||||
<field name="code">ATP_REISE_NETTOAUSGLEICH</field>
|
||||
</record>
|
||||
<!-- AP19: Präsenz-/Ausbildungs-/Zivildienst-Tage (§ 7 Abs 1/2
|
||||
BMSVG) für die fiktive BV-Bemessungsgrundlage (at_bv_fiktiv_kbg). -->
|
||||
<record id="input_type_atp_prd_tage" model="hr.payslip.input.type">
|
||||
<field name="name">Präsenz-/Ausbildungs-/Zivildienst — Tage</field>
|
||||
<field name="code">ATP_PRD_TAGE</field>
|
||||
<field name="is_quantity" eval="True"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -24,4 +24,17 @@
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">{'B001': {'laufend': 39.05, 'sz': 37.55}, 'B002': {'laufend': 39.05, 'sz': 37.55}}</field>
|
||||
</record>
|
||||
<!-- AP19: fiktive BG Präsenz-/Ausbildungs-/Zivildienst —
|
||||
KBGG § 3 Abs 1 idF vor BGBl I 53/2016: 2026: 435,90 €
|
||||
(lb-vor-04, Stand 2026-08); BMSVG § 7 Abs 1/2. -->
|
||||
<record id="hr_rule_parameter_at_bv_fiktiv_kbg" model="hr.rule.parameter">
|
||||
<field name="name">BMSVG § 7 — fiktive BG KBGG-Basis</field>
|
||||
<field name="code">at_bv_fiktiv_kbg</field>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
</record>
|
||||
<record id="hr_rule_parameter_value_at_bv_fiktiv_kbg" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="hr_rule_parameter_at_bv_fiktiv_kbg"/>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">435.90</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -22,6 +22,17 @@
|
||||
<field name="l10n_at_nacht_sv" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- AP18: Nachtschwerarbeit-Tage (NSchG Art VII iVm Art XI Abs 6; -->
|
||||
<!-- mandantenseitige Katalog-Klassifikation ASchV/NSchG — Zähler- -->
|
||||
<!-- grundlage für den Nachtschwerarbeitsmonat) -->
|
||||
<record id="wett_nachtschwer" model="hr.work.entry.type">
|
||||
<field name="name">Nachtschwerarbeit (Tag)</field>
|
||||
<field name="color">24</field>
|
||||
<field name="display_code">NSW</field>
|
||||
<field name="code">ATP_NACHTSCHWER</field>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
</record>
|
||||
|
||||
<!-- Überstunden -->
|
||||
<record id="wett_ue_std" model="hr.work.entry.type">
|
||||
<field name="name">Überstunde Tag (50 %)</field>
|
||||
|
||||
@@ -14,6 +14,7 @@ from . import buchungsbeleg
|
||||
from . import wien_dag
|
||||
from . import efzg
|
||||
from . import mutterschutz
|
||||
from . import nachtschwerarbeit
|
||||
from . import karenz
|
||||
from . import aeb
|
||||
from . import pfaendung
|
||||
|
||||
@@ -222,9 +222,11 @@ class HrPayslip(models.Model):
|
||||
# AP4: fiktive BG während des Krankengeldbezugs (§ 7 Abs 3
|
||||
# BMSVG) und des Wochengeldbezugs (§ 7 Abs 4, AP7) — nur
|
||||
# in beitragspflichtigen Läufen (Erstfenster-Teilmonate:
|
||||
# dokumentierter Randfall).
|
||||
# dokumentierter Randfall). AP18/AP19: Präsenz-/Ausbildungs-
|
||||
# und Zivildienst-Fiktion (§ 7 Abs 1/2, ATP_PRD_TAGE).
|
||||
return basis + self._at_bv_fiktiv_krankengeld() \
|
||||
+ self._at_bv_fiktiv_wochengeld()
|
||||
+ self._at_bv_fiktiv_wochengeld() \
|
||||
+ self._at_bv_fiktiv_prasenzenzdienst()
|
||||
if pflichtig_ab > self.date_to:
|
||||
# LZP liegt zur Gänze im beitragsfreien Erstfenster.
|
||||
return 0.0
|
||||
@@ -235,6 +237,33 @@ class HrPayslip(models.Model):
|
||||
return 0.0
|
||||
return basis * tage / max(self._l10n_at_lohnsteuertage(), 1)
|
||||
|
||||
def _at_bv_fiktiv_prasenzenzdienst(self):
|
||||
"""Fiktive BV-Grundlage während des Präsenz-/Ausbildungs-
|
||||
und Zivildienstes (§ 7 Abs 1/2 BMSVG; RIS-Verifikation lokal
|
||||
BMSVG-Volltext 2025-07, lb-vor-04): 1,53 % der fiktiven BG in
|
||||
Höhe des KBGG § 3 Abs 1 idF vor BGBl I 53/2016 — 2026: 435,90 €
|
||||
(Parameter at_bv_fiktiv_kbg, Stand lb-vor-04) — aliquot auf
|
||||
die Dienst-Tage je Kalendertage des LZP. ⚠ >12 Monate
|
||||
Zeitsoldat/Auslandseinsatz-/Ausbildungsdienst: AG-Beitrag
|
||||
entfällt (Bund zahlt) — mandantenseitig berücksichtigen,
|
||||
Eingabe nicht setzen."""
|
||||
self.ensure_one()
|
||||
tage = 0
|
||||
for eingabe in self.input_line_ids:
|
||||
if eingabe.input_type_id.code == 'ATP_PRD_TAGE' \
|
||||
and eingabe.amount:
|
||||
tage += int(eingabe.amount)
|
||||
if not tage:
|
||||
return 0.0
|
||||
kbg = float(self.env['hr.rule.parameter']
|
||||
._get_parameter_from_code(
|
||||
'at_bv_fiktiv_kbg', self.date_from,
|
||||
raise_if_not_found=False) or 0.0)
|
||||
if not kbg:
|
||||
return 0.0
|
||||
monatstage = (self.date_to - self.date_from).days + 1
|
||||
return kbg * min(tage, monatstage) / float(monatstage)
|
||||
|
||||
def _at_bv_basis_sonst(self, result_rules=None):
|
||||
"""BV-Grundlage sonstiger Bezüge (Sonderzahlungen & Co.):
|
||||
ungecappt (kein HBG-/Jahresdeckel, § 6 Abs 5) und ohne
|
||||
|
||||
@@ -134,6 +134,30 @@ class HrVersion(models.Model):
|
||||
version.erfahrungsstufe = \
|
||||
version.l10n_at_vwgj_stufe_vorschlag
|
||||
|
||||
l10n_at_status = fields.Selection(
|
||||
[('an', 'Arbeitnehmer'),
|
||||
('organ_minder', 'Organ ≤ 25 %% — SV-pflichtig'),
|
||||
('organ_mehr', 'Organ ≥ 25 %% — kein SV-Verhältnis')],
|
||||
string='AT Status (Organ/Geschäftsführung)',
|
||||
default='an', required=True,
|
||||
groups="hr_payroll.group_hr_payroll_user",
|
||||
help='AP19 (lb-vst-03, lb-gsf-02/03/05): vertragsrechtlich freie '
|
||||
'Dienstverhältnisse von Organnen. Organ ≥ 25 % Beteiligung: '
|
||||
'kein SV-Verhältnis (§ 4 Abs 2 ASVG-Ausnahme — kein SVDN/SVDG, '
|
||||
'kein mBGM, LSt normal); Organ ≤ 25 %: pflichtversichert wie '
|
||||
'Dienstnehmer (§ 4 Abs 2 dritter Satz ASVG). '
|
||||
'Mandanten-Entscheid je Fall; Rechtsberatung vor Umstellung.')
|
||||
l10n_at_ferialpraktikant = fields.Boolean(
|
||||
string='AT Ferialpraktikant/Praktikum',
|
||||
groups="hr_payroll.group_hr_payroll_user",
|
||||
help='AP19 (lb-pra-01…04): Ferialpraktikum/Volontariat. '
|
||||
'SV/LSt laufen über die normale Entgelt-Logik (§ 4 Abs 2 '
|
||||
'ASVG: unter GFG nur UV; Volontariat ohne Taschengeld: '
|
||||
'UV-Teilversicherung § 8 Abs 1 Z 3 lit c — Meldewesen, '
|
||||
'nicht Entgeltrechnung). BMSVG: § 6 nennt keinen '
|
||||
'Ferialpraktikanten-Ausschluss — Beitrag wie sonstig '
|
||||
'(Erstfenster).')
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
@@ -300,6 +300,47 @@ class L10nAtPayrollMbgm(models.Model):
|
||||
positionen.append(('V01', 1.53, bv_basis * 0.0153, 'BV'))
|
||||
return positionen
|
||||
|
||||
def _l10n_at_gfb_positionen(self, slip):
|
||||
"""AP17: Verrechnungspositionen für geringfügig Beschäftigte
|
||||
(BSGR B010/B030): T01 = § 53a-UV-Pauschalbeitrag (Engine:
|
||||
``_l10n_at_svdg_uv_geringfuegig`` — 2026 6,50 €/Monat, abgeleitet
|
||||
aus aBG 591,29 × 1,1 %; § 51 Abs 2 ASVG), V01 = BMSVG-Beitrag
|
||||
1,53 % (Abfertigungsbeitrag § 5 Abs 2 BMSVG, Engine-Position
|
||||
BV). Keine AV-/IE-Anteile, keine Abschläge (A01–A03/A09/A10/
|
||||
A15), kein T02 — GfB-SZ sind beitragsfrei (§ 49 Abs 3;
|
||||
lb-bes-06)."""
|
||||
self.ensure_one()
|
||||
uv_pauschale = slip._l10n_at_svdg_uv_geringfuegig()
|
||||
positionen = []
|
||||
if uv_pauschale:
|
||||
positionen.append(('T01', 0.0, uv_pauschale, 'AB'))
|
||||
bv_basis = (slip._at_bv_basis_laufen()
|
||||
+ slip._at_bv_basis_sonst())
|
||||
if bv_basis > 0:
|
||||
positionen.append(('V01', 1.53, bv_basis * 0.0153, 'BV'))
|
||||
return positionen
|
||||
|
||||
def _l10n_at_bv_jahresabrechnung_gfb(self, jahr):
|
||||
"""Jährliche Abrechnung GfB (§ 58 Abs 8 ASVG iVm § 6 Abs 2a
|
||||
BMSVG): Summe der JAGB-mBGM-Beiträge des Kalenderjahres und
|
||||
der 2,5 vH-Zuschlag (Z04 „BV-Zuschlag bei jährlicher Zahlung“,
|
||||
D.60) — Fälligkeit bis 15. Jänner des Folgejahres."""
|
||||
self.ensure_one()
|
||||
deklarationen = self.search([
|
||||
('company_id', '=', self.company_id.id),
|
||||
('beitragsmonat', '>=', date(jahr, 1, 1)),
|
||||
('beitragsmonat', '<=', date(jahr, 12, 31)),
|
||||
('art', '=', 'MB'),
|
||||
('state', '=', 'done'),
|
||||
])
|
||||
summe = sum(
|
||||
line.vsum
|
||||
for deklaration in deklarationen
|
||||
for line in deklaration.line_ids.filtered('jagb'))
|
||||
zuschlag = round(summe * 0.025, 2)
|
||||
return {'summe': summe, 'zuschlag': zuschlag,
|
||||
'gesamt': round(summe + zuschlag, 2)}
|
||||
|
||||
def action_erzeugen(self):
|
||||
for deklaration in self:
|
||||
fehler = []
|
||||
@@ -310,9 +351,17 @@ class L10nAtPayrollMbgm(models.Model):
|
||||
fehler.append('Beitragskontonummer (BKNR) fehlt.')
|
||||
vstr = (bknr or '')[:2]
|
||||
deklaration.line_ids.unlink()
|
||||
datensaetze = []
|
||||
summe_gesamt = 0
|
||||
anzahl = 0
|
||||
# AP17: mBGM-Pakete — 'normal' (JAGB 'N') und — bei
|
||||
# jährlicher Abrechnung GfB (§ 58 Abs 8 ASVG iVm § 6
|
||||
# Abs 2a BMSVG) — ein separates JAGB-Paket (E.32.2.2.1/D.52)
|
||||
# für die GfB-Beitragsgrundlagen je Kalendermonat.
|
||||
paket_gruppen = {
|
||||
'normal': {'saetze': [], 'summe': 0.0, 'anzahl': 0},
|
||||
'jagb': {'saetze': [], 'summe': 0.0, 'anzahl': 0},
|
||||
}
|
||||
jagb_flag = company.l10n_at_gfb_jaehrliche_abrechnung
|
||||
if deklaration.art == 'ST':
|
||||
jagb_flag = False
|
||||
if deklaration.art != 'FZ':
|
||||
slips = deklaration._l10n_at_slips()
|
||||
nach_an = {}
|
||||
@@ -325,6 +374,13 @@ class L10nAtPayrollMbgm(models.Model):
|
||||
'Mitarbeiter %s ohne Versicherungsnummer.'
|
||||
% employee.name)
|
||||
continue
|
||||
if getattr(version, 'l10n_at_status', 'an') \
|
||||
== 'organ_mehr':
|
||||
warnungen.append(
|
||||
'Mitarbeiter %s: Organ ≥ 25 %% (kein SV-'
|
||||
'Verhältnis, AP19) — keine mBGM; LSt/Lohnzettel '
|
||||
'unverändert.' % employee.name)
|
||||
continue
|
||||
if len(employee_slips) > 1:
|
||||
fehler.append(
|
||||
'Mitarbeiter %s mit mehreren Läufen im '
|
||||
@@ -356,7 +412,8 @@ class L10nAtPayrollMbgm(models.Model):
|
||||
storno_satz.setze('VSNR', version.ssnid)
|
||||
storno_satz.setze('VSUM', abs(original.vsum),
|
||||
cent=True)
|
||||
datensaetze.append(storno_satz)
|
||||
paket_gruppen['normal']['saetze'].append(
|
||||
storno_satz)
|
||||
deklaration.env['l10n.at.payroll.mbgm.line']\
|
||||
.create({
|
||||
'mbgm_id': deklaration.id,
|
||||
@@ -366,43 +423,63 @@ class L10nAtPayrollMbgm(models.Model):
|
||||
'refw': refw,
|
||||
'vsum': original.vsum,
|
||||
})
|
||||
summe_gesamt += original.vsum
|
||||
anzahl += 1
|
||||
paket_gruppen['normal']['summe'] += original.vsum
|
||||
paket_gruppen['normal']['anzahl'] += 1
|
||||
continue
|
||||
bsgr = deklaration._l10n_at_bsgr(version, slip)
|
||||
tarif = deklaration._l10n_at_tarif(bsgr, version)
|
||||
if not tarif:
|
||||
if slip.l10n_at_geringfuegig:
|
||||
# GfB (AP17): eigenes Beitragsset — § 53a-UV-
|
||||
# Pauschale (T01) + BMSVG 1,53 % (V01), keine
|
||||
# AV-/IE-/Abschlagspositionen, kein T02 (GfB-SZ
|
||||
# beitragsfrei, § 49 Abs 3).
|
||||
positionen = deklaration._l10n_at_gfb_positionen(
|
||||
slip)
|
||||
if not positionen:
|
||||
fehler.append(
|
||||
'Mitarbeiter %s: GfB-Beitragssätze fehlen '
|
||||
'(§-53a-Parameter am Beitragsmonat).'
|
||||
% employee.name)
|
||||
continue
|
||||
else:
|
||||
tarif = deklaration._l10n_at_tarif(bsgr, version)
|
||||
if not tarif:
|
||||
fehler.append(
|
||||
'Mitarbeiter %s: kein Tarifsatz für BSGR '
|
||||
'%s (weder TASY-Katalog noch Parameter '
|
||||
'at_mbgm_tarif am Beitragsmonat).'
|
||||
% (employee.name, bsgr))
|
||||
continue
|
||||
positionen = deklaration._l10n_at_positionen(
|
||||
slip, tarif)
|
||||
if version.l10n_at_fallweise:
|
||||
fehler.append(
|
||||
'Mitarbeiter %s: kein Tarifsatz für BSGR '
|
||||
'%s (weder TASY-Katalog noch Parameter '
|
||||
'at_mbgm_tarif am Beitragsmonat).'
|
||||
'Mitarbeiter %s: fallweise mBGM (G3/G4 mit '
|
||||
'tagesgenauen Tarifblöcken T2/T5) ist ein '
|
||||
'dokumentierter Rest (BSGR %s).'
|
||||
% (employee.name, bsgr))
|
||||
continue
|
||||
if slip.l10n_at_geringfuegig \
|
||||
or version.l10n_at_fallweise:
|
||||
fehler.append(
|
||||
'Mitarbeiter %s: geringfügige/fallweise '
|
||||
'Beschäftigung in der mBGM ist ein '
|
||||
'dokumentierter Rest (BSGR %s, Tarifsystem '
|
||||
'verifizieren).' % (employee.name, bsgr))
|
||||
continue
|
||||
positionen = deklaration._l10n_at_positionen(
|
||||
slip, tarif)
|
||||
vsum = sum(rsum for _c, _s, rsum, _b in positionen)
|
||||
# Konsistenz gegen die GP4-Payslip-Summen.
|
||||
ist = (slip._l10n_at_svdn_gesamt()
|
||||
+ slip._l10n_at_svdg_gesamt()
|
||||
+ slip._l10n_at_svdn_sz()
|
||||
+ sum(
|
||||
slip._l10n_at_svdg_sz(k)
|
||||
for k in ('kv', 'pv', 'av', 'uv', 'iesg'))
|
||||
+ slip._at_bv_beitrag())
|
||||
if slip.l10n_at_geringfuegig:
|
||||
ist = (slip._l10n_at_svdg_uv_geringfuegig()
|
||||
+ slip._at_bv_beitrag())
|
||||
else:
|
||||
ist = (slip._l10n_at_svdn_gesamt()
|
||||
+ slip._l10n_at_svdg_gesamt()
|
||||
+ slip._l10n_at_svdn_sz()
|
||||
+ sum(
|
||||
slip._l10n_at_svdg_sz(k)
|
||||
for k in ('kv', 'pv', 'av', 'uv', 'iesg'))
|
||||
+ slip._at_bv_beitrag())
|
||||
if abs(vsum - ist) > 0.10:
|
||||
warnungen.append(
|
||||
'Mitarbeiter %s: mBGM-VSUM %.2f weicht um '
|
||||
'%.2f von den GP4-Beitragssummen ab '
|
||||
'(Tarif-/Rundungsdifferenz prüfen).'
|
||||
% (employee.name, vsum, vsum - ist))
|
||||
gruppe = (
|
||||
'jagb' if (slip.l10n_at_geringfuegig and jagb_flag)
|
||||
else 'normal')
|
||||
deklaration.env['l10n.at.payroll.mbgm.line'].create({
|
||||
'mbgm_id': deklaration.id,
|
||||
'employee_id': employee.id,
|
||||
@@ -413,46 +490,82 @@ class L10nAtPayrollMbgm(models.Model):
|
||||
deklaration.beitragsmonat.strftime('%Y%m'),
|
||||
employee.id),
|
||||
'vsum': vsum,
|
||||
'jagb': gruppe == 'jagb',
|
||||
})
|
||||
datensaetze.extend(
|
||||
paket_gruppen[gruppe]['saetze'].extend(
|
||||
deklaration._l10n_at_mbgm_datensaetze(
|
||||
employee, version, bsgr, positionen, vsum))
|
||||
summe_gesamt += vsum
|
||||
anzahl += 1
|
||||
paket = L10nAtEldaSatz(
|
||||
L10N_AT_ELDA_IDTEIL + MBGM_PAKET, 326,
|
||||
bezeichnung='mBGM-Paket')
|
||||
paket.setze('SART', 'PS')
|
||||
paket.setze('VSTR', vstr)
|
||||
paket.setze('REFP', 'MBGM-%d-%s' % (
|
||||
company.id,
|
||||
deklaration.beitragsmonat.strftime('%Y%m')))
|
||||
paket.setze('BKNR', bknr)
|
||||
paket.setze('DGNA', company.name[:70])
|
||||
paket.setze('JAGB', 'N')
|
||||
paket.setze('BZRM', deklaration.beitragsmonat.strftime(
|
||||
'%m%Y'))
|
||||
paket.setze('GSVZ', '+' if summe_gesamt >= 0 else '-')
|
||||
paket.setze('GSUM', abs(summe_gesamt), cent=True)
|
||||
paket.setze('ANZM', anzahl)
|
||||
datensaetze.insert(0, paket)
|
||||
paket_gruppen[gruppe]['summe'] += vsum
|
||||
paket_gruppen[gruppe]['anzahl'] += 1
|
||||
datensaetze = []
|
||||
for schluessel in ('normal', 'jagb'):
|
||||
gruppe = paket_gruppen[schluessel]
|
||||
if not gruppe['anzahl'] and not (
|
||||
deklaration.art == 'FZ'
|
||||
and schluessel == 'normal'):
|
||||
continue
|
||||
paket = L10nAtEldaSatz(
|
||||
L10N_AT_ELDA_IDTEIL + MBGM_PAKET, 326,
|
||||
bezeichnung='mBGM-Paket %s' % schluessel)
|
||||
paket.setze('SART', 'PS')
|
||||
paket.setze('VSTR', vstr)
|
||||
paket.setze('REFP', 'MBGM-%d-%s%s' % (
|
||||
company.id,
|
||||
deklaration.beitragsmonat.strftime('%Y%m'),
|
||||
'-JAGB' if schluessel == 'jagb' else ''))
|
||||
paket.setze('BKNR', bknr)
|
||||
paket.setze('DGNA', company.name[:70])
|
||||
paket.setze('JAGB', 'J' if schluessel == 'jagb' else 'N')
|
||||
paket.setze('BZRM', deklaration.beitragsmonat.strftime(
|
||||
'%m%Y'))
|
||||
paket.setze('GSVZ',
|
||||
'+' if gruppe['summe'] >= 0 else '-')
|
||||
paket.setze('GSUM', abs(gruppe['summe']), cent=True)
|
||||
paket.setze('ANZM', gruppe['anzahl'])
|
||||
datensaetze.append(paket)
|
||||
datensaetze.extend(gruppe['saetze'])
|
||||
if jagb_flag and paket_gruppen['jagb']['summe']:
|
||||
warnungen.append(
|
||||
'JAGB-Paket: jährliche Abrechnung der GfB-Beiträge '
|
||||
'(§ 58 Abs 8 ASVG iVm § 6 Abs 2a BMSVG) — Fälligkeit '
|
||||
'bis 15. Jänner des Folgejahres; der jährliche BV-'
|
||||
'Beitragsbetrag ist um 2,5 %% Zuschlag zu erhöhen '
|
||||
'(Summe mBGM-Jahr: siehe Protokoll der '
|
||||
'Dezember-Abrechnung).')
|
||||
if jagb_flag and deklaration.beitragsmonat.month == 12 \
|
||||
and paket_gruppen['jagb']['summe']:
|
||||
jahresabrechnung = deklaration\
|
||||
._l10n_at_bv_jahresabrechnung_gfb(
|
||||
deklaration.beitragsmonat.year)
|
||||
warnungen.append(
|
||||
'GfB-Jahresabrechnung %d: Beiträge %.2f € + '
|
||||
'2,5 %% Zuschlag %.2f € (Z04) = %.2f €, fällig '
|
||||
'bis 15. 1. %d.' % (
|
||||
deklaration.beitragsmonat.year,
|
||||
jahresabrechnung['summe'],
|
||||
jahresabrechnung['zuschlag'],
|
||||
jahresabrechnung['gesamt'],
|
||||
deklaration.beitragsmonat.year + 1))
|
||||
if fehler:
|
||||
deklaration.protokoll = 'FEHLER:\n%s\nWARNUNGEN:\n%s' % (
|
||||
'\n'.join(fehler), '\n'.join(warnungen) or '—')
|
||||
raise UserError('mBGM-Lauf mit Fehlern abgebrochen:\n'
|
||||
+ '\n'.join(fehler))
|
||||
bestand = L10nAtEldaBestand(
|
||||
'MB', vstr, '02',
|
||||
hersteller={'name': 'odoo-at-payroll', 'kfz': 'A',
|
||||
'plz': company.partner_id.zip,
|
||||
'ort': company.partner_id.city,
|
||||
'strasse': company.partner_id.street},
|
||||
datensaetze=datensaetze)
|
||||
datei = L10nAtEldaDatei([bestand])
|
||||
if datensaetze:
|
||||
bestand = L10nAtEldaBestand(
|
||||
'MB', vstr, '02',
|
||||
hersteller={'name': 'odoo-at-payroll', 'kfz': 'A',
|
||||
'plz': company.partner_id.zip,
|
||||
'ort': company.partner_id.city,
|
||||
'strasse': company.partner_id.street},
|
||||
datensaetze=datensaetze)
|
||||
datei = L10nAtEldaDatei([bestand])
|
||||
deklaration.write({
|
||||
'datei': base64.b64encode(datei.render()),
|
||||
'datei_name': 'mbgm_%s.txt' % (
|
||||
deklaration.beitragsmonat.strftime('%Y%m'),),
|
||||
})
|
||||
deklaration.write({
|
||||
'datei': base64.b64encode(datei.render()),
|
||||
'datei_name': 'mbgm_%s.txt' % (
|
||||
deklaration.beitragsmonat.strftime('%Y%m'),),
|
||||
'protokoll': 'WARNUNGEN:\n%s' % (
|
||||
'\n'.join(warnungen) or '—'),
|
||||
'state': 'done',
|
||||
@@ -525,6 +638,19 @@ class L10nAtPayrollMbgm(models.Model):
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
l10n_at_gfb_jaehrliche_abrechnung = fields.Boolean(
|
||||
string='AT GfB: jährliche Abrechnung der Beiträge (JAGB)',
|
||||
help='Vereinbarung nach § 58 Abs 8 ASVG iVm § 6 Abs 2a BMSVG: '
|
||||
'die Abfertigungs-/BV-Beiträge aus geringfügigen '
|
||||
'Beschäftigungsverhältnissen werden jährlich verrechnet. '
|
||||
'mBGM-seitig je Kalendermonat ein eigenes Paket mit '
|
||||
'JAGB = „J“ (DM-ORG D.52/E.32.2.2.1); Fälligkeit bis '
|
||||
'15. Jänner des Folgejahres (unterjähriges AV-Ende: '
|
||||
'bis 15. des Folgemonats). Bei jährlicher Zahlungsweise '
|
||||
'sind zusätzlich 2,5 vH vom Beitrag zu leisten. '
|
||||
'Änderung der Zahlungsweise nur zum Jahresende und dem '
|
||||
'KV-Träger vor dem Beitragszeitraum zu melden.')
|
||||
|
||||
l10n_at_nschab = fields.Boolean(
|
||||
string='AT Nachtschwerarbeitsbeitrags-Betrieb (mBGM-Fallback)',
|
||||
help='Betrieb mit Nachtschwerarbeit (ÖGK-Tarifsystem '
|
||||
@@ -573,6 +699,10 @@ class L10nAtPayrollMbgmLine(models.Model):
|
||||
readonly=True)
|
||||
versicherungsnummer = fields.Char()
|
||||
bsgr = fields.Char(string='Beschäftigtengruppe')
|
||||
jagb = fields.Boolean(
|
||||
string='JAGB-Paket (jährliche Abrechnung GfB)',
|
||||
help='Zeile gehört zum mBGM-Paket für die jährliche Abrechnung '
|
||||
'geringfügig Beschäftigter (DM-ORG D.52).')
|
||||
refw = fields.Char(string='Referenzwert (REFW)')
|
||||
vsum = fields.Monetary(
|
||||
string='VSUM', currency_field='currency_id')
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""AP18: Nachtschwerarbeitsmonat-Zähler (NSchG Art XI Abs 6) —
|
||||
RIS-verifiziert 2026-09-16 (NOR40144608, RIS GesNr 10008502, BGBl
|
||||
1981/354):
|
||||
|
||||
- **≥ 6 Arbeitstage** Nachtschwerarbeit im Kalendermonat → Nachtschwer-
|
||||
arbeitsmonat;
|
||||
- **< 6 Tage**: Rückblick-Fenster — Monat + 1 Vormonat ≥ **12** Tage bzw.
|
||||
Monat + 2 Vormonate ≥ **18** Tage;
|
||||
- (DZR > 3 Monate: Monat + 5 Vormonate ≥ 36 Tage — dokumentierter Rest,
|
||||
kein Durchrechnungszeitraum-Datenmodell);
|
||||
- **Randmonat**: die 6 erwarteten Nachtschwerarbeitstage werden nur
|
||||
deswegen nicht erreicht, weil die Arbeit nicht am Monats-Ersten begonnen
|
||||
bzw. am Letzten geendet hat — v1 mit Pro-rata-Proxy (Tage ≥ 6 × belegte
|
||||
Monatskalendertage / Monatskalendertage, aufgerundet) implementiert ⚠
|
||||
(Arbeitszeiteinteilung der Muster im Parallellauf verifizieren);
|
||||
- Arbeitsunterbrechungen bleiben außer Betracht, solange die PV-Pflicht-
|
||||
versicherung weiterbesteht.
|
||||
|
||||
Datenbasis: Work Entries des Typs ``ATP_NACHTSCHWER`` (mandantenseitige
|
||||
Klassifikation der Nachtschwerarbeit nach NSchG Art VII Abs 2/4 bzw. ASchV
|
||||
§ 1 — die Katalog-Kriterien kann die Engine nicht ableiten). Abgleich mit
|
||||
der E01-Belegung (mBGM-Ergb, AP0/TASY) als Protokoll-Hinweis in der
|
||||
mBGM-Generierung."""
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_NACHTSCHWER_WET = 'ATP_NACHTSCHWER'
|
||||
|
||||
|
||||
class HrVersion(models.Model):
|
||||
_inherit = 'hr.version'
|
||||
|
||||
def _l10n_at_nachtschwer_tage(self, monat_erster, monat_letzter):
|
||||
"""Tage mit Nachtschwerarbeit-WETs im (Teil-)Monat."""
|
||||
self.ensure_one()
|
||||
eintraege = self.env['hr.work.entry'].search([
|
||||
('employee_id', '=', self.employee_id.id),
|
||||
('date', '>=', monat_erster),
|
||||
('date', '<=', monat_letzter),
|
||||
('state', '!=', 'cancelled'),
|
||||
('work_entry_type_id.code', '=', _NACHTSCHWER_WET),
|
||||
])
|
||||
return len({eintrag.date for eintrag in eintraege})
|
||||
|
||||
|
||||
class HrPayslip(models.Model):
|
||||
_inherit = 'hr.payslip'
|
||||
|
||||
l10n_at_nachtschwerarbeitsmonat = fields.Boolean(
|
||||
string='AT Nachtschwerarbeitsmonat',
|
||||
compute='_compute_l10n_at_nachtschwerarbeitsmonat',
|
||||
store=True,
|
||||
help='NSchG Art XI Abs 6 (RIS NOR40144608): ≥ 6 Nachtschwerarbeits-'
|
||||
'tage im Monat bzw. Rückblick-Fenster (12/18; 36 bei DZR > '
|
||||
'3 Monate — dokumentierter Rest) bzw. Randmonat (Pro-rata-'
|
||||
'Proxy ⚠). Grundlage NSchAB 3,80 % (Art XI Abs 3 iVm '
|
||||
'Sistierung; TASY-ERGB E01).')
|
||||
|
||||
@api.depends('date_from', 'date_to', 'version_id')
|
||||
def _compute_l10n_at_nachtschwerarbeitsmonat(self):
|
||||
for slip in self:
|
||||
slip.l10n_at_nachtschwerarbeitsmonat = \
|
||||
slip._l10n_at_nachtschwerarbeitsmonat(slip.date_from)
|
||||
|
||||
def _l10n_at_nachtschwerarbeitsmonat(self, stichtag):
|
||||
"""Art XI Abs 6: 6/12/18-Fenster + Randmonat-Proxy."""
|
||||
self.ensure_one()
|
||||
version = self.version_id
|
||||
if not version:
|
||||
return False
|
||||
erster = stichtag.replace(day=1)
|
||||
letzter = (erster + relativedelta(months=1)) \
|
||||
- relativedelta(days=1)
|
||||
monatstage = (letzter - erster).days + 1
|
||||
tage = version._l10n_at_nachtschwer_tage(erster, letzter)
|
||||
if tage >= 6:
|
||||
return True
|
||||
# Randmonat (Art XI Abs 6 letzter Satz): die sechs erwarteten
|
||||
# Nachtschwerarbeitstage werden nur deswegen nicht erreicht,
|
||||
# weil die Arbeit nicht am Monats-Ersten begonnen bzw. am
|
||||
# Letzten geendet hat — v1 Pro-rata-Proxy.
|
||||
beginn = max(erster, self.date_from)
|
||||
ende = min(letzter, self.date_to)
|
||||
if (beginn, ende) != (erster, letzter):
|
||||
belegte = (ende - beginn).days + 1
|
||||
erforderlich = int(-(-6 * belegte // monatstage)) # ceil
|
||||
if tage >= erforderlich:
|
||||
return True
|
||||
# Rückblick-Fenster (12/18): Monat + 1 bzw. + 2 Vormonate.
|
||||
tage_1 = version._l10n_at_nachtschwer_tage(
|
||||
erster - relativedelta(months=1),
|
||||
erster - relativedelta(days=1))
|
||||
if tage + tage_1 >= 12:
|
||||
return True
|
||||
tage_2 = version._l10n_at_nachtschwer_tage(
|
||||
erster - relativedelta(months=2),
|
||||
erster - relativedelta(days=1))
|
||||
if tage + tage_1 + tage_2 >= 18:
|
||||
return True
|
||||
return False
|
||||
@@ -61,6 +61,18 @@ class HrVersionPrivate(models.Model):
|
||||
class HrPayslip(models.Model):
|
||||
_inherit = 'hr.payslip'
|
||||
|
||||
def _l10n_at_sv_satz(self, seite, komponente, monat=None,
|
||||
staffel_basis=None):
|
||||
"""AP19: Organ ≥ 25 % Beteiligung — kein SV-Verhältnis
|
||||
(lb-vst-03; § 4 Abs 2 ASVG-Ausnahme) → alle Beitragssätze 0;
|
||||
LSt läuft unverändert weiter."""
|
||||
self.ensure_one()
|
||||
if getattr(self.version_id, 'l10n_at_status', 'an') \
|
||||
== 'organ_mehr':
|
||||
return 0.0
|
||||
return super()._l10n_at_sv_satz(
|
||||
seite, komponente, monat=monat, staffel_basis=staffel_basis)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Arbeitstage (resource_calendar) — § 17 Abs 2 KV, user convention:
|
||||
# Werktage des KV werden als Arbeitstage aus dem Ressourcenkalender
|
||||
|
||||
@@ -19,3 +19,5 @@ from . import test_reisekosten_private
|
||||
from . import test_pendler_private
|
||||
from . import test_endabrechnung_private
|
||||
from . import test_karenz_private
|
||||
from . import test_ap17_private
|
||||
from . import test_ap18_ap19_private
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""AP17 privat: mBGM für geringfügig Beschäftigte (BSGR B010/B030 —
|
||||
§ 53a-UV-Pauschale T01 + BMSVG-Beitrag V01) und die JAGB-Paket-Trennung
|
||||
für die jährliche Abrechnung (§ 58 Abs 8 ASVG iVm § 6 Abs 2a BMSVG,
|
||||
DM-ORG D.52/E.32.2.2.1) inkl. 2,5 vH-Zuschlag (Z04) in der
|
||||
GfB-Jahresabrechnung."""
|
||||
import base64
|
||||
import datetime
|
||||
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class Ap17GfbMbgmTest(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.company = self.env.company
|
||||
self.company.write({
|
||||
'l10n_at_beitragskontonummer': '1412345678',
|
||||
'l10n_at_lohnsteuernummer': '123456789',
|
||||
})
|
||||
self.category = self.env['hr.salary.rule.category'].create({
|
||||
'name': 'Test AT AP17', 'code': 'TEST_AT_AP17'})
|
||||
self.structure_type = self.env[
|
||||
'hr.payroll.structure.type'].create({
|
||||
'name': 'Test AT AP17 Type'})
|
||||
self.struktur_gfb = self.env['hr.payroll.structure'].create({
|
||||
'name': 'Test AT AP17 GfB',
|
||||
'code': 'TEST_AT_AP17_GFB',
|
||||
'type_id': self.structure_type.id,
|
||||
})
|
||||
self.env['hr.salary.rule'].create({
|
||||
'name': 'LFD', 'code': 'LFD',
|
||||
'struct_id': self.struktur_gfb.id,
|
||||
'category_id': self.category.id,
|
||||
'condition_select': 'none',
|
||||
'l10n_at_bemessung': 'laufend',
|
||||
'amount_select': 'fix',
|
||||
'amount_fix': 500.0,
|
||||
})
|
||||
employee = self.env['hr.employee'].create({
|
||||
'name': 'AP17 GfB Test', 'birthday': '1990-05-15'})
|
||||
version = self.env['hr.version'].search(
|
||||
[('employee_id', '=', employee.id)], limit=1)
|
||||
version.write({
|
||||
'contract_date_start': datetime.date(2020, 1, 1),
|
||||
'ssnid': '1234150590',
|
||||
'sex': 'male',
|
||||
})
|
||||
self.employee = employee
|
||||
self.version = version
|
||||
|
||||
def _slip(self, monat, jahr=2026, confirm=True):
|
||||
erster = datetime.date(jahr, monat, 1)
|
||||
letzter = (erster.replace(day=28) + datetime.timedelta(days=4)) \
|
||||
.replace(day=1) - datetime.timedelta(days=1)
|
||||
slip = self.env['hr.payslip'].create({
|
||||
'name': 'AP17 %d/%d' % (monat, jahr),
|
||||
'employee_id': self.employee.id,
|
||||
'struct_id': self.struktur_gfb.id,
|
||||
'date_from': erster,
|
||||
'date_to': letzter,
|
||||
})
|
||||
slip.compute_sheet()
|
||||
if confirm:
|
||||
slip.action_payslip_done()
|
||||
return slip
|
||||
|
||||
def _deklaration(self, monat, jahr=2026):
|
||||
deklaration = self.env['l10n.at.payroll.mbgm'].create({
|
||||
'company_id': self.company.id,
|
||||
'beitragsmonat': datetime.date(jahr, monat, 1),
|
||||
'art': 'MB',
|
||||
})
|
||||
deklaration.action_erzeugen()
|
||||
return deklaration
|
||||
|
||||
def test_01_gfb_mbgm_ohne_jagb_flag(self):
|
||||
"""GfB-Lauf: BSGR B030, T01 = § 53a-UV-Pauschale 6,50 € (VPTA 0),
|
||||
V01 BV 1,53 %; ohne Company-Flag im normalen Paket (JAGB 'N')."""
|
||||
slip = self._slip(1)
|
||||
self.assertTrue(slip.l10n_at_geringfuegig)
|
||||
deklaration = self._deklaration(1)
|
||||
self.assertEqual(deklaration.line_ids.bsgr, 'B030')
|
||||
self.assertFalse(deklaration.line_ids.jagb)
|
||||
roh = base64.b64decode(deklaration.datei).decode('ISO-8859-15')
|
||||
zeilen = roh.strip('\r\n').split('\r\n')
|
||||
daten = zeilen[2:-1]
|
||||
paket = daten[0]
|
||||
self.assertEqual(paket[0:2], 'PS')
|
||||
self.assertEqual(paket[170], 'N') # JAGB
|
||||
satzarten = [zeile[0:2] for zeile in daten]
|
||||
# PS + G1 + T1 + BS AB + V1 T01 (Pauschale) + BS BV + V1 V01
|
||||
self.assertEqual(satzarten, ['PS', 'G1', 'T1', 'BS', 'V1',
|
||||
'BS', 'V1'])
|
||||
v1_t01 = daten[4]
|
||||
self.assertEqual(v1_t01[20:23], 'T01')
|
||||
self.assertEqual(v1_t01[24:30], '000000') # VPTA 0 (Pauschale)
|
||||
self.assertEqual(v1_t01[31:42], '00000000650') # RSUM 6,50 €
|
||||
|
||||
def test_02_jagb_paket_trennung(self):
|
||||
"""Company-Flag: das GfB-mBGM läuft ins JAGB-Paket (JAGB 'J',
|
||||
REFP-Suffix, eigenes ANZM/GSUM); kein normales Paket, wenn nur
|
||||
GfB-AN im Monat."""
|
||||
self.company.l10n_at_gfb_jaehrliche_abrechnung = True
|
||||
slip = self._slip(2)
|
||||
deklaration = self._deklaration(2)
|
||||
self.assertTrue(deklaration.line_ids.jagb)
|
||||
roh = base64.b64decode(deklaration.datei).decode('ISO-8859-15')
|
||||
zeilen = roh.strip('\r\n').split('\r\n')
|
||||
daten = zeilen[2:-1]
|
||||
paket = daten[0]
|
||||
self.assertEqual(paket[170], 'J') # JAGB 'J'
|
||||
self.assertIn('JAGB', deklaration.protokoll)
|
||||
self.assertIn('2,5', deklaration.protokoll)
|
||||
|
||||
def test_03_bv_jahresabrechnung_gfb(self):
|
||||
"""Jänner + Februar JAGB-Läufe: Summe der JAGB-Beiträge und
|
||||
2,5 vH-Zuschlag (§ 6 Abs 2a BMSVG; Z04), Fälligkeit Folgejahr."""
|
||||
self.company.l10n_at_gfb_jaehrliche_abrechnung = True
|
||||
self._slip(1)
|
||||
self._slip(2)
|
||||
self._deklaration(1)
|
||||
deklaration_2 = self._deklaration(2)
|
||||
abrechnung = deklaration_2._l10n_at_bv_jahresabrechnung_gfb(2026)
|
||||
self.assertEqual(abrechnung['summe'],
|
||||
sum(deklaration_2.line_ids.mapped('vsum')) * 2)
|
||||
self.assertAlmostEqual(abrechnung['zuschlag'],
|
||||
round(abrechnung['summe'] * 0.025, 2))
|
||||
self.assertAlmostEqual(abrechnung['gesamt'],
|
||||
round(abrechnung['summe'] * 1.025, 2))
|
||||
@@ -0,0 +1,198 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""AP18/AP19-Tests: Nachtschwerarbeitsmonat-Zähler (NSchG Art XI Abs 6
|
||||
— 6/12/18-Fenster + Randmonat-Proxy), Schwerarbeitsmeldungs-Fixsatz
|
||||
(E.22, BEST SM, SART 65, Tätigkeitsblöcke, Altersfilter, Erstellvor-
|
||||
schriften), SV-Sperre Organ ≥ 25 % (lb-vst-03) und die fiktive
|
||||
BV-Grundlage Präsenz-/Ausbildungs-/Zivildienst (§ 7 Abs 1/2 BMSVG)."""
|
||||
import base64
|
||||
import datetime
|
||||
|
||||
from odoo import fields
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class Ap18Test(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.company = self.env.company
|
||||
self.company.write({
|
||||
'l10n_at_beitragskontonummer': '1412345678',
|
||||
'l10n_at_lohnsteuernummer': '123456789',
|
||||
})
|
||||
employee = self.env['hr.employee'].create({
|
||||
'name': 'AP18 Test', 'birthday': '1970-01-15'})
|
||||
version = self.env['hr.version'].search(
|
||||
[('employee_id', '=', employee.id)], limit=1)
|
||||
version.write({
|
||||
'contract_date_start': datetime.date(2020, 1, 1),
|
||||
'ssnid': '1234150170',
|
||||
'sex': 'male',
|
||||
})
|
||||
self.employee = employee
|
||||
self.version = version
|
||||
|
||||
def _nachtschwer_wets(self, tage, monat, jahr=2026):
|
||||
wett = self.env.ref(
|
||||
'l10n_at_hr_payroll_private.wett_nachtschwer')
|
||||
erster = datetime.date(jahr, monat, 1)
|
||||
for tag in range(1, tage + 1):
|
||||
self.env['hr.work.entry'].create({
|
||||
'name': 'NSchW %d' % tag,
|
||||
'employee_id': self.employee.id,
|
||||
'date': erster.replace(day=tag),
|
||||
'work_entry_type_id': wett.id,
|
||||
})
|
||||
|
||||
def test_01_nachtschwerarbeitsmonat_fenster(self):
|
||||
"""Art XI Abs 6: 6 Tage im Monat → NSchM; 2 + 3 (Vormonat 5):
|
||||
2+5 = 7 < 12 → kein NSchM; 4 + 8 = 12 → NSchM via 12er-Fenster;
|
||||
4 + 4 + 11 = 19 ≥ 18 → 18er-Fenster."""
|
||||
self._nachtschwer_wets(6, 3)
|
||||
slip = self.env['hr.payslip'].create({
|
||||
'name': 'NSchW 3/2026', 'employee_id': self.employee.id,
|
||||
'date_from': datetime.date(2026, 3, 1),
|
||||
'date_to': datetime.date(2026, 3, 31),
|
||||
})
|
||||
self.assertTrue(slip.l10n_at_nachtschwerarbeitsmonat)
|
||||
# 12er-Fenster: März 4 + Februar 8 = 12 → NSchM im April.
|
||||
self.env['hr.work.entry'].search([]).unlink()
|
||||
self._nachtschwer_wets(4, 4)
|
||||
self._nachtschwer_wets(8, 3)
|
||||
slip_4 = self.env['hr.payslip'].create({
|
||||
'name': 'NSchW 4/2026', 'employee_id': self.employee.id,
|
||||
'date_from': datetime.date(2026, 4, 1),
|
||||
'date_to': datetime.date(2026, 4, 30),
|
||||
})
|
||||
self.assertTrue(slip_4.l10n_at_nachtschwerarbeitsmonat)
|
||||
|
||||
def test_02_randmonat_pro_rata(self):
|
||||
"""Randmonat-Proxy: 5 Tage im 15-tägigen Teilmonat —
|
||||
erforderlich = ceil(6 × 15/31) = 2 → 5 ≥ 2 → NSchM (Proxy ⚠)."""
|
||||
self._nachtschwer_wets(5, 5)
|
||||
slip = self.env['hr.payslip'].create({
|
||||
'name': 'NSchW 5/2026 (Randmonat)',
|
||||
'employee_id': self.employee.id,
|
||||
'date_from': datetime.date(2026, 5, 20),
|
||||
'date_to': datetime.date(2026, 5, 31),
|
||||
})
|
||||
self.assertTrue(slip.l10n_at_nachtschwerarbeitsmonat)
|
||||
|
||||
def test_03_schwerarbeitsmeldung_fixsatz(self):
|
||||
"""SART 65 (Länge 800): BKNR/DGNA/VSNR/GEBD/JAHR + Tätigkeits-
|
||||
blöcke (TART '01' an 502, TVON/TBIS); Altersfilter-Warnung."""
|
||||
junger = self.env['hr.employee'].create({
|
||||
'name': 'AP18 Jung', 'birthday': '1995-06-10'})
|
||||
version_j = self.env['hr.version'].search(
|
||||
[('employee_id', '=', junger.id)], limit=1)
|
||||
version_j.write({'ssnid': '1234100695', 'sex': 'male'})
|
||||
meldung = self.env['l10n.at.payroll.schwerarbeitsmeldung'].create({
|
||||
'company_id': self.company.id, 'jahr': 2026})
|
||||
line = self.env[
|
||||
'l10n.at.payroll.schwerarbeitsmeldung.line'].create({
|
||||
'meldung_id': meldung.id,
|
||||
'employee_id': junger.id,
|
||||
'versicherungsnummer': '1234100695',
|
||||
'taetigkeit_ids': [
|
||||
(0, 0, {'ziffer': 1,
|
||||
'von': datetime.date(2026, 1, 1),
|
||||
'bis': datetime.date(2026, 12, 31)}),
|
||||
],
|
||||
})
|
||||
meldung.action_erzeugen()
|
||||
self.assertEqual(meldung.state, 'done')
|
||||
roh = base64.b64decode(meldung.datei).decode('ISO-8859-15')
|
||||
zeilen = roh.strip('\r\n').split('\r\n')
|
||||
sm_satz = zeilen[2]
|
||||
self.assertEqual(len(sm_satz), 800)
|
||||
self.assertEqual(sm_satz[0:2], '65')
|
||||
self.assertEqual(sm_satz[339:349], '1234100695') # VSNR pos 340
|
||||
self.assertEqual(sm_satz[501:503], '01') # TART_1
|
||||
self.assertEqual(sm_satz[503:507], '0101') # TVON
|
||||
self.assertEqual(sm_satz[507:511], '3112') # TBIS
|
||||
self.assertIn('Altersgrenze', meldung.protokoll)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class Ap19Test(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.company = self.env.company
|
||||
self.company.write({
|
||||
'l10n_at_beitragskontonummer': '1412345678',
|
||||
'l10n_at_lohnsteuernummer': '123456789',
|
||||
})
|
||||
self.category = self.env['hr.salary.rule.category'].create({
|
||||
'name': 'Test AT AP19', 'code': 'TEST_AT_AP19'})
|
||||
self.structure_type = self.env[
|
||||
'hr.payroll.structure.type'].create({
|
||||
'name': 'Test AT AP19 Type'})
|
||||
self.struktur = self.env['hr.payroll.structure'].create({
|
||||
'name': 'Test AT AP19', 'code': 'TEST_AT_AP19',
|
||||
'type_id': self.structure_type.id})
|
||||
self.env['hr.salary.rule'].create({
|
||||
'name': 'LFD', 'code': 'LFD',
|
||||
'struct_id': self.struktur.id,
|
||||
'category_id': self.category.id,
|
||||
'condition_select': 'none',
|
||||
'l10n_at_bemessung': 'laufend',
|
||||
'amount_select': 'fix',
|
||||
'amount_fix': 3000.0})
|
||||
employee = self.env['hr.employee'].create({
|
||||
'name': 'AP19 Test', 'birthday': '1980-01-01'})
|
||||
version = self.env['hr.version'].search(
|
||||
[('employee_id', '=', employee.id)], limit=1)
|
||||
version.write({
|
||||
'contract_date_start': datetime.date(2020, 1, 1),
|
||||
'ssnid': '1234100180',
|
||||
'sex': 'male'})
|
||||
self.employee = employee
|
||||
self.version = version
|
||||
|
||||
def _slip(self, monat, jahr=2026):
|
||||
erster = datetime.date(jahr, monat, 1)
|
||||
letzter = (erster.replace(day=28) + datetime.timedelta(days=4)) \
|
||||
.replace(day=1) - datetime.timedelta(days=1)
|
||||
slip = self.env['hr.payslip'].create({
|
||||
'name': 'AP19 %d/%d' % (monat, jahr),
|
||||
'employee_id': self.employee.id,
|
||||
'struct_id': self.struktur.id,
|
||||
'date_from': erster,
|
||||
'date_to': letzter,
|
||||
})
|
||||
slip.compute_sheet()
|
||||
return slip
|
||||
|
||||
def test_04_organ_mehr_sv_frei(self):
|
||||
"""Organ ≥ 25 %: alle SVDN/SVDG 0 (lb-vst-03); mBGM-Lauf weist
|
||||
den Mitarbeiter mit Protokoll-Hinweis aus."""
|
||||
self.version.l10n_at_status = 'organ_mehr'
|
||||
slip = self._slip(1)
|
||||
slip.action_payslip_done()
|
||||
self.assertAlmostEqual(slip._l10n_at_svdn_gesamt(), 0.0, places=2)
|
||||
self.assertAlmostEqual(slip._l10n_at_svdg_gesamt(), 0.0, places=2)
|
||||
deklaration = self.env['l10n.at.payroll.mbgm'].create({
|
||||
'company_id': self.company.id,
|
||||
'beitragsmonat': datetime.date(2026, 1, 1),
|
||||
'art': 'MB'})
|
||||
deklaration.action_erzeugen()
|
||||
self.assertFalse(deklaration.line_ids)
|
||||
self.assertIn('kein SV', deklaration.protokoll)
|
||||
# LSt läuft weiter (Bemessungsgrundlage ohne SV-Sperre).
|
||||
self.assertGreater(
|
||||
slip._l10n_at_bemessung_laufen(), 0.0)
|
||||
|
||||
def test_04b_prasenzenzdienst_fiktion(self):
|
||||
"""§ 7 Abs 1/2 BMSVG: fiktive BV-BG = 435,90 € × Tage/Monatstage
|
||||
(ATP_PRD_TAGE) zusätzlich zur Basis."""
|
||||
slip = self._slip(3)
|
||||
slip.input_line_ids = [(0, 0, {
|
||||
'input_type_id': self.env.ref(
|
||||
'l10n_at_hr_payroll_private.input_type_atp_prd_tage').id,
|
||||
'amount': 20.0})]
|
||||
basis = slip._at_bv_basis_laufen()
|
||||
fiktiv = slip._at_bv_fiktiv_prasenzenzdienst()
|
||||
self.assertAlmostEqual(fiktiv, 435.90 * 20 / 31.0, places=2)
|
||||
self.assertAlmostEqual(basis, 3000.0 + fiktiv, places=2)
|
||||
@@ -51,6 +51,7 @@
|
||||
<field name="employee_name"/>
|
||||
<field name="versicherungsnummer"/>
|
||||
<field name="bsgr"/>
|
||||
<field name="jagb" optional="show"/>
|
||||
<field name="refw"/>
|
||||
<field name="vsum"/>
|
||||
</list>
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<field name="l10n_at_dienstgebergruppe"
|
||||
placeholder="z. B. NBALG"/>
|
||||
<field name="l10n_at_nschab"/>
|
||||
<field name="l10n_at_gfb_jaehrliche_abrechnung"/>
|
||||
</group>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
Reference in New Issue
Block a user