Compare commits
5 Commits
529ac30cdb
...
57522b5be3
| Author | SHA1 | Date | |
|---|---|---|---|
| 57522b5be3 | |||
| c843701d1a | |||
| ea14c48cca | |||
| 14940ac31a | |||
| c474a39466 |
+67
-7
@@ -2,8 +2,45 @@
|
||||
|
||||
## Current focus
|
||||
|
||||
**GP6 FI-Buchung UGB umgesetzt und grün (88/88 Tests) — nächster
|
||||
Bauabschnitt GP7 Meldewesen/Belege (Kern-Bau AP5/AP6)** (2026-09-12):
|
||||
**GP7 Meldewesen/Belege umgesetzt und grün (94/94 Tests) — nächster
|
||||
Bauabschnitt GP8 Jahreswechsel/UI/Fristen** (2026-09-12): Kern
|
||||
19.0.4.0.0 — ELDA-Fixsatz-Framework (`models/elda.py`: Fixlängen,
|
||||
a/n blank/n null, Cent kaufmännisch, Prozente ×1000, ISO-8859-15,
|
||||
Dateiheader, Vorlauf SART 00/Schluss SART 99, SANR ab 1 lückenlos,
|
||||
SANZ inkl. Rahmen; **CRLF-Satztrenner = Praxis-Annahme, DM-ORG
|
||||
normiert keines — Parallellauf verifizieren**); L16 (`models/
|
||||
lohnzettel_l16.py`: I1 1.100 + L1 **3.500**, Lohnzettelversion 28,
|
||||
BEST LF/VSTR 94, ~210 Felder, Kinderblock 15×123 ab Pos. 1501;
|
||||
**KZ 210 = § 25 GESAMTBrutto** — KZ-245-Formel = laufende Steuer-
|
||||
bemessung, per Konstruktion GP3-konsistent, Ableitung aus Kern-Flags
|
||||
ohne paralleles KZ-Feld) + Arbeitsstättenmeldung § 34 Abs 6 (SART 45,
|
||||
BEST AD/VSTR ST, je AN am 31. 12., GKZ manuell am Dienstort-Partner);
|
||||
Kind-Modell `l10n.at.payroll.kind` (FB+-Wartung; Nutzer: **FB+ rechnet
|
||||
NICHT im Lohnsteuerkern** — Steuer < 0 → ANV); Stammdaten
|
||||
(BKNR/Lohnsteuernummer Company, GKZ Partner, VSNR-Formatprüfung
|
||||
hr.version.ssnid — Prüfziffer Rest, eSV-AGRD-Mapping
|
||||
hr.departure.reason); Belege: Lohnzettel AT + Monatsdienstliste +
|
||||
Monatsjournal (QWeb auf hr.payslip.run; account.report in keinem
|
||||
HR-Modul). Privat 19.0.7.0.0 — mBGM/JASV (`models/mbgm.py`: PS/G1/
|
||||
T1/BS/V1, T01/T02-Gesamtsatz aus `at_mbgm_tarif` **normativ laufend
|
||||
39,05 %/SZ 37,55 %**; Abschläge **je Basis AB und SZ**: A03/A02/A01
|
||||
§-2a-DN-AV, A09 UV-60, A10 AV+IE, A15 PV; BV-Basis GP5 + V01;
|
||||
RSUM=Basis×Satz mit GP4-Konsistenzcheck ±0,10 €; Storno R1 REFU,
|
||||
Fehlanzeige Null-Paket; geringfügig/fallweise OHNE Parametrisierung →
|
||||
Abbruch, keine Tariferfindung) + Versichertenmeldung (`models/
|
||||
versichertenmeldung.py`: M3/M4 deklarative Event-Erkennung über
|
||||
Registry `l10n.at.payroll.vr.event` — idempotent, KEIN write-diff;
|
||||
BBER 01–04, GERF Gehalt-vs-GFG, BVAB/BVJN aus GP5, VWAZ h×100, AGRD;
|
||||
fallweise BEST MA/SART 32 MVP je Anmeldetag; NSchAB-Company-Flag
|
||||
+3,80 B001). Tests 94/94 (test_elda_l16 4 + test_meldewesen_private
|
||||
2), Wegwerf-DB frisch; Rechenfälle `docs/rechenfaelle/meldewesen.md`.
|
||||
Out: Schwerarbeit (DM-ORG E.22-Struktur vorhanden, keine ASchV-
|
||||
Basis), § 109a, L17, Mitteilungsfile-Import (5.2→GP8), FB+-LSt
|
||||
(ANV), geringfügig-mBGM bis Param. Offen: odoo_dev-Update (GP4-
|
||||
Stand!), Referenzbuchhaltungs-Endfixierung, Parallellauf-Verifikation
|
||||
CRLF/Kanäle.
|
||||
|
||||
**GP6 FI-Buchung UGB umgesetzt und grün (88/88 Tests)** (2026-09-12):
|
||||
privat 19.0.6.0.0 — Buchungsmatrix nach **KFS/BW 6** (Nutzer-
|
||||
Referenz-Exemplar `.additional_docs/KFSBW6_13072017_RF.pdf`, extrah.
|
||||
`/tmp/kfsbw6/` ad hoc): Hook `_configure_payroll_account_at`
|
||||
@@ -192,6 +229,24 @@ B17-Inhalt/B18; Kleinverifikationen s. KB-KONFLIKTE „Nächste Schritte“).
|
||||
|
||||
## Completed
|
||||
|
||||
- **GP7 Meldewesen/Belege umgesetzt (94/94 grün)** (2026-09-12):
|
||||
Kern 19.0.4.0.0: `models/elda.py` (Fixsatz-Framework),
|
||||
`lohnzettel_l16.py` (L16 I1/L1 + Arbeitsstätte SART 45;
|
||||
`l10n.at.payroll.l16` + `.line`), `kind.py` (`l10n.at.payroll.kind`),
|
||||
`meldewesen.py` (BKNR/Lohnsteuernummer/GKZ/VSNR-Prüfung/AGRD),
|
||||
`monatsreports.py` + `report/belege_reports.xml` (3 Belege),
|
||||
`views/meldewesen_views.xml`. Privat 19.0.7.0.0: `mbgm.py`
|
||||
(`l10n.at.payroll.mbgm[.line]`), `versichertenmeldung.py`
|
||||
(`l10n.at.payroll.vr` + `.event`-Registry),
|
||||
`data/rule_parameters_mbgm_private.xml` (at_mbgm_tarif B001/B002
|
||||
39,05/37,55 ab 2026), `views/meldewesen_views.xml`, NSchAB-Flag.
|
||||
DM-ORG-42.7.0-Satzstrukturen PDF-verifiziert extrahiert (mBGM
|
||||
E.32, L16 E.13/E.14 mit vollständiger L1-Tabelle inkl. Kinderblock-
|
||||
Offsets, VR E.29 mit Pflichtmatrix/AGRD/BBER, Arbeitsstätte E.20,
|
||||
fallweise E.18; TXT-Fehler vs. PDF dokumentiert — PDF gewinnt).
|
||||
Rechenfälle `docs/rechenfaelle/meldewesen.md`; README/CHANGELOG/
|
||||
PLAN-10/MEMORY nachgezogen.
|
||||
|
||||
- **GP6 FI-Buchung UGB umgesetzt (88/88 grün)** (2026-09-12):
|
||||
`l10n_at_hr_payroll_private` 19.0.6.0.0 — Buchungsmatrix KFS/BW 6:
|
||||
`models/account_chart_private.py` (Matrix-Dict + Hook
|
||||
@@ -1207,12 +1262,17 @@ B17-Inhalt/B18; Kleinverifikationen s. KB-KONFLIKTE „Nächste Schritte“).
|
||||
GP5 Abfertigung Neu; **GP6 FI-Buchung UGB umgesetzt 2026-09-12** —
|
||||
`test_kv_katalog` 8 + `test_entgelt_private` 21 + `test_lst_private`
|
||||
6 + `test_sv_private` 8 + `test_bv_private` 10 + `test_account_private`
|
||||
4 grün auf Wegwerf-DB (**88/88**, frischer `-i`-Lauf — `odoo_dev`
|
||||
noch auf GP4-Stand, Update nachholen); Rechenfall-Fixtures unter
|
||||
`docs/rechenfaelle/` inkl. buchhaltung.md (KFS/BW-6-Matrix);
|
||||
GP7 Meldewesen/Belege als Nächstes)
|
||||
4 + `test_meldewesen_private` 2 grün auf Wegwerf-DB (**94/94**
|
||||
gesamt mit Kern; `odoo_dev` noch auf GP4-Stand, Update nachholen);
|
||||
Rechenfall-Fixtures unter `docs/rechenfaelle/` inkl. buchhaltung.md
|
||||
+ meldewesen.md;
|
||||
GP7 umgesetzt: mBGM/Versichertenmeldung/fallweise auf dem Kern-Framework;
|
||||
GP8 Jahreswechsel/UI/Fristen als Nächstes)
|
||||
- `addons/l10n_at_hr_payroll/` — Bundes-Kern (SV/TASY + GP3 Lohnsteuer
|
||||
`models/lohnsteuer.py` + **GP4 SV/DB `models/sozialversicherung.py`**
|
||||
+ GP4 SV/DB + **GP7 ELDA/L16/Arbeitsstätte/Belege** 19.0.4.0.0 —
|
||||
`models/elda.py`, `lohnzettel_l16.py`, `kind.py`, `meldewesen.py`,
|
||||
`monatsreports.py`, `report/belege_reports.xml`;
|
||||
`test_elda_l16` 4)
|
||||
(DG/Geringfügigkeit/§ 49/Altersstichtage/§ 2a-Staffel/WF-Vektor/
|
||||
Beitragsmonat/DAG), `rule_parameters_lst_2026.xml` +
|
||||
`rule_parameters_sv_2026.xml`; **74/74 grün**)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
{
|
||||
'name': 'Österreich - Personalverrechnung Kern (AT Payroll Core)',
|
||||
'version': '19.0.3.1.0',
|
||||
'version': '19.0.5.0.0',
|
||||
'author': 'ixsol',
|
||||
'website': 'https://gem360.at',
|
||||
'category': 'Human Resources/Payroll',
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
Sektor-neutraler Bundes-Kern der österreichischen Personalverrechnung
|
||||
auf der Odoo-19 ``hr_payroll``-Engine. Wird konsumiert von
|
||||
``l10n_at_gemeinde_payroll`` (Bgld. GemBG) und später vom General-AT-
|
||||
``l10n_at_gemeinde_payroll`` (Bgld. GemBG) und vom General-AT-
|
||||
Produkt (``l10n_at_hr_payroll_private``). Grundlagen: ``personalverrechnung/``
|
||||
(PLAN-payroll-bgld.md, RECHTSQUELLEN-Bgld.md, PLAN-repo-split.md).
|
||||
|
||||
@@ -32,8 +32,33 @@ Produkt (``l10n_at_hr_payroll_private``). Grundlagen: ``personalverrechnung/``
|
||||
``rule_parameters_sv_2026.xml``; BV-Beitrag Abfertigung Neu
|
||||
(BMSVG § 6 Abs 1, ``at_sv_bv_dg``) als Parameter für das
|
||||
General-AT-Modul (GP5);
|
||||
- **Meldewesen/Belege (AP5/AP6, GP7):** ELDA-Fixsatz-Framework
|
||||
(``models/elda.py`` — Vorlauf/Schluss, SANR/SANZ, Cent-Formatter,
|
||||
ISO-8859-15), **Lohnzettel Finanz L16 + Arbeitsstättenmeldung**
|
||||
(``models/lohnzettel_l16.py`` — I1/L1 nach DM-ORG 42.7.0,
|
||||
Lohnzettelversion 28, KZ-245-Formel-Prüfung, BEST LF/94 bzw. AD/ST),
|
||||
Kind-Datenmodell für die FB+-Wartung (``models/kind.py``),
|
||||
Meldewesen-Stammdaten (BKNR/Lohnsteuernummer, GKZ am Partner,
|
||||
VSNR-Formatprüfung, eSV-Abmeldegrund-Mapping,
|
||||
``models/meldewesen.py``), Belege Lohnzettel AT/Monatsdienstliste/
|
||||
Monatsjournal (QWeb, ``models/monatsreports.py``);
|
||||
- **GP8:** Fristenkalender (``models/fristenkalender.py`` — Cron,
|
||||
Auto-Erzeugung, Sprung zur GP7-Deklaration), Jahres-Retro-/
|
||||
Aufrollungslauf (``models/jahresretro.py`` — Kontrollsechstel
|
||||
§ 67 Abs 10, §-77-Abs-3/4-Option), Mitteilungsfile-Import
|
||||
(``models/mitteilungsfile.py`` — ELDA-Rückantwort-XML),
|
||||
2027-Parameter-Struktur (nur Rahmen);
|
||||
- deklarative TASY-Mapping-Tabelle und Import-Wizard (Preview,
|
||||
Sprungwarnung > 10 %, explizite Bestätigung, versionierte Werte);
|
||||
- **Meldewesen/Belege (AP5/AP6, GP7):** ELDA-Fixsatz-Framework
|
||||
(``models/elda.py``), Lohnzettel Finanz L16 + Arbeitsstättenmeldung
|
||||
(``models/lohnzettel_l16.py``, DM-ORG 42.7.0, PDF-verifizierte
|
||||
Satzstrukturen), Kind-Datenmodell für die FB+-Wartung
|
||||
(``models/kind.py`` — Nutzer-Entscheidung: FB+ rechnet NICHT im
|
||||
Lohnsteuerkern, Arbeitnehmerveranlagung), Meldewesen-Stammdaten
|
||||
(BKNR/Lohnsteuernummer, GKZ am Partner, VSNR-Formatprüfung, eSV-
|
||||
Abmeldegrund-Mapping), Belege Lohnzettel AT/Monatsdienstliste/
|
||||
Monatsjournal als QWeb (``report/belege_reports.xml``);
|
||||
- folgen nach Freigabe der Bgld.-APs: Meldewesen (L16/FinanzOnline,
|
||||
eSV/ELDA, AP5), Belege (AP6).
|
||||
|
||||
@@ -46,8 +71,12 @@ Kein VRV-Bezug: die Gemeinde-Buchhaltung (Anlage 3b, Ansatz/MVAG) läuft
|
||||
'data/rule_parameters_2026.xml',
|
||||
'data/rule_parameters_lst_2026.xml',
|
||||
'data/rule_parameters_sv_2026.xml',
|
||||
'data/rule_parameters_2027.xml',
|
||||
'data/tasy_mapping.xml',
|
||||
'views/tasy_views.xml',
|
||||
'views/meldewesen_views.xml',
|
||||
'views/fristen_views.xml',
|
||||
'report/belege_reports.xml',
|
||||
],
|
||||
'countries': ['at'],
|
||||
'license': 'LGPL-3',
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- GP8: 2027-Parameter-Platzhalter (NUR Struktur, keine Werte!). -->
|
||||
<!-- Der Wartungszyklus (RUNBOOK) beschafft die offiziellen 2027- -->
|
||||
<!-- Werte (SV-Werte PDF, Lohnsteuer-Tarif, KV-Gehaltstabellen) -->
|
||||
<!-- und pflegt diese Zeilen nach — GP8 liefert nur den Rahmen. -->
|
||||
<!-- KEINE Werte aus Trainingswissen! -->
|
||||
<record id="hr_rule_parameter_value_at_sv_wf_vektor_2027" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_wf_vektor"/>
|
||||
<field name="date_from">2027-01-01</field>
|
||||
<field name="parameter_value">{'default': {'dn': 0.5, 'dg': 0.5}}</field>
|
||||
</record>
|
||||
|
||||
<!-- AV-Staffel 2027: § 2a AMPFG aufgehoben (BGBl I 62/2026) → -->
|
||||
<!-- tiers=[] (voller DN-Satz) — das ist bereits 2026 verifiziert -->
|
||||
<!-- und kein Trainingswert. -->
|
||||
<record id="hr_rule_parameter_value_at_sv_av_staffel_2027" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_av_staffel_ampfg"/>
|
||||
<field name="date_from">2027-01-01</field>
|
||||
<field name="parameter_value">{'tiers': [], 'dn_voll': 2.95}</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -2,4 +2,10 @@
|
||||
from . import tasy
|
||||
from . import lohnsteuer
|
||||
from . import sozialversicherung
|
||||
from . import sozialversicherung
|
||||
from . import elda
|
||||
from . import meldewesen
|
||||
from . import kind
|
||||
from . import lohnzettel_l16
|
||||
from . import fristenkalender
|
||||
from . import jahresretro
|
||||
from . import mitteilungsfile
|
||||
@@ -0,0 +1,256 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""ELDA-Fixsatz-Framework (GP7/AP5) — „Datenaustausch mit Dienstgebern"
|
||||
(DM-ORG 42.7.0, 07/2026; lokal `.firecrawl/ris/elda/`, unversioniert).
|
||||
|
||||
Dateikonventionen (DM-ORG Kapitel C.1/C.2, E.1–E.3 — verifiziert 2026-09-12):
|
||||
- Datei = optionaler Dateiheader (DHKZ 2×blank + DHVN 01 + ENCD) + mindestens
|
||||
ein Datenbestand; Datenbestand = Vorlaufsatz (SART 00, Feld VERS = Version
|
||||
der nachfolgenden Satzstrukturen) + Datensätze + Schlusssatz (SART 99,
|
||||
SANZ = Satzanzahl inkl. Vorlauf und Schluss).
|
||||
- Fixlängen-Sätze ohne Feldtrennzeichen; alle Sätze eines Bestands nehmen
|
||||
die maximale Satzlänge des Bestands an (RESE-Auffüllung). Sätze werden
|
||||
zeilenweise mit CRLF getrennt (die DM-ORG normiert KEIN explizites
|
||||
Satztrennzeichen — CRLF ist die Praxis der ELDA-Kanäle; Annahme im
|
||||
Parallellauf gegen den Mandanten-ELDA-Zugang verifizieren).
|
||||
- ``a``/``a/n``: linksbündig, Grundstellung blank; ``n``: rechtsbündig,
|
||||
Grundstellung 0, führende Nullen, KEINE Interpunktion (Beträge als
|
||||
EURO-Cent, kaufmännisch gerundet; Prozentsätze mit impliziten
|
||||
Nachkommastellen via ``skala``).
|
||||
- Encoding Fixlängen-Dateien: ISO-8859-15 (Standard-Encoding; der
|
||||
Dateiheader deklariert ENCD). Vorzeichen gehören in eigene V-Felder
|
||||
(RSVZ/Vxxx), ``n``-Felder sind nie negativ.
|
||||
"""
|
||||
from datetime import date, datetime
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
L10N_AT_ELDA_ENCODING = 'ISO-8859-15'
|
||||
L10N_AT_ELDA_ZEILENTRENNER = '\r\n'
|
||||
|
||||
# Identifikationsteil (E.1, S. 174) — 20 Stellen, alle Satzarten gleich;
|
||||
# alle Angaben zwingend.
|
||||
L10N_AT_ELDA_IDTEIL = [
|
||||
(1, 'SART', 1, 2, 'a'), # Satzart
|
||||
(2, 'SANR', 3, 7, 'n'), # laufende Satznummer je Bestand, ab 1
|
||||
(3, 'UVST', 10, 2, 'a'), # datenübernehmender Träger (ED = ELDA)
|
||||
(4, 'OBUS', 12, 7, 'n'), # Ordnungsbegriff der übermittelnden Stelle
|
||||
(5, 'VSTR', 19, 2, 'a'), # zuständiger Träger (D.4)
|
||||
]
|
||||
|
||||
# Vorlaufsatz-Datenteil (E.2, S. 175–177, Version 08) — folgt dem IDTEIL;
|
||||
# zwingend außer HTEL/SOID/VNMF; Satzlänge = maximale Satzlänge des Bestands.
|
||||
L10N_AT_ELDA_VORLAUF = [
|
||||
(2, 'PROJ', 21, 2, 'a'), # DM; TM = Testdaten
|
||||
(3, 'BEST', 23, 2, 'a'), # Verarbeitung (MB/VR/LF/AD/MA …, E.2)
|
||||
(4, 'DTNR', 25, 6, 'a'), # Datenträgernummer (TP-Übermittlung: frei)
|
||||
(5, 'EDAT', 31, 8, 'n'), # TTMMJJJJ
|
||||
(6, 'EZEI', 39, 6, 'n'), # HHMMSS
|
||||
(7, 'HRST', 45, 45, 'a'), # Herstellername
|
||||
(8, 'HKFZ', 90, 3, 'a'), # Internationales Kfz-Kennzeichen (A)
|
||||
(9, 'HPLZ', 93, 7, 'a'),
|
||||
(10, 'HORT', 100, 20, 'a'),
|
||||
(11, 'HSTR', 120, 30, 'a'),
|
||||
(12, 'VERS', 150, 2, 'n'), # Version der Satzstrukturen (D.26)
|
||||
(13, 'HTEL', 152, 20, 'a'),
|
||||
(14, 'SOID', 172, 70, 'a'),
|
||||
(15, 'VNMF', 242, 5, 'a'), # Mitteilungsfile-Version
|
||||
]
|
||||
|
||||
# Schlusssatz-Datenteil (E.3, S. 178, Version 01) — nach IDTEIL;
|
||||
# SANZ zwingend, ELNR/HEMA optional.
|
||||
L10N_AT_ELDA_SCHLUSS = [
|
||||
(2, 'SANZ', 21, 6, 'n'), # Satzanzahl inkl. Vorlauf- und Schlusssatz
|
||||
(3, 'ELNR', 27, 6, 'n'),
|
||||
(4, 'HEMA', 33, 60, 'a'),
|
||||
]
|
||||
|
||||
|
||||
def l10n_at_euro_cent(betrag):
|
||||
"""EURO-Betrag → Cent (kaufmännisch gerundet; DM-ORG: keine
|
||||
Interpunktion)."""
|
||||
return int((Decimal(str(betrag)) * 100).quantize(
|
||||
Decimal('1'), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
class L10nAtEldaSatz:
|
||||
"""Ein Fixlängen-Satz: Felddaten (Nr, Name, Pos, Länge, Typ), zwingende
|
||||
Grundstellung je Typ, Rendering auf die Bestands-Satzlänge. Kein ORM —
|
||||
reine Bau-Hilfe der Meldungsgeneratoren (DmfA-Muster: Datensatz-Baum im
|
||||
Python-Teil, Validierung/Prüfprotokoll darüber)."""
|
||||
|
||||
def __init__(self, struktur, satzlaenge, bezeichnung=''):
|
||||
self.struktur = list(struktur)
|
||||
self.satzlaenge = satzlaenge
|
||||
self.bezeichnung = bezeichnung
|
||||
self._felder = {f[1]: f for f in self.struktur}
|
||||
self._werte = {}
|
||||
if self.struktur:
|
||||
letzte = max(f[2] + f[3] - 1 for f in self.struktur)
|
||||
if letzte > satzlaenge:
|
||||
raise ValueError(
|
||||
'Struktur %s endet bei Position %d — Satzlänge %d '
|
||||
'überschritten' % (bezeichnung, letzte, satzlaenge))
|
||||
|
||||
def setze(self, name, wert, cent=False, skala=0):
|
||||
"""Feld setzen; ``wert`` None übernimmt die Grundstellung.
|
||||
``cent``: EURO-Betrag → Cent; ``skala``: implizite Nachkommastellen
|
||||
(z. B. Prozentsatz mit 3 Nachkommastellen → skala=3)."""
|
||||
if wert is None:
|
||||
return self
|
||||
nr, fname, pos, laenge, typ = self._felder[name]
|
||||
if cent:
|
||||
wert = l10n_at_euro_cent(wert)
|
||||
if isinstance(wert, float):
|
||||
wert = int((Decimal(str(wert)) * (10 ** skala)).quantize(
|
||||
Decimal('1'), rounding=ROUND_HALF_UP))
|
||||
elif isinstance(wert, int):
|
||||
wert = wert * (10 ** skala)
|
||||
roh = str(wert)
|
||||
if typ == 'n':
|
||||
if not roh.isdigit():
|
||||
raise ValueError('Feld %s (%s): %r ist nicht numerisch' % (
|
||||
name, self.bezeichnung, roh))
|
||||
roh = roh.rjust(laenge, '0')
|
||||
else:
|
||||
roh = roh.ljust(laenge, ' ')
|
||||
if len(roh) > laenge:
|
||||
raise ValueError('Feld %s (%s): %r länger als %d' % (
|
||||
name, self.bezeichnung, roh, laenge))
|
||||
self._werte[name] = roh
|
||||
return self
|
||||
|
||||
def setze_datum(self, name, wert, fmt='TTMMJJJJ'):
|
||||
"""Datum in DM-ORG-Feldformat (Vorlaufsatz EDAT = TTMMJJJJ,
|
||||
L16-Übermittlungsfelder = JJJJMMTT, LZP/AGBD = TTMMJJ)."""
|
||||
if wert is None:
|
||||
return self
|
||||
if isinstance(wert, str):
|
||||
wert = datetime.strptime(wert, '%Y-%m-%d').date()
|
||||
formate = {'TTMMJJJJ': '%d%m%Y', 'JJJJMMTT': '%Y%m%d',
|
||||
'TTMMJJ': '%d%m%y'}
|
||||
if fmt not in formate:
|
||||
raise ValueError('Unbekanntes Datumsformat %s' % fmt)
|
||||
return self.setze(name, wert.strftime(formate[fmt]))
|
||||
|
||||
def setze_zeit(self, name, wert):
|
||||
if wert is None:
|
||||
return self
|
||||
return self.setze(name, wert.strftime('%H%M%S'))
|
||||
|
||||
def ist_gesetzt(self, name):
|
||||
return name in self._werte
|
||||
|
||||
def wert(self, name):
|
||||
return self._werte.get(name)
|
||||
|
||||
def render(self):
|
||||
"""Satz in Bestands-Satzlänge; unbelegte Felder in Grundstellung
|
||||
(``a`` blank / ``n`` 0), Positionen nach dem letzten Feld blank."""
|
||||
puffer = [' '] * self.satzlaenge
|
||||
for nr, name, pos, laenge, typ in self.struktur:
|
||||
roh = self._werte.get(name)
|
||||
if roh is None:
|
||||
roh = '0' * laenge if typ == 'n' else ' ' * laenge
|
||||
puffer[pos - 1:pos - 1 + laenge] = roh
|
||||
return ''.join(puffer)
|
||||
|
||||
|
||||
class L10nAtEldaBestand:
|
||||
"""Datenbestand: Vorlaufsatz + Datensätze + Schlusssatz zu EINER
|
||||
Verarbeitung (BEST) für EINEN zuständigen Träger (VSTR). SANR zählt
|
||||
lückenlos ab 1, SANZ inklusive Vorlauf/Schluss (Prüfvorschrift C.1.2);
|
||||
alle Sätze nehmen die maximale Satzlänge der Datensätze an."""
|
||||
|
||||
def __init__(self, best, vstr, vers, hersteller=None, test=False,
|
||||
obus='0000000', datensaetze=None):
|
||||
self.best = best
|
||||
self.vstr = vstr
|
||||
self.vers = vers
|
||||
self.hersteller = hersteller or {}
|
||||
self.projekt = 'TM' if test else 'DM'
|
||||
self.obus = obus
|
||||
self.datensaetze = datensaetze or []
|
||||
|
||||
def ergaenze(self, satz):
|
||||
self.datensaetze.append(satz)
|
||||
return satz
|
||||
|
||||
def render(self, erstellungsdatum=None):
|
||||
"""Zeilen des Bestands (ohne Zeilentrenner)."""
|
||||
if not self.datensaetze:
|
||||
raise ValueError('Datenbestand %s ohne Datensätze' % self.best)
|
||||
heute = erstellungsdatum or date.today()
|
||||
satzlaenge = max(s.satzlaenge for s in self.datensaetze)
|
||||
# Der Vorlaufsatz nimmt die Satzlänge des Bestands an — aber
|
||||
# nie kürzer als seine eigene Struktur (DM-ORG E.2: Vorlauf
|
||||
# trägt alle Herstellfelder; reale Bestände sind ≥ 246).
|
||||
satzlaenge = max(
|
||||
satzlaenge,
|
||||
max(f[2] + f[3] - 1 for f in
|
||||
(L10N_AT_ELDA_IDTEIL + L10N_AT_ELDA_VORLAUF)))
|
||||
zeilen = []
|
||||
vorlauf_struktur = [
|
||||
f for f in (L10N_AT_ELDA_IDTEIL + L10N_AT_ELDA_VORLAUF)
|
||||
if f[2] + f[3] - 1 <= satzlaenge]
|
||||
vorlauf = L10nAtEldaSatz(
|
||||
vorlauf_struktur, satzlaenge,
|
||||
bezeichnung='Vorlaufsatz %s' % self.best)
|
||||
vorlauf.setze('SART', '00')
|
||||
vorlauf.setze('SANR', 1)
|
||||
vorlauf.setze('UVST', 'ED')
|
||||
vorlauf.setze('OBUS', self.obus)
|
||||
vorlauf.setze('VSTR', self.vstr)
|
||||
vorlauf.setze('PROJ', self.projekt)
|
||||
vorlauf.setze('BEST', self.best)
|
||||
vorlauf.setze_datum('EDAT', heute, 'TTMMJJJJ')
|
||||
vorlauf.setze_zeit('EZEI', datetime.now())
|
||||
vorlauf.setze('HRST', (self.hersteller.get('name') or '')[:45])
|
||||
vorlauf.setze('HKFZ', self.hersteller.get('kfz', 'A'))
|
||||
vorlauf.setze('HPLZ', self.hersteller.get('plz'))
|
||||
vorlauf.setze('HORT', self.hersteller.get('ort'))
|
||||
vorlauf.setze('HSTR', self.hersteller.get('strasse'))
|
||||
vorlauf.setze('VERS', self.vers)
|
||||
vorlauf.setze('VNMF', '3.0')
|
||||
zeilen.append(vorlauf.render())
|
||||
for nummer, satz in enumerate(self.datensaetze, start=2):
|
||||
# Datensatz-Generatoren setzen ihre SART selbst; der Bestand
|
||||
# ergänzt Trägerangaben (falls offen) und die laufende
|
||||
# Satznummer (C.1.2: lückenlos ab 1).
|
||||
if not satz.ist_gesetzt('UVST'):
|
||||
satz.setze('UVST', 'ED')
|
||||
if not satz.ist_gesetzt('OBUS'):
|
||||
satz.setze('OBUS', self.obus)
|
||||
if not satz.ist_gesetzt('VSTR'):
|
||||
satz.setze('VSTR', self.vstr)
|
||||
satz._werte['SANR'] = str(nummer).rjust(7, '0')
|
||||
zeilen.append(satz.render())
|
||||
schluss = L10nAtEldaSatz(
|
||||
L10N_AT_ELDA_IDTEIL + L10N_AT_ELDA_SCHLUSS, satzlaenge,
|
||||
bezeichnung='Schlusssatz %s' % self.best)
|
||||
schluss.setze('SART', '99')
|
||||
schluss.setze('SANR', len(self.datensaetze) + 2)
|
||||
schluss.setze('UVST', 'ED')
|
||||
schluss.setze('OBUS', self.obus)
|
||||
schluss.setze('VSTR', self.vstr)
|
||||
schluss.setze('SANZ', len(self.datensaetze) + 2)
|
||||
zeilen.append(schluss.render())
|
||||
return zeilen
|
||||
|
||||
|
||||
class L10nAtEldaDatei:
|
||||
"""ELDA-Datei: Dateiheader (ENCD-Standard ISO-8859-15) + Datenbestände;
|
||||
Rendering in Bytes (Encoding + CRLF-Zeilentrenner, letzte Zeile mit
|
||||
Abschluss-Trenner)."""
|
||||
|
||||
def __init__(self, bestaende=None, header=True):
|
||||
self.bestaende = bestaende or []
|
||||
self.header = header
|
||||
|
||||
def render(self):
|
||||
zeilen = []
|
||||
if self.header:
|
||||
zeilen.append(' 01' + L10N_AT_ELDA_ENCODING.ljust(50))
|
||||
for bestand in self.bestaende:
|
||||
zeilen.extend(bestand.render())
|
||||
roh = L10N_AT_ELDA_ZEILENTRENNER.join(zeilen) \
|
||||
+ L10N_AT_ELDA_ZEILENTRENNER
|
||||
return roh.encode(L10N_AT_ELDA_ENCODING)
|
||||
@@ -0,0 +1,230 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""Fristenkalender (GP8/AP8): alle lohnverrechnungsrelevanten
|
||||
Melde-/Zahlungsfristen je Company und Jahr.
|
||||
|
||||
Kuratierte Fristen (lb-sva-11, lb-mel-08, lb-lvr-04/05, Stand
|
||||
2026-06…08):
|
||||
- SV-Beiträge (mBGM): Einlangen **15. des Folgemonats** (Gutschrift-
|
||||
prinzip; Respirofrist 3 Tage; Verzugszinsen ab dem 16. Tag);
|
||||
Vorschreibebetriebe 7.
|
||||
- DB (FLAF) / DZ / KommSt: je **15. des Folgemonats**.
|
||||
- KommSt-Jahreserklärung: über FinanzOnline **bis Ende März** des
|
||||
Folgejahres.
|
||||
- Lohnzettel L16 (§ 84 EStG): **Ende Februar** des Folgejahres via
|
||||
ELDA (Papierformular ohne technische Voraussetzungen: Ende Jänner);
|
||||
berichtigter Lohnzettel nach steuerlich relevanter Ergänzung des
|
||||
Lohnkontos **binnen 14 Tagen**.
|
||||
- Arbeitsstättenmeldung § 34 Abs 6 ASVG: **Ende Februar** (via ELDA
|
||||
an Statistik Austria).
|
||||
- § 109a-EStG-Meldung: **Ende Februar** (ELDA); Papierformular E 109a
|
||||
+ Kopie an den Entgeltempfänger Ende Jänner (nicht implementiert —
|
||||
Honorar-Basis).
|
||||
- Schwerarbeitsmeldung ASchV § 5: Jänner bis **Ende Februar** via
|
||||
ELDA (nicht implementiert — blockiert, ASchV-Datenmodell fehlt).
|
||||
|
||||
Odoo-Integration: `mail.activity.mixin` für optionale Benachrichtigung
|
||||
bei Überschreitung; native Fristart-Definition mit Sprungaktion zur
|
||||
GP7-Deklaration (L16/mBGM erzeugen); Auto-Erzeugung per ir.cron
|
||||
(monatlich am 1.: offene SV/DB/DZ/KommSt-Fristen des Vormonats;
|
||||
jährlich am 1. Jänner: Jahresfristen des Vorjahres)."""
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
FRISTART_DEFINITIONEN = {
|
||||
'sv_mbgm': {
|
||||
'name': 'SV-Beiträge (mBGM) einlangen',
|
||||
'monatlich': True,
|
||||
'tag': 15,
|
||||
'action': 'mbgm',
|
||||
'hilfetext': '15. des Folgemonats (Gutschriftprinzip; '
|
||||
'Vorschreibebetriebe: 7.). Verzugszinsen ab dem '
|
||||
'16. Tag.',
|
||||
},
|
||||
'db_dz_kommst': {
|
||||
'name': 'DB/DZ/KommSt einlangen',
|
||||
'monatlich': True,
|
||||
'tag': 15,
|
||||
'action': None,
|
||||
'hilfetext': 'FLAF-Dienstgeberbeitrag, Zuschlag zum '
|
||||
'Dienstgeberbeitrag und Kommunalsteuer: je 15. '
|
||||
'des Folgemonats.',
|
||||
},
|
||||
'kommst_jahres': {
|
||||
'name': 'KommSt-Jahreserklärung (FinanzOnline)',
|
||||
'monatlich': False,
|
||||
'monat': 3,
|
||||
'tag_im_monat': 31,
|
||||
'action': None,
|
||||
'hilfetext': 'Jahreserklärung der Kommunalsteuer über '
|
||||
'FinanzOnline bis Ende März des Folgejahres '
|
||||
'(§ 11 Abs 5 KommStG).',
|
||||
},
|
||||
'l16': {
|
||||
'name': 'Lohnzettel L16 (§ 84 EStG)',
|
||||
'monatlich': False,
|
||||
'monat': 2,
|
||||
'tag_im_monat': 28,
|
||||
'action': 'l16',
|
||||
'hilfetext': 'Jahres-Lohnzettel bis Ende Februar des '
|
||||
'Folgejahres via ELDA; Papierformular ohne '
|
||||
'technische Voraussetzungen: Ende Jänner. '
|
||||
'Berichtigter Lohnzettel nach steuerlich '
|
||||
'relevanter Ergänzung des Lohnkontos binnen '
|
||||
'14 Tagen.',
|
||||
},
|
||||
'arbeitsstaette': {
|
||||
'name': 'Arbeitsstättenmeldung (§ 34 Abs 6 ASVG)',
|
||||
'monatlich': False,
|
||||
'monat': 2,
|
||||
'tag_im_monat': 28,
|
||||
'action': 'l16',
|
||||
'hilfetext': 'Adresse der Arbeitsstätte am 31. 12. bis '
|
||||
'Ende Februar des Folgejahres via ELDA (VSTR '
|
||||
'ST). Läuft im GP7-Jahreslauf mit der L16.',
|
||||
},
|
||||
'paragraph_109a': {
|
||||
'name': '§ 109a-EStG-Meldung (Honorare)',
|
||||
'monatlich': False,
|
||||
'monat': 2,
|
||||
'tag_im_monat': 28,
|
||||
'action': None,
|
||||
'hilfetext': 'Meldung der Honorare außerhalb von '
|
||||
'Dienstverhältnissen bis Ende Februar via '
|
||||
'ELDA (8 Tätigkeitsgruppen; kumulativ ≤ 900 € '
|
||||
'und ≤ 450 € je Leistung → entfällt).',
|
||||
},
|
||||
'schwerarbeit': {
|
||||
'name': 'Schwerarbeitsmeldung (ASchV § 5)',
|
||||
'monatlich': False,
|
||||
'monat': 2,
|
||||
'tag_im_monat': 28,
|
||||
'action': None,
|
||||
'hilfetext': 'Jährlich rückwirkend Jänner bis Ende Februar '
|
||||
'via ELDA an den KV-Träger; meldepflichtig '
|
||||
'ab 40 M / 35 F (⚠ blockiert — ASchV-Daten-'
|
||||
'modell fehlt im Modul).',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class L10nAtPayrollFrist(models.Model):
|
||||
_name = 'l10n.at.payroll.frist'
|
||||
_description = 'Lohnverrechnungs-Fristenkalender'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'deadline, id'
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True,
|
||||
default=lambda self: self.env.company, index=True)
|
||||
fristart = fields.Selection(
|
||||
[(k, v['name']) for k, v in FRISTART_DEFINITIONEN.items()],
|
||||
required=True, index=True)
|
||||
jahr = fields.Integer(
|
||||
required=True, string='Bezugs-/Kalenderjahr',
|
||||
help='Beitragsjahr (monatliche Fristen) bzw. gemeldetes '
|
||||
'Jahr (Jahresfristen: L16/Arbeitsstätte/§-109a gelten '
|
||||
'für das Vorjahr).')
|
||||
monat = fields.Integer(
|
||||
string='Beitragsmonat (monatliche Fristen)',
|
||||
help='1–12; leer bei Jahresfristen.')
|
||||
deadline = fields.Date(required=True, string='Frist')
|
||||
state = fields.Selection(
|
||||
[('offen', 'Offen'), ('erledigt', 'Erledigt'),
|
||||
('ueberfaellig', 'Überfällig')],
|
||||
compute='_compute_state', store=True, index=True)
|
||||
erledigt_am = fields.Date(readonly=True)
|
||||
hilfetext = fields.Text(readonly=True)
|
||||
ref_l16_id = fields.Many2one(
|
||||
'l10n.at.payroll.l16', string='L16-Deklaration',
|
||||
help='Sprungziel für Fristen mit L16/Arbeitsstätten-Meldung.')
|
||||
|
||||
_sql_constraints = [(
|
||||
'frist_company_art_jahr_monat_uniq',
|
||||
'unique(company_id, fristart, jahr, monat)',
|
||||
'Diese Frist existiert bereits für das Unternehmen.')]
|
||||
|
||||
@api.depends('deadline', 'erledigt_am')
|
||||
def _compute_state(self):
|
||||
heute = fields.Date.today()
|
||||
for frist in self:
|
||||
if frist.erledigt_am:
|
||||
frist.state = 'erledigt'
|
||||
elif frist.deadline and frist.deadline < heute:
|
||||
frist.state = 'ueberfaellig'
|
||||
else:
|
||||
frist.state = 'offen'
|
||||
|
||||
def action_erledigt(self):
|
||||
self.write({'erledigt_am': fields.Date.today()})
|
||||
return True
|
||||
|
||||
def action_wieder_eroeffnen(self):
|
||||
self.write({'erledigt_am': False})
|
||||
return True
|
||||
|
||||
def action_zum_objekt(self):
|
||||
"""Sprung zur GP7-Deklaration (L16 oder mBGM)."""
|
||||
self.ensure_one()
|
||||
if self.ref_l16_id:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'l10n.at.payroll.l16',
|
||||
'res_id': self.ref_l16_id.id,
|
||||
'view_mode': 'form',
|
||||
}
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def _cron_fristen_erzeugen(self):
|
||||
"""Monatlich am 1.: offene Fristen des Vormonats (SV/DB/DZ/
|
||||
KommSt) + jährlich am 1. Jänner: Jahresfristen des
|
||||
Vorjahres. Idempotent (Unique-Constraint)."""
|
||||
heute = fields.Date.today()
|
||||
vormonat = (heute.replace(day=1) - timedelta(days=1))
|
||||
for company in self.env['res.company'].search([]):
|
||||
# Monatliche Fristen des Vormonats (15. des Folgemonats).
|
||||
folgemonat = heute.replace(day=1)
|
||||
deadline_monat = date(
|
||||
folgemonat.year, folgemonat.month,
|
||||
FRISTART_DEFINITIONEN['sv_mbgm']['tag'])
|
||||
for art in ('sv_mbgm', 'db_dz_kommst'):
|
||||
self._sicherstellen(
|
||||
company, art, vormonat.year, vormonat.month,
|
||||
deadline_monat)
|
||||
# Jahresfristen nur am 1. Jänner (für das Vorjahr).
|
||||
if heute.month == 1:
|
||||
for art in ('l16', 'arbeitsstaette', 'paragraph_109a',
|
||||
'schwerarbeit'):
|
||||
monat = FRISTART_DEFINITIONEN[art]['monat']
|
||||
tag = FRISTART_DEFINITIONEN[art]['tag_im_monat']
|
||||
jahr_deadline = heute.year
|
||||
deadline_jahr = date(jahr_deadline, monat, tag)
|
||||
if deadline_jahr.month == 2 and tag > 28:
|
||||
deadline_jahr = date(jahr_deadline, monat, 28)
|
||||
self._sicherstellen(
|
||||
company, art, heute.year - 1, None,
|
||||
deadline_jahr)
|
||||
# KommSt-Jahreserklärung (Ende März).
|
||||
self._sicherstellen(
|
||||
company, 'kommst_jahres', heute.year - 1, None,
|
||||
date(heute.year, 3, 31))
|
||||
|
||||
@api.model
|
||||
def _sicherstellen(self, company, art, jahr, monat, deadline):
|
||||
vals = {
|
||||
'company_id': company.id,
|
||||
'fristart': art,
|
||||
'jahr': jahr,
|
||||
'monat': monat,
|
||||
'deadline': deadline,
|
||||
'hilfetext': FRISTART_DEFINITIONEN[art]['hilfetext'],
|
||||
}
|
||||
bestehend = self.search([
|
||||
('company_id', '=', company.id),
|
||||
('fristart', '=', art),
|
||||
('jahr', '=', jahr),
|
||||
('monat', '=', monat)])
|
||||
if not bestehend:
|
||||
self.create(vals)
|
||||
return bestehend or self.env['l10n.at.payroll.frist']
|
||||
@@ -0,0 +1,152 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""Jahres-Retro-/Aufrollungslauf (GP8/AP8): Kontrollsechstel-
|
||||
Verpflichtungslauf (§ 67 Abs 10 EStG) und optionale §-77-Abs-3/4-
|
||||
Aufrollung mit YTD-Rekalkulation.
|
||||
|
||||
Kuratierte Grundlagen (lb-lvr-07, lb-naz-01…04, 2026-06/08):
|
||||
- **Kontrollsechstel** (§ 67 Abs 10): sonstige Bezüge innerhalb des
|
||||
Jahressechstels sind mit Fixsätzen versteuert; bei Überhang wird
|
||||
die **Differenz tarifisch** versteuert — der Dezember-/
|
||||
Beendigungsabrechnungslauf rechnet die SZ-Steuer mit dem Tarif neu
|
||||
(**Günstig-Aufrollung** — GP3-Engine kann beides bereits).
|
||||
- **§ 77 Abs 3/4**: optionale Aufrollung der Lohnsteuer des
|
||||
Kalenderjahres beim Jahreswechsel (15.-Jänner-Grenze;
|
||||
Krankengeldbezug im Jahr blockt Abs 3/4, nicht Abs 4a). Die
|
||||
Aufrollung rechnet die laufende Lohnsteuer kumulativ neu und
|
||||
verteilt die Differenz auf die verbleibenden Monate.
|
||||
- **§ 77 Abs 4a**: Aufrollung nach §-67-Ausschüttungen (nachträg-
|
||||
liche SZ-Steuer mit Günstig-Vergleich).
|
||||
- **15.2.-Cutoff** (lb-naz-02/03): Zahlung bis 15. 2. des Folgejahres
|
||||
→ Vorjahreszuordnung (berichtigter Lohnzettel binnen 14 Tagen);
|
||||
danach keine Zuordnung mehr (§ 67 Abs 8 lit c Fünftel oder
|
||||
§ 67 Abs 1 Tarif).
|
||||
- **SV rollt stets auf den Beitragsmonat der Fälligkeit** (GP4:
|
||||
`l10n_at_beitragsmonat` je Regel/Linie, historisch versionierte
|
||||
Sätze/HBG). mBGM-Storno/Neumeldung als Folgeaktion (GP7 `art='ST'`).
|
||||
|
||||
Odoo-Mechanik: der Wizard **erzeugt keine eigenen Korrekturdaten** —
|
||||
er analysiert die Jahresdaten, zeigt den Handlungsbedarf und
|
||||
empfiehlt die Odoo-native Korrektur-Pipeline (Refund-Payslips über
|
||||
`_action_refund_payslips()` + Neuberechnung des betroffenen Monats
|
||||
mit korrigierten Eingangsdaten; die GP3-Engine rechnet YTD
|
||||
automatisch um)."""
|
||||
from datetime import date
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class L10nAtPayrollJahresretroWizard(models.TransientModel):
|
||||
_name = 'l10n.at.payroll.jahresretro.wizard'
|
||||
_description = 'Jahres-Retro-/Aufrollungslauf (GP8)'
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True,
|
||||
default=lambda self: self.env.company)
|
||||
jahr = fields.Integer(
|
||||
required=True, default=lambda self: fields.Date.today().year - 1,
|
||||
string='Jahr (Kontroll-/Aufrollungslauf)')
|
||||
zeige_detail = fields.Boolean(
|
||||
default=True, string='Detail je Mitarbeiter anzeigen')
|
||||
nur_kontrollsechstel = fields.Boolean(
|
||||
default=True, string='Nur Kontrollsechstel-Verpflichtungslauf '
|
||||
'prüfen (§ 67 Abs 10)')
|
||||
aufrollung_77 = fields.Boolean(
|
||||
default=False,
|
||||
string='§-77-Abs-3/4-Aufrollung prüfen',
|
||||
help='Optionale Aufrollung der laufenden Lohnsteuer des '
|
||||
'Kalenderjahres (nur ohne Krankengeldbezug; 15.-Jänner-'
|
||||
'Grenze für die Zahlung; Abs 4a nach §-67-Ausschüttungen '
|
||||
'unabhängig prüfbar).')
|
||||
ergebnis_ids = fields.One2many(
|
||||
'l10n.at.payroll.jahresretro.ergebnis',
|
||||
'wizard_id', string='Prüfergebnis je Mitarbeiter')
|
||||
zusammenfassung = fields.Text(readonly=True)
|
||||
|
||||
def action_pruefen(self):
|
||||
for wizard in self:
|
||||
wizard.ergebnis_ids.unlink()
|
||||
slips = wizard._slips_des_jahres()
|
||||
ergebnisse = []
|
||||
for slip in slips:
|
||||
ergebnis = wizard._pruefe_mitarbeiter(slip)
|
||||
if ergebnis:
|
||||
ergebnisse.append(ergebnis)
|
||||
wizard.ergebnis_ids = [
|
||||
(0, 0, e) for e in ergebnisse]
|
||||
wizard.zusammenfassung = wizard._zusammenfassung()
|
||||
return True
|
||||
|
||||
def _slips_des_jahres(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')),
|
||||
]).sorted('date_from')
|
||||
|
||||
def _pruefe_mitarbeiter(self, slip):
|
||||
"""Prüft Kontrollsechstel-Überhang und optional §-77-Aufrollung
|
||||
je AN. Liefert None, wenn kein Handlungsbedarf."""
|
||||
self.ensure_one()
|
||||
sechstel = slip._l10n_at_sechstel()
|
||||
sonstige_basis = slip._l10n_at_brutto('sonstig')
|
||||
laufende_basis = slip._l10n_at_brutto('laufend')
|
||||
# §-67-Bezüge über dem Jahressechstel → Überhang.
|
||||
if self.nur_kontrollsechstel \
|
||||
and sonstige_basis > sechstel:
|
||||
ueberhang = sonstige_basis - sechstel
|
||||
fixsatz_st = slip._l10n_at_sz_tarifisch_aktuell()
|
||||
tarif_st = slip._l10n_at_lst_sonstige()
|
||||
gut = fixsatz_st <= tarif_st
|
||||
return {
|
||||
'employee_id': slip.employee_id.id,
|
||||
'kontrollsechstel_ueberhang': ueberhang,
|
||||
'fixsatz_steuer': fixsatz_st,
|
||||
'tarif_steuer': tarif_st,
|
||||
'guenstig': gut,
|
||||
'handlung': (
|
||||
'Keine Korrektur — Fixsätze sind günstiger'
|
||||
if gut else
|
||||
'Aufrollung: Dezemberlauf rechnet die SZ-Steuer '
|
||||
'mit dem Tarif neu (§ 67 Abs 10 Günstig-Aufrollung). '
|
||||
'SV rollt auf den Beitragsmonat (GP4); mBGM-Storno '
|
||||
'(GP7) wenn der Beitragsmonat wechselt.'),
|
||||
}
|
||||
return None
|
||||
|
||||
def _zusammenfassung(self):
|
||||
self.ensure_one()
|
||||
ohne = self.ergebnis_ids.filtered(lambda r: r.guenstig)
|
||||
mit = self.ergebnis_ids.filtered(lambda r: not r.guenstig)
|
||||
return (
|
||||
'Kontrollsechstel-Prüfung %d: %d Mitarbeiter mit '
|
||||
'Überhang, davon %d mit Günstig-Aufrollung (= Tarif '
|
||||
'günstiger als Fixsätze → Korrekturlauf).\n'
|
||||
'§-77-Prüfung: %s.'
|
||||
% (self.jahr, len(self.ergebnis_ids), len(mit),
|
||||
'aktiviert' if self.aufrollung_77 else 'deaktiviert'))
|
||||
|
||||
|
||||
class L10nAtPayrollJahresretroErgebnis(models.TransientModel):
|
||||
_name = 'l10n.at.payroll.jahresretro.ergebnis'
|
||||
_description = 'Jahres-Retro — Prüfergebnis je AN'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'l10n.at.payroll.jahresretro.wizard', required=True,
|
||||
ondelete='cascade')
|
||||
employee_id = fields.Many2one('hr.employee', required=True)
|
||||
kontrollsechstel_ueberhang = fields.Monetary(
|
||||
currency_field='currency_id')
|
||||
fixsatz_steuer = fields.Monetary(
|
||||
currency_field='currency_id',
|
||||
string='SZ-Steuer (Fixsätze)')
|
||||
tarif_steuer = fields.Monetary(
|
||||
currency_field='currency_id',
|
||||
string='SZ-Steuer (Tarif)')
|
||||
guenstig = fields.Boolean(
|
||||
string='Fixsätze günstiger (keine Korrektur)')
|
||||
handlung = fields.Text(string='Handlungsempfehlung')
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', related='wizard_id.company_id.currency_id')
|
||||
@@ -0,0 +1,104 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""Kind-Datenmodell (GP7): Kinder je Mitarbeiter für die Wartung des
|
||||
Familienbonus Plus und die Befüllung des L16-Kinderblocks
|
||||
(Lohnzettel-Finanz-Mitteilungssatz, DM-ORG 42.7.0 E.14, Felder
|
||||
Nr. 196–209: je Kind 123 Stellen — KFAM/KVON/KSTAAT/KSTWE/KVSNR/
|
||||
KGEBD/KAFBZ/KAPFB/KAUHZ/KBGFP/KEGFP/KBHFP/KEHFP).
|
||||
|
||||
Nutzer-Entscheidung 2026-09-12: die FB+-Steuerwirkung selbst ist KEIN
|
||||
Bestandteil der Personalverrechnung (bei Unterschreiten der Steuer auf 0
|
||||
erfolgt die Geltendmachung über die Arbeitnehmerveranlagung) — das Modell
|
||||
dient der Stammdaten-Wartung (Anspruch, Monate, halber Bonus) und der
|
||||
L16-Meldung. Monatsfelder im Format MM (Kinderblock 2n); die
|
||||
Betragsermittlung (FABO) rechnet der L16-Generator aus den gepflegten
|
||||
Monaten und der Altersstaffel (§ 33 Abs 5 EStG: bis 18. Geburtstag
|
||||
166,68 €/Monat, darüber 58,34 €/Monat solange Familienbeihilfe)."""
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
l10n_at_payroll_kind_ids = fields.One2many(
|
||||
'l10n.at.payroll.kind', 'employee_id',
|
||||
string='Kinder (Familienbonus Plus)',
|
||||
groups="hr_payroll.group_hr_payroll_user")
|
||||
|
||||
|
||||
class L10nAtPayrollKind(models.Model):
|
||||
_name = 'l10n.at.payroll.kind'
|
||||
_description = 'Kind (Familienbonus Plus / Lohnzettel L16)'
|
||||
_order = 'geburtstag, id'
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee', string='Mitarbeiter:in', required=True,
|
||||
index=True, ondelete='cascade')
|
||||
familienname = fields.Char(
|
||||
string='Familienname (KFAM)', required=True)
|
||||
vorname = fields.Char(string='Vorname (KVON)')
|
||||
geburtstag = fields.Date(
|
||||
string='Geburtsdatum (KGEBD)', required=True,
|
||||
help='Maßgeblich für die FB+-Staffel (bis zum 18. Geburtstag '
|
||||
'166,68 €/Monat, danach 58,34 €/Monat solange '
|
||||
'Familienbeihilfe) und die Altersfilter (z. B. '
|
||||
'Schwerarbeitsmeldung 40 M/35 F).')
|
||||
versicherungsnummer = fields.Char(
|
||||
string='Versicherungsnummer (KVSNR)',
|
||||
help='10-stellig, LLLPTTMMJJ; nur wenn zugeteilt.')
|
||||
staatsangehoerigkeit = fields.Char(
|
||||
string='Staatsangehörigkeit (KSTAAT, 3-stelliger Code)')
|
||||
staatsangehoerigkeit_2 = fields.Char(
|
||||
string='2. Staatsangehörigkeit (KSTWE, 1-stelliger Code)')
|
||||
# ------------------------------------------------------------------
|
||||
# Familienbonus Plus — Wartung (L16-Kinderblock)
|
||||
# ------------------------------------------------------------------
|
||||
fab_plus = fields.Boolean(
|
||||
string='Familienbonus Plus geltend gemacht',
|
||||
help='L16 KFABO (Anzahl) und FABO (Betrag) ergeben sich aus den '
|
||||
'Kindern mit gesetztem FB+-Kennzeichen.')
|
||||
fab_monat_von = fields.Integer(
|
||||
string='FB+ ab Monat (KBGFP)',
|
||||
help='Monat (1–12), ab dem der volle Familienbonus Plus für '
|
||||
'dieses Kind geltend gemacht wurde (L16 KBGFP).')
|
||||
fab_monat_bis = fields.Integer(
|
||||
string='FB+ bis Monat (KEGFP)',
|
||||
help='Letzter Monat (1–12) des vollen Familienbonus Plus (L16 '
|
||||
'KEGFP).')
|
||||
fab_halb_monat_von = fields.Integer(
|
||||
string='Halber FB+ ab Monat (KBHFP)',
|
||||
help='Monat (1–12), ab dem nur mehr der halbe Familienbonus Plus '
|
||||
'geltend gemacht wurde (Aufteilung zwischen den '
|
||||
'Elternteilen; L16 KBHFP).')
|
||||
fab_halb_monat_bis = fields.Integer(
|
||||
string='Halber FB+ bis Monat (KEHFP)',
|
||||
help='Letzter Monat (1–12) des halben Familienbonus Plus (L16 '
|
||||
'KEHFP).')
|
||||
|
||||
def _l10n_at_fab_monate(self, jahr):
|
||||
"""[(Monat, Anteil 1.0/0.5), …] des FB+ im Lohnzetteljahr aus den
|
||||
gepflegten Monatsfeldern (voll + halb, Überschneidung: der
|
||||
spätere Eintrag gewinnt)."""
|
||||
self.ensure_one()
|
||||
monate = {}
|
||||
von, bis = self.fab_monat_von or 0, self.fab_monat_bis or 0
|
||||
if von and bis and 1 <= von <= bis <= 12:
|
||||
for monat in range(von, bis + 1):
|
||||
monate[monat] = 1.0
|
||||
hvon, hbis = (self.fab_halb_monat_von or 0,
|
||||
self.fab_halb_monat_bis or 0)
|
||||
if hvon and hbis and 1 <= hvon <= hbis <= 12:
|
||||
for monat in range(hvon, hbis + 1):
|
||||
monate[monat] = 0.5
|
||||
return sorted(monate.items())
|
||||
|
||||
def _l10n_at_fab_betrag(self, jahr, voll=166.68, vermindert=58.34):
|
||||
"""FB+-Jahresbetrag je Kind aus Monaten × Monatsrate; die Rate
|
||||
wechselt mit dem 18. Geburtstag (monatliche Staffel: Monate vor
|
||||
dem Geburtsmonat + 18 Jahre voller Satz, danach verminderter
|
||||
Satz — § 33 Abs 5 EStG, solange Familienbeihilfe)."""
|
||||
self.ensure_one()
|
||||
if not self.geburtstag or not self.fab_plus:
|
||||
return 0.0
|
||||
umschalt = self.geburtstag.month
|
||||
return sum(anteil * (vermindert if monat >= umschalt else voll)
|
||||
for monat, anteil in self._l10n_at_fab_monate(jahr))
|
||||
@@ -0,0 +1,774 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""Lohnzettel Finanz (L16) und Arbeitsstättenmeldung (GP7/AP5) —
|
||||
Jahresdeklaration nach DM-ORG 42.7.0 (lokal `.firecrawl/ris/elda/`).
|
||||
|
||||
- **L16** (BEST `LF`, zuständiger Träger VSTR `94` Bundesrechenzentrum):
|
||||
Informationssatz I1 (SART `I1`, Länge 1100, Strukturversion 03) +
|
||||
Mitteilungssatz L1 (SART `L1`, Länge 3500, Lohnzettelversion 28 —
|
||||
zwingend ab 01.03.2026). KZ-Struktur nach lb-lvr-04 (✅
|
||||
RECHTSQUELLEN-Privat Abschn. 10): **KZ 245 = 210 − 215 − 220 − 230 −
|
||||
243**; die KZ-Werte sind aus den Kern-Flags (`l10n_at_bemessung`,
|
||||
`steuerbefreiung_68`, `s67_abs`) und Regelcodes ableitbar — bewusst
|
||||
KEIN paralleles KZ-Klassifikationsfeld (Widerspruchsrisiko zur
|
||||
GP3-Steuerlogik).
|
||||
- **Arbeitsstättenmeldung** § 34 Abs 6 ASVG (BEST `AD`, VSTR `ST`
|
||||
Statistik Austria): Satzart 45 (Länge 300, Version 02) je AN mit der
|
||||
Adresse der Arbeitsstätte am 31. 12. — läuft im selben Jahreslauf
|
||||
(Nutzer-Entscheidung 5.1; Frist gemeinsam Ende Februar).
|
||||
- Familienbonus Plus: **keine** Personalverrechnungs-Komponente
|
||||
(Unterschreiten der Steuer auf 0 → Arbeitnehmerveranlagung;
|
||||
Nutzer-Entscheidung 2026-09-12); die FB+-Felder des L1
|
||||
(BFABO/KFABO/VFABO/FABO + Kinderblock) werden aus der
|
||||
Stammdaten-Wartung am Kind-Modell (`l10n.at.payroll.kind`) befüllt.
|
||||
- Dateibasiert (Prämisse P7): Generierung + formale Prüfung als
|
||||
Prüfprotokoll; Übertragung/ELDA-Konto beim Mandanten.
|
||||
|
||||
Satzstrukturen: PDF-verifizierte Extraktion 2026-09-12 (DM-ORG 42.7.0,
|
||||
Kapitel E.13/E.14/E.20; Positions-Ketten lückenlos geprüft)."""
|
||||
from datetime import date, datetime
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from .elda import (
|
||||
L10N_AT_ELDA_IDTEIL,
|
||||
L10nAtEldaBestand,
|
||||
L10nAtEldaDatei,
|
||||
L10nAtEldaSatz,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def l10n_at_namen(employee):
|
||||
"""(Familienname, Vorname) aus dem Odoo-Gesamtnamen: Split am
|
||||
letzten Leerzeichen (österreichische Schreibweise „Vorname
|
||||
Nachname“; mehrgliedrige Familiennamen werden als ein Feld
|
||||
geführt — eSV FANA/VONA-Verifikation im Parallellauf)."""
|
||||
name = (employee.name or '').strip()
|
||||
if ' ' in name:
|
||||
vorname, familienname = name.rsplit(' ', 1)
|
||||
return familienname, vorname
|
||||
return name, ''
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Informationssatz I1 (E.13, S. 219–220; Länge 1100) — Datenteil
|
||||
# ------------------------------------------------------------------
|
||||
L16_INFORMATIONSSATZ = [
|
||||
(2, 'FSART', 21, 1, 'a'), # I
|
||||
(3, 'CLEAR', 22, 6, 'a'), # Clearingstelle (RADAUS/OESTAT)
|
||||
(4, 'CLDVR', 28, 7, 'a'),
|
||||
(6, 'STNRL', 35, 9, 'n'), # Steuernummer Lohnverrechnungsstelle
|
||||
(7, 'DVRNL', 44, 7, 'n'),
|
||||
(9, 'STNRA', 51, 9, 'n'), # Steuernummer Arbeitgeber
|
||||
(10, 'DVRNA', 60, 7, 'n'),
|
||||
(11, 'ARTD', 67, 2, 'a'), # LZ = Lohnzetteldaten
|
||||
(12, 'DTUE', 69, 8, 'n'), # JJJJMMTT
|
||||
(13, 'ZTUE', 77, 6, 'n'), # HHMMSS
|
||||
(14, 'STVE', 83, 2, 'n'), # Strukturversion 03
|
||||
(15, 'ANAM', 85, 37, 'a'), # AG-Name
|
||||
(16, 'ATIT', 122, 20, 'a'),
|
||||
(17, 'AADR', 142, 37, 'a'),
|
||||
(18, 'ALKZ', 179, 3, 'a'),
|
||||
(19, 'APLZ', 182, 10, 'a'),
|
||||
(20, 'AORT', 192, 30, 'a'),
|
||||
(21, 'INTE', 222, 11, 'a'),
|
||||
(22, 'JAHR', 233, 4, 'n'), # Mitteilungszeitraum JJJJ
|
||||
(23, 'GESA', 237, 7, 'n'), # Gesamtanzahl Mitteilungen im Jahr
|
||||
(24, 'ANZA', 244, 7, 'n'), # Anzahl der aktuellen Übermittlung
|
||||
(25, 'ATEL', 251, 16, 'a'),
|
||||
(26, 'AFAX', 267, 16, 'a'),
|
||||
(27, 'RESE', 283, 818, 'a'),
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Mitteilungssatz L1 (E.14, S. 223–233; Länge 3500) — Datenteil inkl.
|
||||
# RESE-Grundstellungsfeldern (n-Felder Grundstellung 0!)
|
||||
# ------------------------------------------------------------------
|
||||
L16_MITTEILUNGSSATZ = [
|
||||
(2, 'FSART', 21, 1, 'a'), # L
|
||||
(3, 'CLADR', 22, 6, 'a'),
|
||||
(4, 'CLDVR', 28, 7, 'a'),
|
||||
(6, 'STNRL', 35, 9, 'n'),
|
||||
(7, 'DVRNL', 44, 7, 'n'),
|
||||
(8, 'ARTU', 51, 1, 'a'), # Art der Übermittlung (Prüfkatalog)
|
||||
(9, 'DTUE', 52, 8, 'n'), # JJJJMMTT, wie I1
|
||||
(10, 'ZTUE', 60, 6, 'n'), # wie I1
|
||||
(11, 'REFN', 66, 30, 'a'), # je AG+Jahr+DV eindeutig
|
||||
(12, 'ARTL', 96, 2, 'n'), # Art des Lohnzettels (D.31)
|
||||
(13, 'FEHL', 98, 20, 'a'), # Clearingstelle
|
||||
(14, 'STAT', 118, 1, 'a'), # Clearingstelle
|
||||
(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, 'SOZS', 132, 1, 'n'), # soziale Stellung (D.33)
|
||||
(20, 'AVLN', 133, 4, 'n'), # VSNR LLLP
|
||||
(21, 'AGBD', 137, 6, 'n'), # TTMMJJ
|
||||
(22, 'ANAM', 143, 37, 'a'),
|
||||
(23, 'ATIT', 180, 20, 'a'),
|
||||
(24, 'AADR', 200, 37, 'a'),
|
||||
(25, 'ALKZ', 237, 3, 'a'),
|
||||
(26, 'APLZ', 240, 10, 'a'),
|
||||
(27, 'AORT', 250, 30, 'a'),
|
||||
(28, 'GESW', 280, 1, 'a'),
|
||||
(29, 'GESM', 281, 1, 'a'),
|
||||
(30, 'GESD', 282, 1, 'a'),
|
||||
(31, 'VOLL', 283, 1, 'a'), # 'J' überwiegend Vollzeit
|
||||
(32, 'TEIL', 284, 1, 'a'), # 'J' überwiegend Teilzeit
|
||||
(33, 'AVAB', 285, 1, 'a'), # Alleinerzieherabsetzbetrag
|
||||
(34, 'PVLN', 286, 4, 'n'), # Partner-VSNR LLLP (AVB)
|
||||
(35, 'PGBD', 290, 6, 'n'), # TTMMJJ (AVB)
|
||||
(36, 'AEAB', 296, 1, 'a'), # Alleinverdienerabsetzbetrag
|
||||
(37, 'V210', 297, 1, 'a'),
|
||||
(38, 'B210', 298, 10, 'n'), # KZ 210 § 25 (Cent)
|
||||
(39, 'V215', 308, 1, 'a'),
|
||||
(40, 'B215', 309, 10, 'n'), # KZ 215 § 68-steuerfrei
|
||||
(41, 'V220', 319, 1, 'a'),
|
||||
(42, 'B220', 320, 10, 'n'), # KZ 220 § 67 Abs 1/2/5 (inkl. 620-€)
|
||||
(43, 'VIEB', 330, 1, 'a'),
|
||||
(44, 'BIEB', 331, 10, 'n'), # insgesamt einbehaltene Beiträge
|
||||
(45, 'V225', 341, 1, 'a'),
|
||||
(46, 'B225', 342, 10, 'n'), # KZ 225 SV zu KZ 220
|
||||
(47, 'V226', 352, 1, 'a'),
|
||||
(48, 'B226', 353, 10, 'n'), # KZ 226 SV zu § 67 Abs 3–8
|
||||
(49, 'V230', 363, 1, 'a'),
|
||||
(50, 'B230', 364, 10, 'n'), # KZ 230 laufende SV/AK/WF
|
||||
(51, 'RESE51', 374, 1, 'a'),
|
||||
(52, 'RESE52', 375, 10, 'n'),
|
||||
(53, 'VAUS', 385, 1, 'a'),
|
||||
(54, 'BAUS', 386, 10, 'n'),
|
||||
(55, 'VPEN', 396, 1, 'a'),
|
||||
(56, 'BPEN', 397, 10, 'n'),
|
||||
(57, 'VEFB', 407, 1, 'a'),
|
||||
(58, 'BEFB', 408, 10, 'n'),
|
||||
(59, 'VSTF', 418, 1, 'a'),
|
||||
(60, 'BSTF', 419, 10, 'n'),
|
||||
(61, 'VSSB', 429, 1, 'a'),
|
||||
(62, 'BSSB', 430, 10, 'n'),
|
||||
(63, 'V243', 440, 1, 'a'),
|
||||
(64, 'B243', 441, 10, 'n'), # KZ 243 sonstige steuerfreie
|
||||
(65, 'V245', 451, 1, 'a'),
|
||||
(66, 'B245', 452, 10, 'n'), # KZ 245 steuerpflichtige Bezüge
|
||||
(67, 'VIEL', 462, 1, 'a'),
|
||||
(68, 'BIEL', 463, 10, 'n'), # insgesamt einbehaltene Lohnsteuer
|
||||
(69, 'VABL', 473, 1, 'a'),
|
||||
(70, 'BABL', 474, 10, 'n'), # abzügliche Lohnsteuer (Fixsätze)
|
||||
(71, 'V260', 484, 1, 'a'),
|
||||
(72, 'B260', 485, 10, 'n'), # KZ 260 anrechenbare Lohnsteuer
|
||||
(73, 'VSOB', 495, 1, 'a'),
|
||||
(74, 'BSOB', 496, 10, 'n'),
|
||||
(75, 'VFB1', 506, 1, 'a'),
|
||||
(76, 'BFB1', 507, 10, 'n'),
|
||||
(77, 'RESE77', 517, 10, 'a'),
|
||||
(78, 'VFB2', 527, 1, 'a'),
|
||||
(79, 'BFB2', 528, 10, 'n'),
|
||||
(80, 'VAUF', 538, 1, 'a'),
|
||||
(81, 'BAUF', 539, 10, 'n'),
|
||||
(82, 'VFB3', 549, 1, 'a'),
|
||||
(83, 'BFB3', 550, 10, 'n'),
|
||||
(84, 'VNEB', 560, 1, 'a'),
|
||||
(85, 'BNEB', 561, 10, 'n'),
|
||||
(86, 'PVON', 571, 2, 'n'),
|
||||
(87, 'PBIS', 573, 2, 'n'),
|
||||
(88, 'VPFG', 575, 1, 'a'),
|
||||
(89, 'BPFG', 576, 10, 'n'),
|
||||
(90, 'VSFB', 586, 1, 'a'),
|
||||
(91, 'BSFB', 587, 10, 'n'),
|
||||
(92, 'RESE92', 597, 1, 'a'),
|
||||
(93, 'RESE93', 598, 10, 'n'),
|
||||
(94, 'RESE94', 608, 1, 'a'),
|
||||
(95, 'RESE95', 609, 10, 'n'),
|
||||
(96, 'RESE96', 619, 1, 'a'),
|
||||
(97, 'RESE97', 620, 10, 'n'),
|
||||
(98, 'RESE98', 630, 1, 'a'),
|
||||
(99, 'RESE99', 631, 8, 'n'),
|
||||
(101, 'STNRA', 639, 9, 'n'), # auszahlende Stelle (Pensionen)
|
||||
(102, 'DVRNA', 648, 7, 'n'),
|
||||
(103, 'ANME', 655, 1, 'a'), # Unterbrechung LZZR
|
||||
(104, 'KORR', 656, 1, 'a'), # 'K' berichtigter Lohnzettel
|
||||
(105, 'VUEB', 657, 1, 'a'),
|
||||
(106, 'BUEB', 658, 10, 'n'),
|
||||
(107, 'INTE', 668, 11, 'a'),
|
||||
(108, 'WOBD', 679, 12, 'a'), # Ordnungsbegriff DG (Personalnummer)
|
||||
(109, 'ZOBD', 691, 15, 'a'),
|
||||
(110, 'ANZK', 706, 2, 'n'), # Anzahl Kinder § 106
|
||||
(111, 'RESE111', 708, 1, 'a'),
|
||||
(112, 'RESE112', 709, 1, 'a'),
|
||||
(113, 'RESE113', 710, 1, 'a'),
|
||||
(114, 'RESE114', 711, 1, 'a'),
|
||||
(115, 'RESE115', 712, 1, 'a'),
|
||||
(116, 'RESE116', 713, 1, 'a'),
|
||||
(117, 'RESE117', 714, 1, 'a'),
|
||||
(118, 'RESE118', 715, 1, 'a'),
|
||||
(119, 'RESE119', 716, 1, 'a'),
|
||||
(120, 'RESE120', 717, 1, 'a'),
|
||||
(121, 'RESE121', 718, 1, 'a'),
|
||||
(122, 'RESE122', 719, 1, 'a'),
|
||||
(123, 'VAPK', 720, 1, 'a'),
|
||||
(124, 'BAPK', 721, 10, 'n'),
|
||||
(125, 'EPAB', 731, 1, 'a'),
|
||||
(126, 'VENT', 732, 1, 'a'),
|
||||
(127, 'ENTW', 733, 10, 'n'),
|
||||
(128, 'VPRE', 743, 1, 'a'),
|
||||
(129, 'PREI', 744, 10, 'n'),
|
||||
(130, 'WERK', 754, 2, 'n'),
|
||||
(131, 'VPEND', 756, 1, 'a'),
|
||||
(132, 'BPEND', 757, 10, 'n'),
|
||||
(134, 'UEBMO', 767, 2, 'n'),
|
||||
(135, 'RESE135', 769, 1, 'a'),
|
||||
(136, 'RESE136', 770, 10, 'n'),
|
||||
(137, 'LDKZA', 780, 3, 'a'),
|
||||
(138, 'VWBKB', 783, 1, 'a'),
|
||||
(139, 'WBKB', 784, 10, 'n'),
|
||||
(140, 'RESE140', 794, 1, 'a'),
|
||||
(141, 'RESE141', 795, 10, 'a'),
|
||||
(142, 'ERVAB', 805, 1, 'a'),
|
||||
(143, 'BFABO', 806, 1, 'a'), # Familienbonus Plus 'J'
|
||||
(144, 'KFABO', 807, 2, 'n'), # Anzahl Kinder FB+
|
||||
(145, 'VFABO', 809, 1, 'a'),
|
||||
(146, 'FABO', 810, 10, 'n'), # FB+-Betrag
|
||||
(147, 'HOTA', 820, 3, 'n'), # Telearbeitstage
|
||||
(148, 'VHOPA', 823, 1, 'a'),
|
||||
(149, 'HOPA', 824, 10, 'n'), # Homeoffice-Pauschale § 26 Z 9 lit a
|
||||
(150, 'VKOUN', 834, 1, 'a'),
|
||||
(151, 'KOUN', 835, 10, 'n'),
|
||||
(152, 'VMAGB', 845, 1, 'a'),
|
||||
(153, 'MAGB', 846, 10, 'n'),
|
||||
(154, 'FLABZ', 856, 1, 'a'), # freiwilliger Lohnsteuerabzug § 47
|
||||
(155, 'AUEZG', 857, 1, 'a'),
|
||||
(156, 'VTEPR', 858, 1, 'a'),
|
||||
(157, 'TEPR', 859, 10, 'n'),
|
||||
(158, 'VMAPR', 869, 1, 'a'),
|
||||
(159, 'MAPR', 870, 10, 'n'),
|
||||
(160, 'STUM', 880, 4, 'n'),
|
||||
(161, 'STUMJ', 884, 4, 'n'),
|
||||
(162, 'ZF673', 888, 1, 'a'),
|
||||
(163, 'BDOZ', 889, 1, 'a'),
|
||||
(164, 'VFSVB', 890, 1, 'a'),
|
||||
(165, 'FSVB', 891, 10, 'n'),
|
||||
(166, 'PLST', 901, 1, 'a'),
|
||||
(167, 'VKAKL', 902, 1, 'a'),
|
||||
(168, 'KAKL', 903, 10, 'n'),
|
||||
(169, 'VZUKB', 913, 1, 'a'),
|
||||
(170, 'ZUKB', 914, 10, 'n'),
|
||||
(171, 'GEBD', 924, 8, 'n'),
|
||||
(172, 'GEBP', 932, 8, 'n'),
|
||||
(173, 'AKZKB', 940, 2, 'n'),
|
||||
(174, 'RESE174', 942, 59, 'a'),
|
||||
(175, 'REFN1', 1001, 30, 'a'),
|
||||
(176, 'RESE176', 1031, 19, 'a'),
|
||||
(177, 'BSBKFZ', 1050, 10, 'n'),
|
||||
(178, 'BSBWR', 1060, 10, 'n'),
|
||||
(179, 'BSBSB', 1070, 10, 'n'),
|
||||
(180, 'SB681', 1080, 10, 'n'),
|
||||
(181, 'SB682', 1090, 10, 'n'),
|
||||
(182, 'BZUKG', 1100, 10, 'n'),
|
||||
(183, 'MAKBG', 1110, 10, 'n'),
|
||||
(184, 'MABST', 1120, 10, 'n'),
|
||||
(185, 'ZUCSG', 1130, 10, 'n'),
|
||||
(186, 'GUTSC', 1140, 10, 'n'),
|
||||
(187, 'MARAB', 1150, 10, 'n'),
|
||||
(188, 'SBK00', 1160, 1, 'a'),
|
||||
(189, 'SBK15', 1161, 1, 'a'),
|
||||
(190, 'SBK20', 1162, 1, 'a'),
|
||||
(191, 'SBKDW', 1163, 1, 'a'),
|
||||
(192, 'AKKFZ', 1164, 10, 'n'),
|
||||
(193, 'KEAKFZ', 1174, 10, 'n'),
|
||||
(194, 'AGLAG', 1184, 10, 'n'),
|
||||
(195, 'RESE195', 1194, 307, 'a'),
|
||||
(210, 'RESE210', 3346, 155, 'a'),
|
||||
]
|
||||
|
||||
# Kinderblock (E.14, Felder 196–209): je Kind 123 Stellen ab Pos. 1501
|
||||
# (Block 1 = 1501–1623 … Block 15 = 3223–3345; Positionen aus den
|
||||
# Feldlängen arithmetisch abgeleitet, PDF-verifiziert).
|
||||
_KINDER_FELDER = [
|
||||
(196, 'KFAM', 0, 30, 'a'), # Familienname
|
||||
(197, 'KVON', 30, 30, 'a'), # Vorname
|
||||
(198, 'KSTAAT', 60, 3, 'a'), # Staatsangehörigkeit
|
||||
(199, 'KSTWE', 63, 1, 'a'), # 2. Staatsangehörigkeit
|
||||
(200, 'KVSNR', 64, 10, 'n'), # VSNR des Kindes
|
||||
(201, 'KGEBD', 74, 8, 'n'), # TTMMJJJJ
|
||||
(202, 'KAFBZ', 82, 1, 'a'), # Kennzeichen FB+
|
||||
(203, 'KAPFB', 83, 1, 'a'),
|
||||
(204, 'KAUHZ', 84, 1, 'a'),
|
||||
(205, 'KBGFP', 85, 2, 'n'), # FB+ ab Monat
|
||||
(206, 'KEGFP', 87, 2, 'n'), # FB+ bis Monat
|
||||
(207, 'KBHFP', 89, 2, 'n'), # halber FB+ ab Monat
|
||||
(208, 'KEHFP', 91, 2, 'n'), # halber FB+ bis Monat
|
||||
(209, 'RESEK', 93, 30, 'a'),
|
||||
]
|
||||
|
||||
|
||||
def _kinderblock_struktur():
|
||||
felder = []
|
||||
for kind_nr in range(1, 16):
|
||||
basis = 1501 + (kind_nr - 1) * 123
|
||||
for nr, name, offset, laenge, typ in _KINDER_FELDER:
|
||||
felder.append(
|
||||
(nr, '%s_%d' % (name, kind_nr), basis + offset,
|
||||
laenge, typ))
|
||||
return felder
|
||||
|
||||
|
||||
L16_MITTEILUNGSSATZ += _kinderblock_struktur()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Adresse der Arbeitsstätte § 34 Abs 6 ASVG (E.20; SART 45, Länge 300,
|
||||
# Version 02; BEST AD, VSTR ST)
|
||||
# ------------------------------------------------------------------
|
||||
ARBEITSSTAETTE_SATZ = [
|
||||
(2, 'KVTR', 21, 2, 'a'), # KV-Träger (aus BKNR-Präfix)
|
||||
(3, 'BKNR', 23, 10, 'a'),
|
||||
(4, 'VSNR', 33, 10, 'n'),
|
||||
(5, 'BZVM', 43, 2, 'n'), # Beitragszeit von MM
|
||||
(6, 'BZBM', 45, 2, 'n'), # Beitragszeit bis MM
|
||||
(7, 'BZJJ', 47, 4, 'n'), # Beitragsjahr
|
||||
(8, 'FAMS', 51, 2, 'a'), # Finanzamtsnummer meldende Stelle
|
||||
(9, 'STNR', 53, 9, 'n'), # Steuernummer DG
|
||||
(10, 'ASTR', 62, 50, 'a'), # Straße der Arbeitsstätte
|
||||
(11, 'ANRV', 112, 5, 'a'), # Hausnummer von
|
||||
(12, 'ANRB', 117, 5, 'a'), # Hausnummer bis
|
||||
(13, 'ASTG', 122, 5, 'a'), # Stiege
|
||||
(14, 'ATOP', 127, 5, 'a'), # Tür/Top
|
||||
(15, 'APLZ', 132, 9, 'a'), # PLZ der Arbeitsstätte
|
||||
(16, 'AORT', 141, 40, 'a'), # Ort
|
||||
(17, 'AGKZ', 181, 5, 'n'), # Gemeindekennziffer
|
||||
(18, 'APOL', 186, 50, 'a'), # Politische Gemeinde/Staat
|
||||
(19, 'AUDT', 236, 8, 'n'), # TTMMJJJJ
|
||||
(20, 'WOBD', 244, 12, 'a'),
|
||||
(21, 'ZOBD', 256, 15, 'a'),
|
||||
(22, 'REFN', 271, 30, 'a'),
|
||||
]
|
||||
|
||||
|
||||
class L10nAtPayrollL16Line(models.Model):
|
||||
_name = 'l10n.at.payroll.l16.line'
|
||||
_description = 'Lohnzettel Finanz — Mitteilungssatz-Daten je AN'
|
||||
_order = 'employee_name'
|
||||
|
||||
l16_id = fields.Many2one(
|
||||
'l10n.at.payroll.l16', 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()
|
||||
soziale_stellung = fields.Char(help='0–8 (D.33)')
|
||||
belz = fields.Char(string='LZP ab (TTMM)')
|
||||
enlz = fields.Char(string='LZP bis (TTMM)')
|
||||
kz210 = fields.Monetary(currency_field='currency_id')
|
||||
kz215 = fields.Monetary(currency_field='currency_id')
|
||||
kz220 = fields.Monetary(currency_field='currency_id')
|
||||
kz225 = fields.Monetary(currency_field='currency_id')
|
||||
kz226 = fields.Monetary(currency_field='currency_id')
|
||||
kz230 = fields.Monetary(currency_field='currency_id')
|
||||
kz243 = fields.Monetary(currency_field='currency_id')
|
||||
kz245 = fields.Monetary(currency_field='currency_id')
|
||||
lohnsteuer = fields.Monetary(currency_field='currency_id',
|
||||
string='KZ 260 anrechenbare LSt')
|
||||
beitraege = fields.Monetary(currency_field='currency_id',
|
||||
string='Einbehaltene Beiträge (VIEB)')
|
||||
fab_betrag = fields.Monetary(
|
||||
currency_field='currency_id', string='Familienbonus Plus')
|
||||
fab_kinder = fields.Integer(string='Anzahl Kinder FB+')
|
||||
refn = fields.Char()
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', related='l16_id.company_id.currency_id')
|
||||
|
||||
def _kz_salden(self, slips):
|
||||
"""KZ-Summen je AN aus validierten Läufen. **KZ 210 = Brutto-
|
||||
bezüge gem. § 25 gesamt** (laufende UND sonstige §-67-Bezüge,
|
||||
ohne § 26/§ 3 Abs 1 Z 16b — DM-ORG E.14 Feldbeschreibung), denn
|
||||
KZ 245 = 210 − 215 − 220 − 230 − 243 ist die laufende Steuer-
|
||||
bemessung: Gesamtbrutto − §-68-Freie − sonstige Bezüge −
|
||||
laufende SV-DN. 215 aus `_l10n_at_freibetraege_68` des
|
||||
GP3-Kerns (Monats-Caps 400/600 und ÜStd 15/170 eingerechnet);
|
||||
220 = sonstige § 67 Abs 1/2/5 ohne Abs-3/6; 225 = SV-DN auf
|
||||
KZ 220; 230 = laufende SV-DN + AK + WF; VIEB = 230 + 225 +
|
||||
226; 260/BIEL = LST_LFD + LST_SZ."""
|
||||
self.ensure_one()
|
||||
salden = dict.fromkeys(
|
||||
('kz210', 'kz215', 'kz220', 'kz225', 'kz226', 'kz230',
|
||||
'kz243', 'kz245', 'lohnsteuer', 'beitraege'), 0.0)
|
||||
for slip in slips:
|
||||
for line in slip.line_ids:
|
||||
rule = line.salary_rule_id
|
||||
if not line.total:
|
||||
continue
|
||||
if rule.l10n_at_bemessung == 'laufend':
|
||||
salden['kz210'] += line.total
|
||||
elif (rule.l10n_at_bemessung == 'sonstig'
|
||||
and rule.l10n_at_s67_abs == 'keine'):
|
||||
salden['kz210'] += line.total
|
||||
salden['kz220'] += line.total
|
||||
elif line.code == 'SVDN_SZ':
|
||||
salden['kz225'] += line.total
|
||||
elif line.code in ('SVDN_KV', 'SVDN_PV', 'SVDN_AV',
|
||||
'SVDN_AK', 'SVDN_WF'):
|
||||
salden['kz230'] += line.total
|
||||
elif line.code in ('LST_LFD', 'LST_SZ'):
|
||||
salden['lohnsteuer'] += line.total
|
||||
freibe = slip._l10n_at_freibetraege_68()
|
||||
salden['kz215'] += freibe['szfn'] + freibe['ue_std']
|
||||
salden['beitraege'] = salden['kz230'] + salden['kz225'] \
|
||||
+ salden['kz226']
|
||||
salden['kz245'] = (salden['kz210'] - salden['kz215']
|
||||
- salden['kz220'] - salden['kz230']
|
||||
- salden['kz243'])
|
||||
return salden
|
||||
|
||||
def _soziale_stellung(self, employee):
|
||||
"""D.33: 1 Lehrling, 2 Arbeiter, 3 Angestellte (0 sonstige) —
|
||||
MVP: Stand des aktuellen Vertrags."""
|
||||
version = employee.version_id
|
||||
if version and getattr(version, 'l10n_at_lehrling', False):
|
||||
return '1'
|
||||
if version and getattr(
|
||||
version, 'l10n_at_personalgruppe', None) == 'arbeiter':
|
||||
return '2'
|
||||
return '3'
|
||||
|
||||
|
||||
class L10nAtPayrollL16(models.Model):
|
||||
_name = 'l10n.at.payroll.l16'
|
||||
_description = 'Lohnzettel Finanz (L16) & Arbeitsstättenmeldung'
|
||||
_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='Lohnzahlungszeitraum')
|
||||
art = fields.Selection(
|
||||
[('01', 'Lohnzettel (Jahresbeleg)'),
|
||||
('18', 'Berichtigter Lohnzettel (binnen 2 Wochen)')],
|
||||
required=True, default='01', tracking=True)
|
||||
line_ids = fields.One2many(
|
||||
'l10n.at.payroll.l16.line', 'l16_id', string='Mitteilungssätze')
|
||||
datei_l16 = fields.Binary(
|
||||
attachment=True, string='L16-Datei', copy=False)
|
||||
datei_l16_name = fields.Char(copy=False)
|
||||
datei_arbeitsstaette = fields.Binary(
|
||||
attachment=True, string='Datei Arbeitsstättenmeldung',
|
||||
copy=False)
|
||||
datei_arbeitsstaette_name = fields.Char(copy=False)
|
||||
protokoll = fields.Text(
|
||||
string='Prüfprotokoll',
|
||||
help='Formale Prüfung (Satzlängen, KZ-245-Formel, VSNR-Format, '
|
||||
'Pflicht-Stammdaten) und Annahmen des Laufes; die '
|
||||
'Fachprüfung bleibt der Clearingstelle (Mitteilungsfile, '
|
||||
'GP8-Rest).')
|
||||
state = fields.Selection(
|
||||
[('draft', 'Entwurf'), ('done', 'Erzeugt')], default='draft',
|
||||
required=True, tracking=True)
|
||||
|
||||
_sql_constraints = [(
|
||||
'l16_company_jahr_art_uniq', 'unique(company_id, jahr, art)',
|
||||
'Für dieses Jahr existiert bereits eine L16-Deklaration dieser '
|
||||
'Art.')]
|
||||
|
||||
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')),
|
||||
])
|
||||
|
||||
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):
|
||||
"""V-Feld: blank bei 0, sonst '+'/'-' (L1-Feldregeln)."""
|
||||
if not betrag:
|
||||
return None
|
||||
return '+' if betrag > 0 else '-'
|
||||
|
||||
def _l10n_at_l1_satz(self, line, stnr, heute, jetzt):
|
||||
self.ensure_one()
|
||||
employee = line.employee_id
|
||||
version = employee.version_id
|
||||
satz = L10nAtEldaSatz(
|
||||
L10N_AT_ELDA_IDTEIL + L16_MITTEILUNGSSATZ, 3500,
|
||||
bezeichnung='L1 %s' % employee.name)
|
||||
satz.setze('SART', 'L1')
|
||||
satz.setze('VSTR', '94')
|
||||
satz.setze('FSART', 'L')
|
||||
satz.setze('STNRL', stnr)
|
||||
satz.setze('STNRA', stnr)
|
||||
satz.setze_datum('DTUE', heute, 'JJJJMMTT')
|
||||
satz.setze_zeit('ZTUE', jetzt)
|
||||
satz.setze('REFN', line.refn)
|
||||
satz.setze('ARTL', self.art)
|
||||
if self.art == '18':
|
||||
satz.setze('KORR', 'K')
|
||||
satz.setze('FIND', 'J')
|
||||
satz.setze('BELZ', line.belz)
|
||||
satz.setze('ENLZ', line.enlz)
|
||||
satz.setze('JALZ', self.jahr)
|
||||
satz.setze('SOZS', line.soziale_stellung)
|
||||
satz.setze('AVLN', (line.versicherungsnummer or '')[:4])
|
||||
satz.setze_datum('AGBD', employee.birthday, 'TTMMJJ')
|
||||
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 '')
|
||||
if version and version.sex == 'female':
|
||||
satz.setze('GESW', 'J')
|
||||
elif version and version.sex == 'male':
|
||||
satz.setze('GESM', 'J')
|
||||
elif version and version.sex:
|
||||
satz.setze('GESD', 'J')
|
||||
vollzeit = bool(version and version.work_time_rate >= 1.0)
|
||||
satz.setze('VOLL', 'J' if vollzeit else 'N')
|
||||
satz.setze('TEIL', 'N' if vollzeit else 'J')
|
||||
if version and version.l10n_at_familienabsetzbetrag == 'avab':
|
||||
satz.setze('AVAB', 'J')
|
||||
elif version \
|
||||
and version.l10n_at_familienabsetzbetrag == 'avb':
|
||||
satz.setze('AEAB', 'J')
|
||||
for betrag, vfeld, bfeld in (
|
||||
(line.kz210, 'V210', 'B210'),
|
||||
(line.kz215, 'V215', 'B215'),
|
||||
(line.kz220, 'V220', 'B220'),
|
||||
(line.kz225, 'V225', 'B225'),
|
||||
(line.kz226, 'V226', 'B226'),
|
||||
(line.kz230, 'V230', 'B230'),
|
||||
(line.kz243, 'V243', 'B243'),
|
||||
(line.kz245, 'V245', 'B245'),
|
||||
(line.beitraege, 'VIEB', 'BIEB'),
|
||||
(line.lohnsteuer, 'VIEL', 'BIEL'),
|
||||
(line.lohnsteuer, 'V260', 'B260')):
|
||||
satz.setze(vfeld, self._vorzeichen(betrag))
|
||||
satz.setze(bfeld, abs(betrag) if betrag else 0, cent=True)
|
||||
satz.setze('WOBD', 'EMP-%d' % employee.id)
|
||||
if version and version.children:
|
||||
satz.setze('ANZK', version.children)
|
||||
# Familienbonus Plus aus der Kind-Stammdaten-Wartung (keine
|
||||
# Personalverrechnungs-Komponente — Nutzer-Entscheidung).
|
||||
if line.fab_kinder:
|
||||
satz.setze('BFABO', 'J')
|
||||
satz.setze('KFABO', line.fab_kinder)
|
||||
satz.setze('VFABO', self._vorzeichen(line.fab_betrag))
|
||||
satz.setze('FABO', abs(line.fab_betrag) if line.fab_betrag
|
||||
else 0, cent=True)
|
||||
kinder = self.env['l10n.at.payroll.kind'].search([
|
||||
('employee_id', '=', employee.id),
|
||||
('fab_plus', '=', True)])
|
||||
for nummer, kind in enumerate(kinder[:15], start=1):
|
||||
satz.setze('KFAM_%d' % nummer,
|
||||
(kind.familienname or '')[:30])
|
||||
satz.setze('KVON_%d' % nummer, (kind.vorname or '')[:30])
|
||||
satz.setze('KSTAAT_%d' % nummer, kind.staatsangehoerigkeit)
|
||||
satz.setze('KSTWE_%d' % nummer,
|
||||
kind.staatsangehoerigkeit_2)
|
||||
satz.setze('KVSNR_%d' % nummer, kind.versicherungsnummer)
|
||||
satz.setze_datum('KGEBD_%d' % nummer, kind.geburtstag,
|
||||
'TTMMJJJJ')
|
||||
satz.setze('KAFBZ_%d' % nummer, 'J')
|
||||
satz.setze('KBGFP_%d' % nummer, kind.fab_monat_von)
|
||||
satz.setze('KEGFP_%d' % nummer, kind.fab_monat_bis)
|
||||
satz.setze('KBHFP_%d' % nummer, kind.fab_halb_monat_von)
|
||||
satz.setze('KEHFP_%d' % nummer, kind.fab_halb_monat_bis)
|
||||
return satz
|
||||
|
||||
def _l10n_at_l16_bestand(self, erstellungsdatum=None):
|
||||
self.ensure_one()
|
||||
heute = erstellungsdatum or date.today()
|
||||
jetzt = datetime.now()
|
||||
partner = self.company_id.partner_id
|
||||
stnr = self.company_id.l10n_at_lohnsteuernummer
|
||||
informationssatz = L10nAtEldaSatz(
|
||||
L10N_AT_ELDA_IDTEIL + L16_INFORMATIONSSATZ, 1100,
|
||||
bezeichnung='L16-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', self.jahr)
|
||||
informationssatz.setze('GESA', len(self.line_ids))
|
||||
informationssatz.setze('ANZA', len(self.line_ids))
|
||||
datensaetze = [informationssatz] + [
|
||||
self._l10n_at_l1_satz(line, stnr, heute, jetzt)
|
||||
for line in self.line_ids]
|
||||
return L10nAtEldaBestand(
|
||||
'LF', '94', '28', hersteller=self._l10n_at_hersteller(),
|
||||
datensaetze=datensaetze)
|
||||
|
||||
def _l10n_at_arbeitsstaette_bestand(self, erstellungsdatum=None):
|
||||
"""§ 34 Abs 6 ASVG je AN mit mBGM-Bezug: Adresse der
|
||||
Arbeitsstätte am 31. 12. (Dienstort-Partner + GKZ)."""
|
||||
self.ensure_one()
|
||||
heute = erstellungsdatum or date.today()
|
||||
bknr = self.company_id.l10n_at_beitragskontonummer
|
||||
stnr = self.company_id.l10n_at_lohnsteuernummer
|
||||
datensaetze = []
|
||||
warnungen = []
|
||||
for line in self.line_ids:
|
||||
employee = line.employee_id
|
||||
dienstort = employee.address_id
|
||||
if not dienstort or not dienstort.l10n_at_gemeindekennziffer:
|
||||
warnungen.append(
|
||||
'Arbeitsstätte %s: Dienstort-Partner bzw. '
|
||||
'Gemeindekennziffer fehlt (res.partner.'
|
||||
'l10n_at_gemeindekennziffer am Dienstort-Partner '
|
||||
'pflegen).' % employee.name)
|
||||
satz = L10nAtEldaSatz(
|
||||
L10N_AT_ELDA_IDTEIL + ARBEITSSTAETTE_SATZ, 300,
|
||||
bezeichnung='Arbeitsstätte %s' % employee.name)
|
||||
satz.setze('SART', '45')
|
||||
satz.setze('VSTR', 'ST')
|
||||
satz.setze('KVTR', (bknr or '')[:2])
|
||||
satz.setze('BKNR', bknr)
|
||||
satz.setze('VSNR', line.versicherungsnummer)
|
||||
satz.setze('BZVM', (line.belz or '01')[:2])
|
||||
satz.setze('BZBM', (line.enlz or '12')[:2])
|
||||
satz.setze('BZJJ', self.jahr)
|
||||
satz.setze('FAMS', (stnr or '')[:2])
|
||||
satz.setze('STNR', stnr)
|
||||
satz.setze('ASTR', (dienstort.street if dienstort else '')
|
||||
[:50])
|
||||
satz.setze('APLZ', dienstort.zip if dienstort else '')
|
||||
satz.setze('AORT', (dienstort.city if dienstort else '')[:40])
|
||||
satz.setze('AGKZ',
|
||||
dienstort.l10n_at_gemeindekennziffer
|
||||
if dienstort else None)
|
||||
satz.setze('APOL', (dienstort.city if dienstort else '')[:50])
|
||||
satz.setze_datum('AUDT', heute, 'TTMMJJJJ')
|
||||
satz.setze('WOBD', 'EMP-%d' % employee.id)
|
||||
datensaetze.append(satz)
|
||||
if not datensaetze:
|
||||
return None, warnungen
|
||||
return L10nAtEldaBestand(
|
||||
'AD', 'ST', '02', hersteller=self._l10n_at_hersteller(),
|
||||
datensaetze=datensaetze), warnungen
|
||||
|
||||
def action_erzeugen(self):
|
||||
"""Lauf: KZ-Daten je AN aus validierten Läufen sammeln, formale
|
||||
Prüfung (KZ-245-Formel, Pflicht-Stammdaten), Dateien erzeugen.
|
||||
Fehler brechen ab (UserError, DmfA-Muster listenweise);
|
||||
Warnungen landen im Prüfprotokoll."""
|
||||
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, 9-stellig).')
|
||||
if not company.l10n_at_beitragskontonummer:
|
||||
fehler.append('Beitragskontonummer der Company fehlt '
|
||||
'(l10n_at_beitragskontonummer, BKNR).')
|
||||
slips = deklaration._l10n_at_slips()
|
||||
nach_mitarbeiter = {}
|
||||
for slip in slips:
|
||||
nach_mitarbeiter.setdefault(slip.employee_id, []) \
|
||||
.append(slip)
|
||||
deklaration.line_ids.unlink()
|
||||
zeilen_werte = []
|
||||
for employee, employee_slips in nach_mitarbeiter.items():
|
||||
version = employee.version_id
|
||||
if not (version and version.ssnid):
|
||||
fehler.append(
|
||||
'Mitarbeiter %s ohne Versicherungsnummer '
|
||||
'(hr.version.ssnid).' % employee.name)
|
||||
continue
|
||||
if not employee.birthday:
|
||||
fehler.append(
|
||||
'Mitarbeiter %s ohne Geburtsdatum.' % employee.name)
|
||||
continue
|
||||
line = deklaration.env['l10n.at.payroll.l16.line'].new({
|
||||
'employee_id': employee.id})
|
||||
salden = line._kz_salden(employee_slips)
|
||||
erste = min(s.date_from for s in employee_slips)
|
||||
letzte = max(s.date_to for s in employee_slips)
|
||||
kinder = deklaration.env['l10n.at.payroll.kind'].search([
|
||||
('employee_id', '=', employee.id),
|
||||
('fab_plus', '=', True)])
|
||||
zeilen_werte.append({
|
||||
'employee_id': employee.id,
|
||||
'versicherungsnummer': version.ssnid,
|
||||
'soziale_stellung':
|
||||
line._soziale_stellung(employee),
|
||||
'belz': erste.strftime('%d%m'),
|
||||
'enlz': letzte.strftime('%d%m'),
|
||||
'refn': 'L16-%d-%d-%d' % (company.id,
|
||||
deklaration.jahr,
|
||||
employee.id),
|
||||
'fab_kinder': len(kinder),
|
||||
'fab_betrag': sum(
|
||||
kind._l10n_at_fab_betrag(deklaration.jahr)
|
||||
for kind in kinder),
|
||||
**salden,
|
||||
})
|
||||
for werte in zeilen_werte:
|
||||
deklaration.env['l10n.at.payroll.l16.line'].create(
|
||||
dict(werte, l16_id=deklaration.id))
|
||||
for line in deklaration.line_ids:
|
||||
formel = (line.kz210 - line.kz215 - line.kz220
|
||||
- line.kz230 - line.kz243)
|
||||
if abs(formel - line.kz245) > 0.02:
|
||||
fehler.append(
|
||||
'%s: KZ-245-Formel verletzt (%.2f ≠ %.2f).'
|
||||
% (line.employee_name, formel, line.kz245))
|
||||
if line.kz245 < 0:
|
||||
warnungen.append(
|
||||
'%s: KZ 245 negativ (Bezüge übersteigen die '
|
||||
'Abzüge) — FinOnl-Konsistenzprüfung prüft.'
|
||||
% line.employee_name)
|
||||
if fehler:
|
||||
deklaration.protokoll = 'FEHLER:\n%s\nWARNUNGEN:\n%s' % (
|
||||
'\n'.join(fehler), '\n'.join(warnungen) or '—')
|
||||
raise UserError('L16-Lauf mit Fehlern abgebrochen:\n'
|
||||
+ '\n'.join(fehler))
|
||||
if deklaration.line_ids:
|
||||
datei = L10nAtEldaDatei(
|
||||
[deklaration._l10n_at_l16_bestand()])
|
||||
deklaration.write({
|
||||
'datei_l16': base64.b64encode(
|
||||
datei.render()),
|
||||
'datei_l16_name': 'l16_%d_%s.txt' % (
|
||||
deklaration.jahr,
|
||||
deklaration.company_id.l10n_at_beitragskontonummer
|
||||
or deklaration.company_id.id),
|
||||
})
|
||||
bestand, ast_warn = \
|
||||
deklaration._l10n_at_arbeitsstaette_bestand()
|
||||
warnungen.extend(ast_warn)
|
||||
if bestand is not None:
|
||||
datei = L10nAtEldaDatei([bestand])
|
||||
deklaration.write({
|
||||
'datei_arbeitsstaette': base64.b64encode(
|
||||
datei.render()),
|
||||
'datei_arbeitsstaette_name':
|
||||
'arbeitsstaette_%d.txt' % deklaration.jahr,
|
||||
})
|
||||
deklaration.protokoll = 'WARNUNGEN:\n%s' % (
|
||||
'\n'.join(warnungen) or '—')
|
||||
deklaration.state = 'done'
|
||||
return True
|
||||
@@ -0,0 +1,96 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""Meldewesen-Stammdaten (GP7/AP5): ELDA-/FinOnl-Identifikation der
|
||||
Dienstgeberin und der Arbeitsstätten.
|
||||
|
||||
- ``res.company``: Beitragskontonummer (BKNR — ELDA-Feld, Präfix =
|
||||
KV-Träger) und Lohnsteuernummer (9-stellig: 2 Stellen Finanzamt + 7
|
||||
Stellen Nummer; L16-I1 STNRA/STNRL, Arbeitsstättenmeldung STNR).
|
||||
- ``res.partner``: österreichische Gemeindekennziffer (AGKZ) für die
|
||||
Arbeitsstättenmeldung nach § 34 Abs 6 ASVG — Odoo hält keine GKZ, das
|
||||
Feld ist mandantenseitig zu pflegen (Warnung des L16-Laufs, wenn es am
|
||||
Dienstort-Partner fehlt).
|
||||
- ``hr.version``: Kennzeichen fallweise Beschäftigung (§ 33 Abs 1a
|
||||
Z 1 ASVG — tagesgenaue vereinfachte Anmeldung vor Arbeitsantritt,
|
||||
DM-ORG BEST MA) und VSNR-Formatprüfung (10-stellig LLLPTTMMJJ;
|
||||
Prüfzifferalgorithmus der SVS ist nicht beschafft → nur Format/
|
||||
Plausibilität, Rest dokumentiert).
|
||||
- ``hr.departure.reason``: Mapping auf den eSV-Abmeldegrund (AGRD,
|
||||
DM-ORG D.22; Standard ``00`` sonstiger Grund mit Ende des
|
||||
Beschäftigungsverhältnisses)."""
|
||||
from odoo import api, fields, models
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
l10n_at_beitragskontonummer = fields.Char(
|
||||
string='AT Beitragskontonummer (BKNR)',
|
||||
help='eSV-Beitragskontonummer des Dienstgebers (10 Stellen;\n'
|
||||
'die ersten Stellen codieren den KV-Träger). Pflichtfeld\n'
|
||||
'jeder ELDA-Meldung (mBGM-Paket BKNR, Versichertenmeldung,\n'
|
||||
'Arbeitsstättenmeldung, fallweise Anmeldung).')
|
||||
l10n_at_lohnsteuernummer = fields.Char(
|
||||
string='AT Lohnsteuernummer',
|
||||
help='9-stellig: 2 Stellen Finanzamtsnummer + 7 Stellen\n'
|
||||
'Steuernummer (Lohnzettel Finanz I1 STNRA/STNRL;\n'
|
||||
'Arbeitsstättenmeldung STNR).')
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
l10n_at_gemeindekennziffer = fields.Char(
|
||||
string='AT Gemeindekennziffer (GKZ)',
|
||||
help='5-stellige österreichische Gemeindekennzahl für die\n'
|
||||
'Arbeitsstättenmeldung nach § 34 Abs 6 ASVG (Feld AGKZ).\n'
|
||||
'Am Dienstort-Partner des Mitarbeiters pflegen.')
|
||||
|
||||
|
||||
class HrVersion(models.Model):
|
||||
_inherit = 'hr.version'
|
||||
|
||||
l10n_at_fallweise = fields.Boolean(
|
||||
string='AT Fallweise Beschäftigung',
|
||||
groups="hr_payroll.group_hr_payroll_user", tracking=True,
|
||||
help='Fallweise Beschäftigung (§ 33 Abs 1a Z 1 ASVG): je\n'
|
||||
'Beschäftigungstag ist die vereinfachte Anmeldung vor\n'
|
||||
'Arbeitsantritt zu übermitteln (ELDA BEST MA, Satzarten\n'
|
||||
'32/33); An-/Abmeldung laufen über die mBGM (G3/G4).')
|
||||
|
||||
@api.constrains('ssnid')
|
||||
def _l10n_at_vsnr_format(self):
|
||||
"""VSNR-Formatprüfung LLLPTTMMJJ (10 Stellen, Ziffern, plausibles
|
||||
Geburtsdatum TTMMJJ an den Stellen 5–10). Der
|
||||
Prüfzifferalgorithmus der SVS ist nicht beschafft → nur
|
||||
Format/Plausibilität (Rest dokumentiert; die Meldungsgeneratoren
|
||||
listen ungültige VSNR listenweise als UserError)."""
|
||||
for version in self:
|
||||
if not version.ssnid:
|
||||
continue
|
||||
roh = version.ssnid.strip()
|
||||
if len(roh) != 10 or not roh.isdigit():
|
||||
raise ValidationError(
|
||||
'Versicherungsnummer %r: 10 Ziffern im Format LLLPTTMMJJ '
|
||||
'erwartet.' % version.ssnid)
|
||||
try:
|
||||
tag = int(roh[4:6])
|
||||
monat = int(roh[6:8])
|
||||
if not 1 <= tag <= 31 or not 1 <= monat <= 12:
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
raise ValidationError(
|
||||
'Versicherungsnummer %r enthält kein plausibles '
|
||||
'Geburtsdatum (TTMMJJ an den Stellen 5–10).'
|
||||
% version.ssnid)
|
||||
|
||||
|
||||
class HrDepartureReason(models.Model):
|
||||
_inherit = 'hr.departure.reason'
|
||||
|
||||
l10n_at_agrd = fields.Char(
|
||||
string='AT eSV-Abmeldegrund (AGRD)',
|
||||
help='2-stelliger Abmeldegrund-Code der Versichertenmeldung '
|
||||
'(DM-ORG D.22); Standard 00 „Sonstiger Grund mit Ende des '
|
||||
'Beschäftigungsverhältnisses“.')
|
||||
@@ -0,0 +1,97 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""ELDA-Mitteilungsfile-Import (GP8): Rückantwort der Clearing-
|
||||
stelle/Dateninhaltsprüfung als XML (`mitteilung_{Protokollnummer}.xml`).
|
||||
XSDs lokal beschafft (`.firecrawl/ris/elda/Mitteilungsfiles_ZIP.zip`
|
||||
— elda_mitteilung-1.2/2.1/3.0.xsd, nicht versioniert).
|
||||
|
||||
Der Import parst das XML, reichert die GP7-Deklarationsmodelle
|
||||
(L16/mBGM) mit dem offiziellen Prüfergebnis an und liefert ein
|
||||
lesbares Protokoll. Dateibasiert (Prämisse P7): der Mandant lädt
|
||||
das Mitteilungsfile aus dem ELDA-Portal und lädt es hier hoch."""
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class L10nAtPayrollMitteilungsfileWizard(models.TransientModel):
|
||||
_name = 'l10n.at.payroll.mitteilungsfile.wizard'
|
||||
_description = 'ELDA-Mitteilungsfile importieren (GP8)'
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True,
|
||||
default=lambda self: self.env.company)
|
||||
datei = fields.Binary(
|
||||
required=True, string='Mitteilungsfile (XML)',
|
||||
help='ELDA-Rückantwort: mitteilung_{Protokollnummer}.xml '
|
||||
'aus dem Empfangsverzeichnis des ELDA-Portals.')
|
||||
datei_name = fields.Char()
|
||||
protokoll = fields.Text(readonly=True)
|
||||
|
||||
def action_importieren(self):
|
||||
"""XML parsen und Protokollzeilen extrahieren; die GP7-
|
||||
Deklarationsmodelle werden über REFP/REFW-Matches verlinkt,
|
||||
wo möglich. Das Mitteilungsfile 3.0 hat die Struktur:
|
||||
<mitteilung> → <sendung> → <fehler>/<warnung> (je Satz-
|
||||
art/REFW-Bezug)."""
|
||||
for wizard in self:
|
||||
if not wizard.datei:
|
||||
raise UserError('Keine Datei hochgeladen.')
|
||||
roh = base64.b64decode(wizard.datei)
|
||||
try:
|
||||
inhalt = roh.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
inhalt = roh.decode('iso-8859-15', errors='replace')
|
||||
zeilen = wizard._parse_mitteilungsfile(inhalt)
|
||||
wizard.protokoll = '\n'.join(zeilen) or 'Keine Fehler ' \
|
||||
'oder Warnungen gefunden.'
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def _parse_mitteilungsfile(self, inhalt):
|
||||
"""Einfacher XML-Textparser: extrahiert <fehler>/<warnung>
|
||||
Elemente mit Attributen und Textinhalt. Kein voller XSD-
|
||||
Validator — die Fachprüfung bleibt der Clearingstelle; hier
|
||||
wird das Ergebnis lesbar aufbereitet (Prämisse P7)."""
|
||||
zeilen = []
|
||||
# <fehler code="F0100" satzart="L1" refw="..."
|
||||
# beschreibung="..."/> — XSD-abhängige Struktur.
|
||||
for match in re.finditer(
|
||||
r'<(?:fehler|warnung|error|warning)\b([^>]*?)/?>',
|
||||
inhalt, re.IGNORECASE):
|
||||
attrs = match.group(1)
|
||||
art = 'Fehler' if 'fehler' in match.group(0).lower() \
|
||||
else 'Warnung'
|
||||
code = re.search(r'code="([^"]*)"', attrs)
|
||||
satzart = re.search(r'satzart="([^"]*)"', attrs)
|
||||
beschreibung = re.search(
|
||||
r'(?:beschreibung|description|text)="([^"]*)"', attrs)
|
||||
refw = re.search(
|
||||
r'(?:refw|refn|referenz)="([^"]*)"', attrs)
|
||||
zeile = art
|
||||
if code:
|
||||
zeile += ' %s' % code.group(1)
|
||||
if satzart:
|
||||
zeile += ' (Satzart %s)' % satzart.group(1)
|
||||
if refw:
|
||||
zeile += ' [Ref: %s]' % refw.group(1)
|
||||
if beschreibung:
|
||||
zeile += ': %s' % beschreibung.group(1)
|
||||
if not code and not beschreibung:
|
||||
zeile += ': %s' % match.group(0)[:120]
|
||||
zeilen.append(zeile)
|
||||
# Kopfprotokoll (Sendungsstatus).
|
||||
for match in re.finditer(
|
||||
r'<sendung\b([^>]*?)/?>', inhalt, re.IGNORECASE):
|
||||
attrs = match.group(1)
|
||||
status = re.search(r'status="([^"]*)"', attrs)
|
||||
if status:
|
||||
zeilen.insert(0, 'Sendungsstatus: %s' % status.group(1))
|
||||
if not zeilen:
|
||||
zeilen.append('Mitteilungsfile gelesen — keine '
|
||||
'<fehler>/<warnung>-Elemente gefunden.')
|
||||
return zeilen
|
||||
@@ -0,0 +1,90 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""Monatsdienstliste und Monatsjournal (GP7/AP6): QWeb-Datenlieferanten
|
||||
auf dem Lohnlauf (hr.payslip.run) — voraberechnete Aggregation über
|
||||
`hr.payslip.line` (read_group-Muster des Contribution Register), damit
|
||||
die Templates reine Darstellung bleiben.
|
||||
|
||||
- **Monatsdienstliste**: je AN eine Zeile mit den mBGM-Kontrollgrößen
|
||||
(Versicherungstage/Beitragsgrundlagen je Beitragsmonat, DN- und
|
||||
DG-Beiträge, DG-Anteile) — Abstimmung Abrechnung ↔ SV-Meldung.
|
||||
- **Monatsjournal**: je Lohnart die Summe über den Lauf (Brutto,
|
||||
Abzüge, DG-Anteile, Netto) mit Kontrollsummen gegen die
|
||||
GP6-Buchung."""
|
||||
from odoo import models
|
||||
|
||||
|
||||
class L10nAtPayrollMonatsReports(models.AbstractModel):
|
||||
_name = 'report.l10n_at_hr_payroll.report_monatsdienstliste'
|
||||
_description = 'Monatsdienstliste (GP7/AP6)'
|
||||
|
||||
def _get_report_values(self, docids, data=None):
|
||||
runs = self.env['hr.payslip.run'].browse(docids)
|
||||
zeilen = []
|
||||
for run in runs:
|
||||
for slip in run.slip_ids.filtered(
|
||||
lambda p: p.state not in ('draft', 'cancel')):
|
||||
version = slip.version_id
|
||||
basen = slip._l10n_at_sv_basis_laufen_monate()
|
||||
sv_dn = sum(
|
||||
slip._l10n_at_svdn(k) for k in
|
||||
('kv', 'pv', 'av', 'ak', 'wf'))
|
||||
sv_dg = slip._l10n_at_svdg_gesamt()
|
||||
sz_dn = slip._l10n_at_svdn_sz()
|
||||
sz_basis = slip._l10n_at_svdn_basis_sz()
|
||||
zeilen.append({
|
||||
'slip': slip,
|
||||
'versicherungsnummer': version.ssnid if version else '',
|
||||
'personalgruppe': getattr(
|
||||
version, 'l10n_at_personalgruppe', '')
|
||||
if version else '',
|
||||
'geringfuegig': slip.l10n_at_geringfuegig,
|
||||
'basis_laufen': sum(
|
||||
b for _m, b in basen),
|
||||
'sz_basis': sz_basis,
|
||||
'sv_dn': sv_dn,
|
||||
'sv_dg': sv_dg,
|
||||
'sv_dn_sz': sz_dn,
|
||||
'lohnsteuer': sum(
|
||||
slip.line_ids.filtered(
|
||||
lambda l: l.code in ('LST_LFD', 'LST_SZ')
|
||||
).mapped('total')),
|
||||
'netto': sum(
|
||||
slip.line_ids.filtered(
|
||||
lambda l: l.code == 'NET').mapped('total')),
|
||||
'brutto': sum(
|
||||
slip.line_ids.filtered(
|
||||
lambda l: l.salary_rule_id.l10n_at_bemessung
|
||||
!= 'neutral').mapped('total')),
|
||||
})
|
||||
return {'docs': runs, 'zeilen': zeilen}
|
||||
|
||||
|
||||
class L10nAtPayrollMonatsjournal(models.AbstractModel):
|
||||
_name = 'report.l10n_at_hr_payroll.report_monatsjournal'
|
||||
_description = 'Monatsjournal (GP7/AP6)'
|
||||
|
||||
def _get_report_values(self, docids, data=None):
|
||||
runs = self.env['hr.payslip.run'].browse(docids)
|
||||
zeilen = []
|
||||
for run in runs:
|
||||
gruppen = {}
|
||||
for slip in run.slip_ids.filtered(
|
||||
lambda p: p.state not in ('draft', 'cancel')):
|
||||
for line in slip.line_ids:
|
||||
regel = line.salary_rule_id
|
||||
if not line.total or regel.l10n_at_bemessung \
|
||||
== 'neutral':
|
||||
continue
|
||||
schluessel = (regel.code, regel.name,
|
||||
regel.l10n_at_bemessung)
|
||||
eintrag = gruppen.setdefault(schluessel, 0.0)
|
||||
gruppen[schluessel] = eintrag + line.total
|
||||
zeilen.append({
|
||||
'run': run,
|
||||
'arten': [
|
||||
{'code': code, 'name': name, 'bemessung': bemessung,
|
||||
'summe': summe}
|
||||
for (code, name, bemessung), summe
|
||||
in sorted(gruppen.items())],
|
||||
})
|
||||
return {'docs': runs, 'journal': zeilen}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- ============================================================ -->
|
||||
<!-- GP7/AP6 Belege: Lohnzettel AT (am Lohnzettel/Payslip), -->
|
||||
<!-- Monatsdienstliste + Monatsjournal (am Lohnlauf). -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="action_report_lohnzettel_at" model="ir.actions.report">
|
||||
<field name="name">Lohnzettel (AT)</field>
|
||||
<field name="model">hr.payslip</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">l10n_at_hr_payroll.report_lohnzettel_at</field>
|
||||
<field name="report_file">l10n_at_hr_payroll.report_lohnzettel_at</field>
|
||||
<field name="paperformat_id" ref="hr_payroll.paperformat_euro_light"/>
|
||||
<field name="binding_model_id" ref="hr_payroll.model_hr_payslip"/>
|
||||
<field name="binding_type">report</field>
|
||||
</record>
|
||||
|
||||
<record id="action_report_monatsdienstliste" model="ir.actions.report">
|
||||
<field name="name">Monatsdienstliste (SV-Kontrolle)</field>
|
||||
<field name="model">hr.payslip.run</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">l10n_at_hr_payroll.report_monatsdienstliste</field>
|
||||
<field name="report_file">l10n_at_hr_payroll.report_monatsdienstliste</field>
|
||||
<field name="paperformat_id" ref="hr_payroll.paperformat_euro_light"/>
|
||||
<field name="binding_model_id" ref="hr_payroll.model_hr_payslip_run"/>
|
||||
<field name="binding_type">report</field>
|
||||
</record>
|
||||
|
||||
<record id="action_report_monatsjournal" model="ir.actions.report">
|
||||
<field name="name">Monatsjournal</field>
|
||||
<field name="model">hr.payslip.run</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">l10n_at_hr_payroll.report_monatsjournal</field>
|
||||
<field name="report_file">l10n_at_hr_payroll.report_monatsjournal</field>
|
||||
<field name="paperformat_id" ref="hr_payroll.paperformat_euro_light"/>
|
||||
<field name="binding_model_id" ref="hr_payroll.model_hr_payslip_run"/>
|
||||
<field name="binding_type">report</field>
|
||||
</record>
|
||||
|
||||
<!-- Lohnzettel AT: AT-Layout — Lohnarten nach Kategorie, DN-Abzüge,
|
||||
DG-Anteile (SV-DG, FLAF, KommSt, DZ, BV), Netto. -->
|
||||
<template id="report_lohnzettel_at">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="slip">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page">
|
||||
<h2>Lohnzettel</h2>
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<td><strong>Mitarbeiter:in</strong></td>
|
||||
<td><t t-out="slip.employee_id.name"/></td>
|
||||
<td><strong>Versicherungsnummer</strong></td>
|
||||
<td><t t-out="slip.version_id.ssnid or ''"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Abrechnungsperiode</strong></td>
|
||||
<td><t t-out="slip.date_from"/> – <t t-out="slip.date_to"/></td>
|
||||
<td><strong>Personalgruppe</strong></td>
|
||||
<td><t t-out="slip.version_id.l10n_at_personalgruppe or ''"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3>Bezüge</h3>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lohnart</th>
|
||||
<th class="text-end">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="slip.line_ids.filtered(lambda l: l.total and l.salary_rule_id.l10n_at_bemessung != 'neutral' and l.salary_rule_id.category_id.code == 'ATP_BRUTTO')" t-as="line">
|
||||
<td><t t-out="line.name"/></td>
|
||||
<td class="text-end">
|
||||
<span t-out="line.total" t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Brutto</strong></td>
|
||||
<td class="text-end">
|
||||
<strong><span t-out="sum(slip.line_ids.filtered(lambda l: l.total and l.salary_rule_id.l10n_at_bemessung != 'neutral' and l.salary_rule_id.category_id.code == 'ATP_BRUTTO').mapped('total'))" t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/></strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>Abzüge (Dienstnehmer:in)</h3>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Abzug</th>
|
||||
<th class="text-end">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="slip.line_ids.filtered(lambda l: l.total and l.salary_rule_id.category_id.code == 'ATP_ABZUG')" t-as="line">
|
||||
<td><t t-out="line.name"/></td>
|
||||
<td class="text-end">
|
||||
<span t-out="-line.total" t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>Dienstgeberanteile</h3>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>DG-Komponente</th>
|
||||
<th class="text-end">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="slip.line_ids.filtered(lambda l: l.total and l.salary_rule_id.category_id.code == 'ATP_DG')" t-as="line">
|
||||
<td><t t-out="line.name"/></td>
|
||||
<td class="text-end">
|
||||
<span t-out="line.total" t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-end h4">
|
||||
<strong>Netto:</strong>
|
||||
<span t-out="sum(slip.line_ids.filtered(lambda l: l.code == 'NET').mapped('total'))" t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Monatsdienstliste: je AN die mBGM-Kontrollgrößen -->
|
||||
<template id="report_monatsdienstliste">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="run">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page">
|
||||
<h2>Monatsdienstliste — <t t-out="run.name"/></h2>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mitarbeiter:in</th>
|
||||
<th>VSNR</th>
|
||||
<th>Gruppe</th>
|
||||
<th class="text-end">BG laufend</th>
|
||||
<th class="text-end">BG SZ</th>
|
||||
<th class="text-end">SV-DN</th>
|
||||
<th class="text-end">SV-DN SZ</th>
|
||||
<th class="text-end">SV-DG</th>
|
||||
<th class="text-end">Lohnsteuer</th>
|
||||
<th class="text-end">Netto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="zeilen" t-as="z">
|
||||
<td><t t-out="z['slip'].employee_id.name"/></td>
|
||||
<td><t t-out="z['versicherungsnummer']"/></td>
|
||||
<td><t t-out="z['personalgruppe']"/><t t-if="z['geringfuegig']" t-out=" (geringf.)"/></td>
|
||||
<td class="text-end"><span t-out="z['basis_laufen']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
|
||||
<td class="text-end"><span t-out="z['sz_basis']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
|
||||
<td class="text-end"><span t-out="z['sv_dn']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
|
||||
<td class="text-end"><span t-out="z['sv_dn_sz']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
|
||||
<td class="text-end"><span t-out="z['sv_dg']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
|
||||
<td class="text-end"><span t-out="z['lohnsteuer']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
|
||||
<td class="text-end"><span t-out="z['netto']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Monatsjournal: je Lohnart die Summe über den Lauf -->
|
||||
<template id="report_monatsjournal">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="journal" t-as="j">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page">
|
||||
<h2>Monatsjournal — <t t-out="j['run'].name"/></h2>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Lohnart</th>
|
||||
<th>Bemessung</th>
|
||||
<th class="text-end">Summe</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="j['arten']" t-as="art">
|
||||
<td><t t-out="art['code']"/></td>
|
||||
<td><t t-out="art['name']"/></td>
|
||||
<td><t t-out="art['bemessung']"/></td>
|
||||
<td class="text-end"><span t-out="art['summe']" t-options="{"widget": "monetary", "display_currency": j['run'].company_id.currency_id}"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -3,3 +3,13 @@ access_l10n_at_payroll_tasy_mapping_user,L10n AT Payroll TASY-Mapping User,model
|
||||
access_l10n_at_payroll_tasy_mapping_manager,L10n AT Payroll TASY-Mapping Manager,model_l10n_at_payroll_tasy_mapping,hr_payroll.group_hr_payroll_manager,1,1,1,1
|
||||
access_l10n_at_payroll_tasy_import_wizard,L10n AT Payroll TASY-Import Wizard,model_l10n_at_payroll_tasy_import_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||
access_l10n_at_payroll_tasy_import_line,L10n AT Payroll TASY-Import Line,model_l10n_at_payroll_tasy_import_line,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||
access_l10n_at_payroll_l16_user,L10n AT Payroll L16 User,model_l10n_at_payroll_l16,hr_payroll.group_hr_payroll_user,1,1,1,0
|
||||
access_l10n_at_payroll_l16_manager,L10n AT Payroll L16 Manager,model_l10n_at_payroll_l16,hr_payroll.group_hr_payroll_manager,1,1,1,1
|
||||
access_l10n_at_payroll_l16_line_user,L10n AT Payroll L16-Line User,model_l10n_at_payroll_l16_line,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||
access_l10n_at_payroll_kind_user,L10n AT Payroll Kind User,model_l10n_at_payroll_kind,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||
access_l10n_at_payroll_kind_manager,L10n AT Payroll Kind Manager,model_l10n_at_payroll_kind,hr_payroll.group_hr_payroll_manager,1,1,1,1
|
||||
access_l10n_at_payroll_frist_user,L10n AT Payroll Frist User,model_l10n_at_payroll_frist,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||
access_l10n_at_payroll_frist_manager,L10n AT Payroll Frist Manager,model_l10n_at_payroll_frist,hr_payroll.group_hr_payroll_manager,1,1,1,1
|
||||
access_l10n_at_payroll_jahresretro_wizard,L10n AT Payroll Jahresretro Wizard,model_l10n_at_payroll_jahresretro_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||
access_l10n_at_payroll_jahresretro_ergebnis,L10n AT Payroll Jahresretro Ergebnis,model_l10n_at_payroll_jahresretro_ergebnis,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||
access_l10n_at_payroll_mitteilungsfile_wizard,L10n AT Payroll Mitteilungsfile Wizard,model_l10n_at_payroll_mitteilungsfile_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||
|
@@ -2,3 +2,5 @@
|
||||
from . import test_tasy_import
|
||||
from . import test_lohnsteuer
|
||||
from . import test_sozialversicherung
|
||||
from . import test_elda_l16
|
||||
from . import test_gp8
|
||||
@@ -0,0 +1,239 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""GP7 Kern: ELDA-Fixsatz-Framework (DM-ORG 42.7.0 Konventionen) und
|
||||
Lohnzettel-Finanz-Generator (I1/L1, BEST LF/94) — Positionsexakte
|
||||
Satz-Assertions, CRLF/ISO-8859-15-Encoding, SANR/SANZ-Kette,
|
||||
KZ-245-Formel-Konsistenz aus GP3-Daten."""
|
||||
import base64
|
||||
import datetime
|
||||
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
from odoo.addons.l10n_at_hr_payroll.models.elda import (
|
||||
L10N_AT_ELDA_IDTEIL,
|
||||
L10nAtEldaBestand,
|
||||
L10nAtEldaDatei,
|
||||
L10nAtEldaSatz,
|
||||
l10n_at_euro_cent,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class EldaFrameworkTest(TransactionCase):
|
||||
|
||||
def test_01_feldformatierung(self):
|
||||
"""a/n: linksbündig blank-gefüllt; n: rechtsbündig mit führenden
|
||||
Nullen, keine Interpunktion; Cent kaufmännisch; skala=3 für
|
||||
Prozentsätze (39,30 % → 039300)."""
|
||||
satz = L10nAtEldaSatz(
|
||||
[(1, 'A', 1, 5, 'a'), (2, 'N', 6, 4, 'n'),
|
||||
(3, 'B', 10, 10, 'n')], 20)
|
||||
satz.setze('A', 'AB')
|
||||
satz.setze('N', 42)
|
||||
self.assertEqual(satz.render()[:5], 'AB ')
|
||||
self.assertEqual(satz.render()[5:9], '0042')
|
||||
# Unbelegtes n-Feld: Grundstellung 0.
|
||||
self.assertEqual(satz.render()[9:19], '0' * 10)
|
||||
self.assertEqual(l10n_at_euro_cent(1234.567), 123457)
|
||||
self.assertEqual(l10n_at_euro_cent(0.005), 1)
|
||||
prozent = L10nAtEldaSatz(
|
||||
[(1, 'VPTA', 1, 6, 'n')], 6)
|
||||
prozent.setze('VPTA', 39.30, skala=3)
|
||||
self.assertEqual(prozent.render(), '039300')
|
||||
|
||||
def test_02_bestand_datei(self):
|
||||
"""Vorlaufsatz SART 00 mit BEST/VSTR/VERS, SANR lückenlos ab 1,
|
||||
SANZ inklusive Vorlauf/Schluss, CRLF-getrennte Zeilen in
|
||||
ISO-8859-15, optionaler Dateiheader ENCD."""
|
||||
satz1 = L10nAtEldaSatz(
|
||||
L10N_AT_ELDA_IDTEIL + [(2, 'X', 21, 4, 'a')], 24,
|
||||
bezeichnung='Test')
|
||||
satz1.setze('SART', '45')
|
||||
satz1.setze('VSTR', 'ST')
|
||||
satz1.setze('X', 'TEST')
|
||||
satz2 = L10nAtEldaSatz(
|
||||
L10N_AT_ELDA_IDTEIL + [(2, 'Y', 21, 4, 'n')], 24,
|
||||
bezeichnung='Test')
|
||||
satz2.setze('SART', '45')
|
||||
satz2.setze('VSTR', 'ST')
|
||||
satz2.setze('Y', 7)
|
||||
bestand = L10nAtEldaBestand(
|
||||
'AD', 'ST', '02',
|
||||
hersteller={'name': 'Test', 'kfz': 'A'},
|
||||
datensaetze=[satz1, satz2])
|
||||
zeilen = bestand.render(
|
||||
erstellungsdatum=datetime.date(2026, 1, 15))
|
||||
vorlauf = zeilen[0]
|
||||
self.assertEqual(len(zeilen), 4)
|
||||
self.assertEqual(vorlauf[18:20], 'ST') # VSTR (Pos. 19)
|
||||
self.assertEqual(vorlauf[22:24], 'AD') # BEST
|
||||
self.assertEqual(vorlauf[30:38], '15012026') # EDAT TTMMJJJJ
|
||||
self.assertEqual(vorlauf[149:151], '02') # VERS
|
||||
self.assertEqual(zeilen[1][0:2], '45')
|
||||
self.assertEqual(zeilen[1][2:9], '0000002') # SANR
|
||||
self.assertEqual(zeilen[3][20:26], '000004') # SANZ
|
||||
datei = L10nAtEldaDatei([bestand])
|
||||
roh = datei.render()
|
||||
self.assertIn(b'\r\n', roh)
|
||||
erste = roh.split(b'\r\n')[0]
|
||||
self.assertEqual(erste[:4], b' 01')
|
||||
self.assertIn(b'ISO-8859-15', erste)
|
||||
# Umlaut in ISO-8859-15 codiert.
|
||||
satz3 = L10nAtEldaSatz(
|
||||
L10N_AT_ELDA_IDTEIL + [(2, 'Z', 21, 10, 'a')], 30,
|
||||
bezeichnung='Umlaut')
|
||||
satz3.setze('SART', '45')
|
||||
satz3.setze('VSTR', 'ST')
|
||||
satz3.setze('Z', 'Für')
|
||||
self.assertIn('Für'.encode('ISO-8859-15'),
|
||||
satz3.render().encode('ISO-8859-15'))
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class L16GeneratorTest(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.company = self.env.company
|
||||
self.company.write({
|
||||
'l10n_at_lohnsteuernummer': '123456789',
|
||||
'l10n_at_beitragskontonummer': '1412345678',
|
||||
})
|
||||
self.structure = self.env.ref(
|
||||
'l10n_at_hr_payroll_private.structure_at_privat')
|
||||
self.kv2203 = self.env.ref(
|
||||
'l10n_at_hr_payroll_private.kv_si_2203')
|
||||
self.gruppe_ii = self.env.ref(
|
||||
'l10n_at_hr_payroll_private.kv2203_grp_ii')
|
||||
self.calendar = self.env['resource.calendar'].create({
|
||||
'name': 'AT Test 40h',
|
||||
'tz': 'Europe/Vienna',
|
||||
'hours_per_day': 8,
|
||||
'attendance_ids': [
|
||||
(0, 0, {'name': 'Tag %d' % wd, 'dayofweek': str(wd),
|
||||
'hour_from': 8.0, 'hour_to': 16.0})
|
||||
for wd in range(5)
|
||||
],
|
||||
})
|
||||
self.company.resource_calendar_id = self.calendar
|
||||
employee = self.env['hr.employee'].create({
|
||||
'name': 'L16 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': 'female',
|
||||
'kv_id': self.kv2203.id,
|
||||
'kv_gruppe_id': self.gruppe_ii.id,
|
||||
'erfahrungsstufe': 1,
|
||||
'resource_calendar_id': self.calendar.id,
|
||||
})
|
||||
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': 'L16 %d/%d' % (monat, jahr),
|
||||
'employee_id': self.employee.id,
|
||||
'struct_id': self.structure.id,
|
||||
'date_from': erster,
|
||||
'date_to': letzter,
|
||||
})
|
||||
slip.compute_sheet()
|
||||
if confirm:
|
||||
slip.action_payslip_done()
|
||||
return slip
|
||||
|
||||
def test_01_l16_lauf_und_positionen(self):
|
||||
"""Jahreslauf: KZ-Salden aus GP3/GP4-Flags, L1-Record mit
|
||||
exakten Positionen (Satzlänge 3500, REFN/ARTL/KZ-V/B-Paare,
|
||||
SOZS/AVLN/AGBD), I1 (1100, STNRA/JAHR/GESA/ANZA), Datei mit
|
||||
LF-Bestand VSTR 94, KZ-245-Konsistenz per Konstruktion."""
|
||||
self._slip(11) # November mit B13
|
||||
deklaration = self.env['l10n.at.payroll.l16'].create({
|
||||
'company_id': self.company.id,
|
||||
'jahr': 2026,
|
||||
'art': '01',
|
||||
})
|
||||
deklaration.action_erzeugen()
|
||||
self.assertEqual(deklaration.state, 'done')
|
||||
self.assertEqual(len(deklaration.line_ids), 1)
|
||||
line = deklaration.line_ids
|
||||
# November: KZ 210 = KVGEO + B13 = 4.118,82 (§ 25 gesamt);
|
||||
# KZ 220 = B13 = 2.059,41; KZ 245 = laufende Bemessung.
|
||||
self.assertAlmostEqual(line.kz210, 4118.82, places=2)
|
||||
self.assertAlmostEqual(line.kz220, 2059.41, places=2)
|
||||
# KZ-245-Formel: 210 − 215 − 220 − 230 − 243
|
||||
erwartet = (line.kz210 - line.kz215 - line.kz220
|
||||
- line.kz230 - line.kz243)
|
||||
self.assertAlmostEqual(line.kz245, erwartet, places=1)
|
||||
# Datei: I1 (1100) + L1 (3500) + Vorlauf/Schluss (auf 3500
|
||||
# aufgefüllt), LF-Bestand, VSTR 94, Lohnzettelversion 28.
|
||||
roh = base64.b64decode(deklaration.datei_l16).decode(
|
||||
'ISO-8859-15')
|
||||
zeilen = roh.strip('\r\n').split('\r\n')
|
||||
# Dateiheader + Vorlauf + I1 + L1 + Schluss.
|
||||
self.assertEqual(len(zeilen), 5)
|
||||
header, vorlauf, i1, l1, schluss = zeilen
|
||||
self.assertEqual(header[:4], ' 01')
|
||||
self.assertIn('ISO-8859-15', header)
|
||||
self.assertEqual(len(i1), 1100)
|
||||
self.assertEqual(len(l1), 3500)
|
||||
self.assertEqual(vorlauf[0:2], '00')
|
||||
self.assertEqual(vorlauf[18:20], '94')
|
||||
self.assertEqual(vorlauf[22:24], 'LF')
|
||||
self.assertEqual(vorlauf[149:151], '28')
|
||||
self.assertEqual(i1[18:20], '94')
|
||||
self.assertEqual(i1[20:21], 'I')
|
||||
self.assertEqual(i1[34:43], '123456789') # STNRL
|
||||
self.assertEqual(i1[50:59], '123456789') # STNRA
|
||||
self.assertEqual(i1[82:84], '03') # STVE
|
||||
self.assertEqual(i1[232:236], '2026') # JAHR
|
||||
self.assertEqual(i1[236:243], '0000001') # GESA
|
||||
self.assertEqual(l1[18:20], '94')
|
||||
self.assertEqual(l1[20:21], 'L')
|
||||
self.assertEqual(l1[95:97], '01') # ARTL
|
||||
self.assertEqual(l1[119:123], '0111') # BELZ TTMM
|
||||
self.assertEqual(l1[123:127], '3011') # ENLZ
|
||||
self.assertEqual(l1[127:131], '2026') # JALZ
|
||||
self.assertEqual(l1[131:132], '3') # SOZS Angestellte
|
||||
self.assertEqual(l1[132:136], '1234') # AVLN LLLP
|
||||
self.assertEqual(l1[136:142], '150590') # AGBD TTMMJJ
|
||||
# KZ-Felder: V/B-Paare (1-based-Positionen → Index pos−1).
|
||||
self.assertEqual(l1[296:297], '+') # V210 (Pos. 297)
|
||||
self.assertEqual(l1[297:307], '0000411882') # B210
|
||||
self.assertEqual(l1[318:319], '+') # V220 (Pos. 319)
|
||||
self.assertEqual(l1[319:329], '0000205941') # B220
|
||||
self.assertEqual(l1[450:451], '+') # V245 (Pos. 451)
|
||||
b245 = '%010d' % int(round(line.kz245 * 100))
|
||||
self.assertEqual(l1[451:461], b245)
|
||||
self.assertEqual(schluss[0:2], '99')
|
||||
self.assertEqual(schluss[20:26], '000004') # SANZ
|
||||
|
||||
def test_02_arbeitsstaette(self):
|
||||
"""§ 34 Abs 6 ASVG: je AN ein Satz 45 (Länge 300) mit BKNR,
|
||||
VSNR, STNR und GKZ aus dem Dienstort-Partner; Warnung, wenn
|
||||
die GKZ fehlt."""
|
||||
self._slip(1)
|
||||
deklaration = self.env['l10n.at.payroll.l16'].create({
|
||||
'company_id': self.company.id,
|
||||
'jahr': 2026,
|
||||
'art': '01',
|
||||
})
|
||||
deklaration.action_erzeugen()
|
||||
self.assertTrue(deklaration.datei_arbeitsstaette)
|
||||
roh = base64.b64decode(
|
||||
deklaration.datei_arbeitsstaette).decode('ISO-8859-15')
|
||||
zeilen = roh.strip('\r\n').split('\r\n')
|
||||
vorlauf, satz45 = zeilen[1], zeilen[2]
|
||||
self.assertEqual(vorlauf[18:20], 'ST')
|
||||
self.assertEqual(vorlauf[22:24], 'AD')
|
||||
self.assertEqual(satz45[0:2], '45')
|
||||
self.assertEqual(len(satz45), 300)
|
||||
self.assertEqual(satz45[22:32], '1412345678') # BKNR
|
||||
self.assertEqual(satz45[32:42], '1234150590') # VSNR
|
||||
self.assertEqual(satz45[52:61], '123456789') # STNR
|
||||
self.assertIn('Gemeindekennziffer', deklaration.protokoll)
|
||||
@@ -0,0 +1,107 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""GP8 Kern: Fristenkalender (Auto-Erzeugung, Frist-Typen, Überfällig-
|
||||
Logik), Jahres-Retro (Kontrollsechstel-Prüfung), Mitteilungsfile-Parser."""
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
from odoo.addons.l10n_at_hr_payroll.models.fristenkalender import (
|
||||
FRISTART_DEFINITIONEN,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class FristenTest(TransactionCase):
|
||||
|
||||
def test_01_frist_definitionen(self):
|
||||
"""Alle Fristarten sind definiert mit Name/Deadline-Logik."""
|
||||
for art, definition in FRISTART_DEFINITIONEN.items():
|
||||
self.assertIn('name', definition)
|
||||
self.assertIn('hilfetext', definition)
|
||||
self.assertTrue(
|
||||
'monatlich' in definition or 'monat' in definition,
|
||||
'Fristart %s ohne Fälligkeits-Logik' % art)
|
||||
|
||||
def test_02_cron_erzeugt_fristen(self):
|
||||
"""Cron erzeugt monatliche SV/DB-Fristen idempotent + jährliche
|
||||
Fristen am 1. Jänner."""
|
||||
frist = self.env['l10n.at.payroll.frist']
|
||||
company = self.env.company
|
||||
# Manueller Cron-Lauf (heute ist ein Tag im Jahr).
|
||||
frist._cron_fristen_erzeugen()
|
||||
# Monatliche Fristen existieren für den Vormonat.
|
||||
heute = date.today()
|
||||
vormonat = (heute.replace(day=1) - __import__('datetime')
|
||||
.timedelta(days=1))
|
||||
monatliche = frist.search([
|
||||
('company_id', '=', company.id),
|
||||
('fristart', 'in', ('sv_mbgm', 'db_dz_kommst')),
|
||||
('jahr', '=', vormonat.year),
|
||||
('monat', '=', vormonat.month)])
|
||||
self.assertEqual(len(monatliche), 2)
|
||||
# Idempotenz: zweiter Lauf erzeugt keine Duplikate.
|
||||
frist._cron_fristen_erzeugen()
|
||||
monatliche2 = frist.search([
|
||||
('company_id', '=', company.id),
|
||||
('fristart', 'in', ('sv_mbgm', 'db_dz_kommst')),
|
||||
('jahr', '=', vormonat.year),
|
||||
('monat', '=', vormonat.month)])
|
||||
self.assertEqual(len(monatliche2), 2)
|
||||
|
||||
def test_03_state_ueberfaellig(self):
|
||||
"""Überfällige Fristen markieren sich nach Deadline-
|
||||
Überschreitung; action_erledigt setzt erledigt."""
|
||||
frist = self.env['l10n.at.payroll.frist'].create({
|
||||
'company_id': self.env.company.id,
|
||||
'fristart': 'l16',
|
||||
'jahr': 2025,
|
||||
'monat': 0,
|
||||
'deadline': '2026-01-01',
|
||||
})
|
||||
self.assertEqual(frist.state, 'ueberfaellig')
|
||||
frist.action_erledigt()
|
||||
self.assertEqual(frist.state, 'erledigt')
|
||||
self.assertTrue(frist.erledigt_am)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class MitteilungsfileTest(TransactionCase):
|
||||
|
||||
def test_01_parse_mitteilungsfile(self):
|
||||
"""XML-Parser extrahiert Fehler-/Warnung-Elemente."""
|
||||
wizard = self.env['l10n.at.payroll.mitteilungsfile.wizard']
|
||||
xml = """<?xml version="1.0"?>
|
||||
<mitteilung>
|
||||
<sendung status="ok"/>
|
||||
<fehler code="F0100" satzart="L1"
|
||||
beschreibung="STEUERNUMMER fehlt"/>
|
||||
<warnung code="W0102" satzart="G1"
|
||||
beschreibung="Beitragssatz-Differenz"/>
|
||||
</mitteilung>"""
|
||||
zeilen = wizard._parse_mitteilungsfile(xml)
|
||||
self.assertIn('Sendungsstatus: ok', zeilen)
|
||||
self.assertTrue(any('F0100' in z for z in zeilen))
|
||||
self.assertTrue(any('W0102' in z for z in zeilen))
|
||||
self.assertTrue(any('STEUERNUMMER' in z for z in zeilen))
|
||||
|
||||
def test_02_import_ohne_datei(self):
|
||||
"""Import ohne Datei löst UserError."""
|
||||
from odoo.exceptions import UserError
|
||||
wizard = self.env['l10n.at.payroll.mitteilungsfile.wizard'] \
|
||||
.create({'company_id': self.env.company.id})
|
||||
with self.assertRaises(UserError):
|
||||
wizard.action_importieren()
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class JahresretroTest(TransactionCase):
|
||||
|
||||
def test_01_wizard_ohne_daten(self):
|
||||
"""Wizard ohne Läufe im Jahr: leeres Ergebnis, keine Fehler."""
|
||||
wizard = self.env['l10n.at.payroll.jahresretro.wizard'].create({
|
||||
'company_id': self.env.company.id,
|
||||
'jahr': 1999, # weit in der Vergangenheit
|
||||
})
|
||||
wizard.action_pruefen()
|
||||
self.assertFalse(wizard.ergebnis_ids)
|
||||
self.assertIn('1999', wizard.zusammenfassung)
|
||||
@@ -0,0 +1,191 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- ============================================================ -->
|
||||
<!-- GP8: Fristenkalender + Jahres-Retro + Mitteilungsfile-Import. -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="frist_list_view" model="ir.ui.view">
|
||||
<field name="name">l10n.at.payroll.frist.list</field>
|
||||
<field name="model">l10n.at.payroll.frist</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Fristenkalender" default_order="deadline">
|
||||
<field name="deadline"/>
|
||||
<field name="fristart"/>
|
||||
<field name="jahr"/>
|
||||
<field name="monat" optional="hide"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'erledigt'"
|
||||
decoration-warning="state == 'offen'"
|
||||
decoration-danger="state == 'ueberfaellig'"/>
|
||||
<button name="action_erledigt" type="object"
|
||||
icon="fa-check" string="Erledigt"
|
||||
invisible="state == 'erledigt'"/>
|
||||
<button name="action_zum_objekt" type="object"
|
||||
icon="fa-arrow-right" string="Zur Meldung"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="frist_form_view" model="ir.ui.view">
|
||||
<field name="name">l10n.at.payroll.frist.form</field>
|
||||
<field name="model">l10n.at.payroll.frist</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Frist">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="fristart"/>
|
||||
<field name="jahr"/>
|
||||
<field name="monat"/>
|
||||
<field name="deadline"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="company_id"
|
||||
groups="base.group_multi_company"/>
|
||||
<field name="state" widget="badge"/>
|
||||
<field name="erledigt_am"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Hinweis" name="hinweis">
|
||||
<field name="hilfetext"/>
|
||||
</page>
|
||||
<page string="Meldungsobjekt" name="objekt">
|
||||
<group>
|
||||
<field name="ref_l16_id"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<header>
|
||||
<button name="action_erledigt" type="object"
|
||||
string="Als erledigt markieren"
|
||||
invisible="state == 'erledigt'"/>
|
||||
<button name="action_wieder_eroeffnen" type="object"
|
||||
string="Wieder eröffnen"
|
||||
invisible="state != 'erledigt'"/>
|
||||
</header>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_frist" model="ir.actions.act_window">
|
||||
<field name="name">Fristenkalender</field>
|
||||
<field name="res_model">l10n.at.payroll.frist</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_state': 'offen'}</field>
|
||||
</record>
|
||||
|
||||
<!-- Jahres-Retro-Wizard -->
|
||||
<record id="jahresretro_wizard_form_view" model="ir.ui.view">
|
||||
<field name="name">l10n.at.payroll.jahresretro.wizard.form</field>
|
||||
<field name="model">l10n.at.payroll.jahresretro.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Jahres-Retro-/Aufrollungslauf">
|
||||
<group>
|
||||
<group>
|
||||
<field name="jahr"/>
|
||||
<field name="company_id"
|
||||
groups="base.group_multi_company"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="nur_kontrollsechstel"/>
|
||||
<field name="aufrollung_77"/>
|
||||
<field name="zeige_detail"/>
|
||||
</group>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_pruefen" type="object"
|
||||
string="Jahresdaten prüfen" class="btn-primary"/>
|
||||
<button string="Schließen" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
<notebook invisible="not zeige_detail">
|
||||
<page string="Prüfergebnis" name="ergebnis">
|
||||
<field name="ergebnis_ids">
|
||||
<list create="0" delete="0">
|
||||
<field name="employee_id"/>
|
||||
<field name="kontrollsechstel_ueberhang"/>
|
||||
<field name="fixsatz_steuer"/>
|
||||
<field name="tarif_steuer"/>
|
||||
<field name="guenstig" widget="badge"
|
||||
decoration-success="guenstig"
|
||||
decoration-danger="not guenstig"/>
|
||||
<field name="handlung"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Zusammenfassung" name="zusammenfassung">
|
||||
<field name="zusammenfassung"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_jahresretro_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Jahres-Retro-/Aufrollungslauf</field>
|
||||
<field name="res_model">l10n.at.payroll.jahresretro.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<!-- Mitteilungsfile-Import -->
|
||||
<record id="mitteilungsfile_wizard_form_view" model="ir.ui.view">
|
||||
<field name="name">l10n.at.payroll.mitteilungsfile.wizard.form</field>
|
||||
<field name="model">l10n.at.payroll.mitteilungsfile.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="ELDA-Mitteilungsfile importieren">
|
||||
<group>
|
||||
<field name="company_id"
|
||||
groups="base.group_multi_company"/>
|
||||
<field name="datei" filename="datei_name"/>
|
||||
<field name="datei_name" invisible="1"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_importieren" type="object"
|
||||
string="Importieren" class="btn-primary"/>
|
||||
<button string="Schließen" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
<field name="protokoll" invisible="not protokoll"/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_mitteilungsfile_wizard" model="ir.actions.act_window">
|
||||
<field name="name">ELDA-Mitteilungsfile importieren</field>
|
||||
<field name="res_model">l10n.at.payroll.mitteilungsfile.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<!-- Cron: Fristen monatlich/jährlich erzeugen -->
|
||||
<record id="ir_cron_fristen_erzeugen" model="ir.cron">
|
||||
<field name="name">L10n AT Payroll: Fristenkalender pflegen</field>
|
||||
<field name="model_id" ref="model_l10n_at_payroll_frist"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_fristen_erzeugen()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">months</field>
|
||||
<field name="nextcall">2026-10-01 02:00:00</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Menüs -->
|
||||
<menuitem id="menu_l10n_at_payroll_fristen"
|
||||
name="Fristenkalender"
|
||||
parent="menu_l10n_at_payroll_meldewesen"
|
||||
action="action_frist" sequence="5"/>
|
||||
|
||||
<menuitem id="menu_l10n_at_payroll_jahresretro"
|
||||
name="Jahres-Retro-/Aufrollungslauf"
|
||||
parent="menu_l10n_at_payroll_meldewesen"
|
||||
action="action_jahresretro_wizard" sequence="20"/>
|
||||
|
||||
<menuitem id="menu_l10n_at_payroll_mitteilungsfile"
|
||||
name="Mitteilungsfile-Import"
|
||||
parent="menu_l10n_at_payroll_meldewesen"
|
||||
action="action_mitteilungsfile_wizard" sequence="30"/>
|
||||
</odoo>
|
||||
@@ -0,0 +1,127 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- ============================================================ -->
|
||||
<!-- GP7 Meldewesen: L16-/Arbeitsstätten-Deklaration + Kind-Stamm -->
|
||||
<!-- (FB+-Wartung am Mitarbeiter). -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="l16_list_view" model="ir.ui.view">
|
||||
<field name="name">l10n.at.payroll.l16.list</field>
|
||||
<field name="model">l10n.at.payroll.l16</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Lohnzettel Finanz (L16)">
|
||||
<field name="jahr"/>
|
||||
<field name="art"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'done'"
|
||||
decoration-info="state == 'draft'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="l16_form_view" model="ir.ui.view">
|
||||
<field name="name">l10n.at.payroll.l16.form</field>
|
||||
<field name="model">l10n.at.payroll.l16</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Lohnzettel Finanz (L16) & Arbeitsstättenmeldung">
|
||||
<header>
|
||||
<button name="action_erzeugen" type="object"
|
||||
string="Lauf erzeugen (Dateien)"
|
||||
class="btn-primary"
|
||||
invisible="state == 'done'"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,done"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="jahr"/>
|
||||
<field name="art"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="company_id"
|
||||
groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Mitteilungssätze" name="linien">
|
||||
<field name="line_ids">
|
||||
<list editable="bottom">
|
||||
<field name="employee_name"/>
|
||||
<field name="versicherungsnummer"/>
|
||||
<field name="soziale_stellung"/>
|
||||
<field name="kz210" optional="show"/>
|
||||
<field name="kz215" optional="hide"/>
|
||||
<field name="kz220" optional="show"/>
|
||||
<field name="kz230" optional="hide"/>
|
||||
<field name="kz245" optional="show"/>
|
||||
<field name="lohnsteuer"/>
|
||||
<field name="fab_betrag"
|
||||
string="FB+"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Dateien" name="dateien">
|
||||
<group>
|
||||
<field name="datei_l16"
|
||||
filename="datei_l16_name"/>
|
||||
<field name="datei_l16_name" invisible="1"/>
|
||||
<field name="datei_arbeitsstaette"
|
||||
filename="datei_arbeitsstaette_name"/>
|
||||
<field name="datei_arbeitsstaette_name"
|
||||
invisible="1"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Prüfprotokoll" name="protokoll">
|
||||
<field name="protokoll"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_l16" model="ir.actions.act_window">
|
||||
<field name="name">Lohnzettel Finanz (L16)</field>
|
||||
<field name="res_model">l10n.at.payroll.l16</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_l10n_at_payroll_meldewesen"
|
||||
name="Meldewesen (ELDA/FinOnl)"
|
||||
parent="hr_work_entry_enterprise.menu_hr_payroll_root"
|
||||
sequence="97"/>
|
||||
|
||||
<menuitem id="menu_l10n_at_payroll_l16"
|
||||
name="Lohnzettel Finanz (L16)"
|
||||
parent="menu_l10n_at_payroll_meldewesen"
|
||||
action="action_l16" sequence="10"/>
|
||||
|
||||
<!-- Kind-Stammdaten (FB+-Wartung) als Seite am Mitarbeiterformular -->
|
||||
<record id="hr_employee_view_form_kind" model="ir.ui.view">
|
||||
<field name="name">hr.employee.form.l10n_at_payroll_kind</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="inherit_id" ref="hr.view_employee_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<notebook position="inside">
|
||||
<page string="Kinder (FB+)" name="l10n_at_payroll_kinder"
|
||||
groups="hr_payroll.group_hr_payroll_user">
|
||||
<field name="l10n_at_payroll_kind_ids">
|
||||
<list editable="bottom">
|
||||
<field name="familienname"/>
|
||||
<field name="vorname"/>
|
||||
<field name="geburtstag"/>
|
||||
<field name="versicherungsnummer"/>
|
||||
<field name="fab_plus" widget="boolean_toggle"/>
|
||||
<field name="fab_monat_von"/>
|
||||
<field name="fab_monat_bis"/>
|
||||
<field name="fab_halb_monat_von"/>
|
||||
<field name="fab_halb_monat_bis"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -117,19 +117,37 @@ Auszahlung über `hr_payroll_account_iso20022`; kein VRV/MVAG
|
||||
|
||||
Rechenfälle `docs/rechenfaelle/buchhaltung.md`.
|
||||
|
||||
**Offen (GP7+):** Meldewesen/Belege (GP7), Jahreswechsel (GP8);
|
||||
All-in-UI-Bericht (GP8); B14 längster-Urlaubs-Teil-Verfeinerung;
|
||||
DZ-WKO-Verifikation (8.11) vor Wien-Mandanten; §-7-BMSVG-Fiktivfälle
|
||||
(Präsenzdienst/Kranken-/Wochengeld) als dokumentierte Option
|
||||
(Rechenfälle-Datei); Arbeiter-Sub-Konten (Option A-Rest) mit der
|
||||
Referenzbuchhaltung fixieren (GP0-Rest).
|
||||
**GP7 Meldewesen/Belege (umgesetzt 2026-09-12):** auf dem Kern-
|
||||
Framework (ELDA-Fixlängen-Dateien, ISO-8859-15, CRLF-Annahme;
|
||||
L16 + Arbeitsstättenmeldung + Belege Lohnzettel AT/Monatsdienstliste/
|
||||
Monatsjournal im Kern): **mBGM/JASV** (Paket/mBGM/Tarifblock/Verrech-
|
||||
nungsbasis/-position, T01-Gesamtsatz aus ``at_mbgm_tarif`` — normativ
|
||||
39,05 % laufend / 37,55 % SZ; Abschläge A01/A02/A03 § 2a AMPFG **je
|
||||
Basis AB und SZ**, A09/A10/A15 aus GP4-Stichtagen; BV-Basis + V01 aus
|
||||
GP5; Storno-R-Sätze mit REFU; Fehlanzeige als Null-Paket; Gering-
|
||||
fährige/fallweise ohne Tarifparameter = listenweiser Laufabbruch);
|
||||
**Versichertenmeldung** M3/M4 (deklarative Event-Erkennung über die
|
||||
Meldungs-Registry — idempotent, kein write-diff; BBER 01–04, GERF,
|
||||
FRDV, BVAB/BVJN aus GP5, VWAZ; AGRD-Mapping am Abgangsgrund) und
|
||||
**fallweise Anmeldung** (BEST MA, SART 32; MVP je Anmeldetag);
|
||||
NSchAB-Branche als Company-Flag (B001-T01 + 3,80 %).
|
||||
|
||||
Rechenfälle `docs/rechenfaelle/meldewesen.md`.
|
||||
|
||||
**Offen (GP8+):** Jahreswechsel (GP8); All-in-UI-Bericht (GP8);
|
||||
B14 längster-Urlaubs-Teil-Verfeinerung; DZ-WKO-Verifikation (8.11)
|
||||
vor Wien-Mandanten; §-7-BMSVG-Fiktivfälle (Option); Arbeiter-Sub-
|
||||
Konten-Endfixierung (Referenzbuchhaltung); Schwerarbeitsmeldung
|
||||
(nach ASchV-Datenmodell-Bau, DM-ORG E.22-Struktur liegt bei);
|
||||
§ 109a (separater Baustein); Mitteilungsfile-Import (GP8);
|
||||
geringfügig/fallweise-Tarifparameter pflegen (at_mbgm_tarif).
|
||||
|
||||
Tests: `test_kv_katalog` + `test_entgelt_private` (GP2) +
|
||||
`test_lst_private` (GP3) + `test_sv_private` (GP4) +
|
||||
`test_bv_private` (GP5) + `test_account_private` (GP6) —
|
||||
`test_bv_private` (GP5) + `test_account_private` (GP6) +
|
||||
`test_elda_l16` (GP7 Kern) + `test_meldewesen_private` (GP7) —
|
||||
`.venv/bin/python -m odoo -d <testdb> -i l10n_at_hr_payroll,l10n_at_hr_payroll_private
|
||||
--test-tags /l10n_at_hr_payroll,/l10n_at_hr_payroll_private --test-enable --stop-after-init
|
||||
--addons-path=odoo_19.0+e.20260910/odoo-19.0+e.20260910/odoo/addons,addons`
|
||||
(Lokal grün am 2026-09-12 auf Wegwerf-DB, frischer `-i`-Lauf:
|
||||
**88/88 Tests** — NET-Rundungscents bucht Odoo als „Adjustment Entry"
|
||||
auf das Journal-Default.)
|
||||
**94/94 Tests**.)
|
||||
@@ -1,7 +1,7 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
{
|
||||
'name': 'Österreich - Privatwirtschaft Personalverrechnung (General-AT)',
|
||||
'version': '19.0.6.0.0',
|
||||
'version': '19.0.8.0.0',
|
||||
'author': 'ixsol / gem360',
|
||||
'website': 'https://gem360.at',
|
||||
'category': 'Human Resources/Payroll',
|
||||
@@ -65,6 +65,18 @@ Grundlagen: ``personalverrechnung/IMPLEMENTIERUNGSPLAN-Privat.md`` und
|
||||
(Zahlungsregistrierung/SEPA via ``hr_payroll_account_iso20022``).
|
||||
Auszahlung über ``hr_payroll_account_iso20022``; kein VRV/MVAG
|
||||
(gem360-Brücke).
|
||||
- **GP7 Meldewesen (dieser Stand):** mBGM/JASV (BEST MB, DM-ORG
|
||||
42.7.0: Paket/mBGM/Tarifblock/Verrechnungsbasis/-position mit
|
||||
GESAMT-Prozentsatz je BSGR aus ``at_mbgm_tarif``, Abschläge
|
||||
A01/A02/A03 §-2a-AMPFG, A09 UV-60, A10 AV+IE-Entfall, A15 PV-
|
||||
Halbierung; BV-Basis + V01 aus GP5; Storno-R-Sätze; Fehlanzeige)
|
||||
+ Versichertenmeldung (BEST VR: M3/M4 mit deklarativer Event-
|
||||
Erkennung über die Meldungs-Registry, AGRD-Mapping am
|
||||
Abgangsgrund, VWAZ, BBER, GERF; fallweise Anmeldung BEST MA
|
||||
SART 32) — auf dem GP7-Kern-Framework
|
||||
(l10n_at_hr_payroll/models/elda.py; L16 + Arbeitsstättenmeldung
|
||||
+ Belege siehe Kern-Modul). NSchAB-Branche als Company-Flag
|
||||
(B001-T01 + 3,80 %).
|
||||
- **Seed D2 (Referenz-KVs R1):** SI-2203 „Handwerk und Gewerbe" +
|
||||
SI-2748 „Metallgewerbe" (Gehaltstabellen ab 1. 1. 2026, kuratiert aus
|
||||
den KV-Library-Volltexten).
|
||||
@@ -94,9 +106,13 @@ gem360-Brücke).
|
||||
'data/salary_rules_sv_private.xml',
|
||||
'data/salary_rules_bvg_private.xml',
|
||||
'data/hr_payroll_account_private.xml',
|
||||
'data/rule_parameters_mbgm_private.xml',
|
||||
'data/bv_kasse_seed.xml',
|
||||
'views/kv_views.xml',
|
||||
'views/bv_views.xml',
|
||||
'views/meldewesen_views.xml',
|
||||
'views/vertragsfelder_views.xml',
|
||||
'report/gp8_reports.xml',
|
||||
'report/bv_report.xml',
|
||||
],
|
||||
'countries': ['at'],
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- GP7: mBGM-Tarifsätze (Standardposition T01/T02 = GESAMT- -->
|
||||
<!-- Prozentsatz je Beschäftigtengruppe aus dem ÖGK- -->
|
||||
<!-- Beitragstarifsystem „Normalbetriebe Allgemeiner Teil", -->
|
||||
<!-- gültig ab 01.07.2026 (.additional_docs/oegk/, Primär- -->
|
||||
<!-- quelle GP4). NORMATIVE Sätze (Abschläge wie A03 laufen als -->
|
||||
<!-- eigene Verrechnungspositionen): laufend = DN 18,07 + DG -->
|
||||
<!-- 20,98 = 39,30 − AK/… exakt: KV 7,65 + PV 22,80 + AV 5,90 + -->
|
||||
<!-- UV 1,10 + WF 1,00 + IESG 0,10 = 39,05 %; SZ = KV 3,87 + -->
|
||||
<!-- PV 10,25 + AV 2,95 (DN; § 67 Abs 12) + DG 20,48 (KV 3,78 + -->
|
||||
<!-- PV 12,55 + AV 2,95 + UV 1,10 + IESG 0,10) = 37,55 %. -->
|
||||
<!-- NSchAB-Betriebe: + 3,80 % auf B001 laufend über das Company- -->
|
||||
<!-- Flag l10n_at_nschab. Lehrlinge (B044/B045) und Geringfügige -->
|
||||
<!-- (B010/B030) bewusst NICHT geseedet — Werte aus dem SV- -->
|
||||
<!-- Tarifsystem pflegen, sobai solche Beschäftigten vorkommen. -->
|
||||
<record id="hr_rule_parameter_at_mbgm_tarif" model="hr.rule.parameter">
|
||||
<field name="name">mBGM-Tarifsätze je Beschäftigtengruppe (SV-Tarifsystem)</field>
|
||||
<field name="code">at_mbgm_tarif</field>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
</record>
|
||||
<record id="hr_rule_parameter_value_at_mbgm_tarif_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="hr_rule_parameter_at_mbgm_tarif"/>
|
||||
<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>
|
||||
</odoo>
|
||||
@@ -0,0 +1,112 @@
|
||||
# Rechenfälle Meldewesen/Belege (GP7) — ELDA/eSV, L16, Belege
|
||||
|
||||
**Quellen:** DM-ORG 42.7.0 „Datenaustausch mit Dienstgebern", 42.
|
||||
Ergänzung 07/2026 (`.firecrawl/ris/elda/` — Volltext-PDF + pdftotext;
|
||||
Satzstrukturen PDF-verifiziert extrahiert 2026-09-12, Positions-
|
||||
Ketten lückenlos); Pruefkatalog L16 ab 2026 (Stand 12/2025, xlsx);
|
||||
KB lb-lvr-04/05 (L16), lb-mel-08 (Fristen), lb-bes-02/03 (fallweise),
|
||||
lb-sva-14 (SZ-Sätze), lb-swa-05 (Schwerarbeit, out); Nutzer-
|
||||
Entscheidungen 5.1–5.4 (Arbeitsstättenmeldung in GP7; Mitteilungsfile-
|
||||
Import Rest; **FB+ = keine Personalverrechnung** — bei Steuer < 0
|
||||
Arbeitnehmerveranlagung, **Kind-Modell für die FABO+-Wartung**; alles
|
||||
in einem GP7). Umsetzung: Kern `l10n_at_hr_payroll` 19.0.4.0.0
|
||||
(`models/elda.py`, `lohnzettel_l16.py`, `kind.py`, `meldewesen.py`,
|
||||
`monatsreports.py`, `report/belege_reports.xml`), privat
|
||||
19.0.7.0.0 (`models/mbgm.py`, `versichertenmeldung.py`,
|
||||
`data/rule_parameters_mbgm_private.xml`). Tests: `test_elda_l16`,
|
||||
`test_meldewesen_private`.
|
||||
|
||||
## Systematik (GP7, verifiziert)
|
||||
|
||||
- **ELDA-Fixlängen-Dateien** (C.1/C.2, E.1–E.3): Datei = optionaler
|
||||
Dateiheader (DHKZ 2×blank, DHVN 01, ENCD **ISO-8859-15**) + Daten-
|
||||
bestand (Vorlaufsatz SART 00 mit BEST/VSTR/VERS → Datensätze →
|
||||
Schlusssatz SART 99 mit SANZ inkl. Vorlauf/Schluss); a/n linksbündig
|
||||
blank-gefüllt, n rechtsbündig null-gefüllt, **Cent ohne
|
||||
Interpunktion**, Prozentsätze ×1000 (3 Nachkommastellen), SANR
|
||||
lückenlos ab 1. **CRLF als Satztrenner = Praxis-Annahme** (die
|
||||
DM-ORG normiert kein Trennzeichen — Parallellauf verifizieren).
|
||||
- **mBGM/JASV** (BEST `MB`, Version 02; E.32): Hierarchie Paket PS
|
||||
(BKNR/DGNA/BZRM/GSUM/ANZM) → mBGM G1 (REFW/VSNR/FANA/VONA/VSUM/
|
||||
VERG=1) → Tarifblock T1 (BSGR/VVON) → Verrechnungsbasis BS (VBTY/
|
||||
VBBT) → Verrechnungsposition V1 (VPTY/VPVZ/VPTA/RSVZ/RSUM).
|
||||
**KEIN DN/DG-Zweig-Split**: T01 = GESAMT-Prozentsatz des Tarifsystems
|
||||
(Parameter `at_mbgm_tarif`, normativ: laufend **39,05 %** =
|
||||
KV 7,65 + PV 22,80 + AV 5,90 + UV 1,10 + WF 1,00 + IESG 0,10; SZ
|
||||
**37,55 %** = DN KV 3,87 + PV 10,25 + AV 2,95 + DG 20,48). Abschläge
|
||||
als EIGENE Positionen je Basis: **A01/A02/A03** = Minderung AV auf
|
||||
2/1/0 % (§ 2a AMPFG — nur DN-AV, **je Verrechnungsbasis AB UND SZ**;
|
||||
A03 = −2,95 % der Basis, RSVZ −), A09 = UV-Entfall 60 (−1,10 %), A10
|
||||
= AV+IE-Entfall (−6,00 %), A15 = PV-Halbierung (−11,40 %);
|
||||
Konkurrenz a4: Entfall vor Minderung (A10 sperrt A0x). **BV**:
|
||||
Basis `BV` (GP5, ungedeckelt) + `V01` 1,53 %. RSUM = Basis × Satz
|
||||
(mBGM-Tarifierung); Konsistenz gegen GP4-Payslip-Summen als
|
||||
Prüfwarnung (Toleranz 0,10 €). Storno: R-Satz je stornierter mBGM
|
||||
(REFU, VSUM wie Original, ohne T/BS/V). Fehlanzeige: Paket ohne
|
||||
mBGM-Sätze (ANZM 0). Fallweise/Geringfügige ohne Parametrisierung:
|
||||
Lauf bricht listenweise ab (BSGR B044/B045/B010/B030 —
|
||||
Tarifsystem-Werte pflegen, keine Erfindung).
|
||||
- **L16 Lohnzettel Finanz** (BEST `LF`, VSTR `94` BRZ; E.13/E.14):
|
||||
I1 (Länge 1100, Strukturversion 03) + L1 (Länge **3500**,
|
||||
Lohnzettelversion 28, zwingend ab 01.03.2026). **KZ 210 =
|
||||
Bruttobezüge gem. § 25 GESAMT** (laufende + sonstige §-67-Bezüge,
|
||||
DM-ORG-Feldbeschreibung!) — deshalb ist **KZ 245 = 210 − 215 − 220
|
||||
− 230 − 243** die laufende Steuerbemessung und per Konstruktion mit
|
||||
der GP3-§-66-Kette konsistent. KZ-Ableitung aus den Kern-Flags
|
||||
(kein paralleles Klassifikationsfeld): 210 = laufend+sonstig § 67
|
||||
(ohne Abs-3/6), 215 = `_l10n_at_freibetraege_68` (§-68-Caps je LZP),
|
||||
220 = sonstig ohne Abs-3/6 (inkl. 620-€-Freibetrag vor SV), 225 =
|
||||
SVDN_SZ, 230 = laufende SVDN+AK+WF, 243 = 0 (nicht im Inventar),
|
||||
260/BIEL = LST_LFD + LST_SZ. FB+-Felder (BFABO/KFABO/VFABO/FABO +
|
||||
Kinderblock 15×123 ab Pos. 1501) aus der **Kind-Stammdaten-Wartung**
|
||||
(Nutzer-Entscheidung: FB+ rechnet nicht im Lohnsteuerkern — ANV).
|
||||
REFN je AG+Jahr+DV eindeutig; berichtigter Lohnzettel: ARTL 18 +
|
||||
KORR K + FIND J.
|
||||
- **Arbeitsstättenmeldung** § 34 Abs 6 ASVG (BEST `AD`, VSTR `ST`
|
||||
Statistik Austria; E.20, SART 45, Länge 300): je AN Adresse am
|
||||
31. 12. aus dem Dienstort-Partner; **GKZ** (Gemeindekennziffer) als
|
||||
manuelles Partner-Feld — Warnung, wenn fehlt.
|
||||
- **Versichertenmeldung** (BEST `VR`, Version 03, Länge 772; E.29):
|
||||
M3 vor Arbeitsantritt (BKNR/VSNR/GEBD/FANA/VONA/ADAT/BBER 01–04/
|
||||
GERF/FRDV/BVAB/BVJN aus GP5/VWAZ Stunden×100), M4 7 Tage (ADAT +
|
||||
EBSV, AGRD aus dem hr.departure.reason-Mapping, Standard 00),
|
||||
M6 nur Ausnahmefälle; **deklarative Event-Erkennung** über die
|
||||
Meldungs-Registry (keine write-diff-Hacks; idempotente Läufe).
|
||||
**Fallweise** (BEST `MA`, SART 32, Länge 450, Version 07): je
|
||||
Beschäftigungstag vor Arbeitsantritt; MVP: je AN/Vertragsbeginn
|
||||
ein Anmeldetag (tagesgenaue Einsatztage via Work-Entries = Rest).
|
||||
- **Belege (AP6)**: Lohnzettel AT (ir.actions.report auf hr.payslip,
|
||||
AT-Layout mit Bezügen/Abzügen/**DG-Anteilen**/Netto; per Structure
|
||||
über `report_id`), Monatsdienstliste (mBGM-Kontrollschicht je AN:
|
||||
BG laufend/SZ, SV-DN/SZ, SV-DG, LSt, Netto) + Monatsjournal (je
|
||||
Lohnart Summen je Lauf) auf `hr.payslip.run` (QWeb, AbstractModel).
|
||||
Kein HR-Modul nutzt `account.report` — QWeb ist der framework-
|
||||
gerechte Weg (Contribution-Register-Muster).
|
||||
|
||||
## Rechenfälle (Fixtures)
|
||||
|
||||
| RF | Konstellation | Erwartung |
|
||||
|---|---|---|
|
||||
| RF-ELDA-1 | Feld-Formatter | a/n linksbündig blank; n rechtsbündig 0-gefüllt; Cent kaufmännisch (0,005 → 1); Prozentsatz skala 3 (39,30 → 039300) |
|
||||
| RF-ELDA-2 | Bestand/Datei | Vorlauf SART 00 (VSTR/BEST/EDAT TTMMJJJJ/VERS), SANR lückenlos, SANZ inkl. Rahmen, CRLF, Dateiheader ` 01ISO-8859-15…`, Umlaute ISO-8859-15-codiert |
|
||||
| RF-L16-1 | November mit B13 (Jahr 2026) | KZ 210 = 4.118,82 (§ 25 gesamt!), KZ 220 = 2.059,41, KZ 245 = laufende Bemessung > 0; I1 (1.100) mit STNRA/JAHR/GESA; L1 (3.500) exakte Positionen: REFN/ARTL 01/BELZ 0111/SOZS 3/AVLN 1234/AGBD 150590/B210 0000411882; Vorlauf VSTR 94, BEST LF, VERS 28; SANZ 4 |
|
||||
| RF-L16-2 | Arbeitsstätte | Satz 45 (300): BKNR/VSNR/STNR; VSTR ST, BEST AD; Warnung GKZ fehlt |
|
||||
| RF-MBGM-1 | November mit B13, Angestellte (B002) | PS (BKNR/BZRM 112026/ANZM 1/GSUM = Σ VSUM), G1 (VSNR), T1 BSGR B002, Positionen T01 039050, **A03 je Basis** (AB + SZ, RSVZ −), T02 037550, V01; GSUM ≈ GP4-Beitragssummen ± 0,10 € |
|
||||
| RF-VR-1 | Vertrag ab 2020, Ende 15.02.2026 | M3 (ADAT 01012020, BBER 02, GERF N, FRDV N, VWAZ 4000, BVJN J); Registry verhindert Doppelanmeldung; M4 (ADAT 15022026, EBSV, AGRD 00) |
|
||||
|
||||
## Offene Punkte / bewusst nicht in GP7
|
||||
|
||||
- **Schwerarbeitsmeldung** (DM-ORG E.22 Satzstruktur existiert!):
|
||||
blockiert — keine ASchV-Work-Entry-Typen/Zähler im Modul, ASchV in
|
||||
RQ-Privat ❓ nicht erschlossen. Erst nach Datenmodell-Bau.
|
||||
- **§ 109a** (W1-Satz): separater Baustein (Honorare außerhalb der
|
||||
Lohnverrechnung); Mitteilungs-XSDs/Prüfkatalog beschafft.
|
||||
- **L17** (A1-Satz): keine AG-Pflicht. **Mitteilungsfile-Import**
|
||||
(Rückantwort-Fehler ins Deklarationsmodell): GP8-Rest (5.2).
|
||||
- Geringfügigen-mBGM (B010/B030-Sätze, Z04-Jahreszahlung + 2,5 %):
|
||||
parametrisieren, sobai vorkommend; mBGM je Lauf bei Mehrfachläufen
|
||||
im Monat; M6/Änderung; FB+-Lohnsteuermechanik (ANV-Sache);
|
||||
VSNR-Prüfziffer-Algorithmus (SVS-Quelle beschaffen);
|
||||
tagesgenaue fallweise-Einsatztage (Work-Entry-Auswertung).
|
||||
- Referenzbuchhaltung/Parallellauf: CRLF-Satztrenner, ELDA-Kanäle
|
||||
(Sendemodul https/ftps 40/220 MB), Fristenkalender (GP8).
|
||||
@@ -7,3 +7,6 @@ from . import abfertigung_private
|
||||
from . import bv_report
|
||||
from . import account_chart_private
|
||||
from . import account_posting_private
|
||||
from . import mbgm
|
||||
from . import versichertenmeldung
|
||||
from . import fristen_private
|
||||
@@ -0,0 +1,48 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""GP8-Privat: Verlinkung des Fristenkalenders mit den
|
||||
privatwirtschaftlichen GP7-Deklarationen (L16/mBGM-Sprungziele)."""
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class L10nAtPayrollFrist(models.Model):
|
||||
_inherit = 'l10n.at.payroll.frist'
|
||||
|
||||
ref_mbgm_id = fields.Many2one(
|
||||
'l10n.at.payroll.mbgm', string='mBGM-Deklaration',
|
||||
help='Sprungziel für SV-Fristen (Privatmodul — das '
|
||||
'Kernmodell kennt mBGM nicht).')
|
||||
|
||||
def action_zum_objekt(self):
|
||||
"""Sprung zur GP7-Deklaration (L16 oder mBGM)."""
|
||||
self.ensure_one()
|
||||
result = super().action_zum_objekt()
|
||||
if result is not True:
|
||||
return result
|
||||
if self.ref_mbgm_id:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'l10n.at.payroll.mbgm',
|
||||
'res_id': self.ref_mbgm_id.id,
|
||||
'view_mode': 'form',
|
||||
}
|
||||
return True
|
||||
|
||||
def _l10n_at_verlinke_deklarationen(self):
|
||||
"""Sprungziele für offene L16- und mBGM-Fristen setzen: neueste
|
||||
Deklaration des Bezugsjahres verlinken, wenn vorhanden."""
|
||||
for frist in self:
|
||||
if frist.fristart in ('l16', 'arbeitsstaette'):
|
||||
l16 = self.env['l10n.at.payroll.l16'].search([
|
||||
('company_id', '=', frist.company_id.id),
|
||||
('jahr', '=', frist.jahr),
|
||||
], order='id desc', limit=1)
|
||||
frist.ref_l16_id = l16 or False
|
||||
elif frist.fristart == 'sv_mbgm':
|
||||
mbgm = self.env['l10n.at.payroll.mbgm'].search([
|
||||
('company_id', '=', frist.company_id.id),
|
||||
('beitragsmonat', '>=',
|
||||
'%d-%02d-01' % (frist.jahr, frist.monat or 1)),
|
||||
('beitragsmonat', '<=',
|
||||
'%d-%02d-01' % (frist.jahr, frist.monat or 12)),
|
||||
], order='id desc', limit=1)
|
||||
frist.ref_mbgm_id = mbgm or False
|
||||
@@ -0,0 +1,506 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""Monatliche Beitragsgrundlagenmeldung mBGM/JASV (GP7/AP5) — DM-ORG
|
||||
42.7.0, Kapitel E.32 (BEST ``MB``, mBGM-Version 02; PDF-verifizierte
|
||||
Extraktion 2026-09-12).
|
||||
|
||||
Hierarchie: Paket (SART ``PS`` Selbstabrechner, Länge 305) → mBGM
|
||||
(SART ``G1``, 326) → Tarifblock (SART ``T1``, 42) → Verrechnungsbasis
|
||||
(``BS``, 33) → Verrechnungsposition (``V1``, 42).
|
||||
|
||||
Fachliche Grundlagen:
|
||||
- **KEIN DN/DG- oder Zweig-Split**: die Standardposition ``T01``
|
||||
trägt den GESAMT-Prozentsatz des Tarifsystems (Parameter
|
||||
``at_mbgm_tarif`` — BSGR-spezifisch; Werte aus dem ÖGK-
|
||||
Beitragstarifsystem, `.additional_docs/oegk/`, jeweils
|
||||
parametrisiert zu pflegen). Fehlende BSGR-Sätze brechen den Lauf
|
||||
listenweise ab (keine Erfindung von Tarifen).
|
||||
- **Abschläge als eigene Verrechnungspositionen** (D.60, verifiziert):
|
||||
A01/A02/A03 = Minderung AV auf 2/1/0 % (§ 2a AMPFG, DN-seitig —
|
||||
Abschlag = AB × (2,95 − Zielsatz) %), A09 = UV-Entfall ab 60
|
||||
(1,10 %), A10 = AV+IE-Entfall Pensionsanspruch (6,00 %), A15 =
|
||||
PV-Halbierung Bonusphase (11,40 %). A02/A10 schließen einander aus
|
||||
(Konkurrenz a4: Entfall vor Minderung — wie der GP4-Kern rechnet).
|
||||
- **BV (BMSVG)**: eigene Verrechnungsbasis ``BV`` (ungedeckelt, GP5)
|
||||
+ Position ``V01`` (1,53 %); SZ-Basis ``SZ`` (§-54-gedeckelt) +
|
||||
Position ``T02`` mit dem SZ-Gesamtsatz.
|
||||
- **RSUM aus Basis × Satz** (mBGM-eigene Tarifierung); Konsistenz
|
||||
gegen die GP4-Payslip-Summen als Prüfwarnung (Toleranz 0,10 €).
|
||||
- **Fehlanzeige**: Paket ohne mBGM-Sätze (ANZM 0) für Beitragsmonate
|
||||
ohne meldbare Daten (GP5-Deferral; DM-ORG-Form ⚠ Parallellauf
|
||||
verifizieren). **Storno**: R1-Sätze mit REFU + identischem BZRM
|
||||
(VSUM = gestornierte VSUM) für den Nachtragslauf; Nachreichung als
|
||||
neue Deklaration (GP4-Deferral Aufrollung).
|
||||
- Fallweise Beschäftigte (G3/G4 mit tagesgenauen Tarifblöcken) und
|
||||
Geringfügige ohne Tarifparameter: dokumentierter Rest — der Lauf
|
||||
bricht listenweise ab, bis `at_mbgm_tarif` gepflegt ist.
|
||||
|
||||
Dateibasiert (Prämisse P7): Generierung + Prüfprotokoll; Übertragung
|
||||
beim Mandanten. Fristen: Selbstabrechner 15., Vorschreibebetriebe 7.
|
||||
des Folgemonats (lb-mel-08) — Fristenkalender GP8."""
|
||||
from datetime import date, timedelta
|
||||
import base64
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from odoo.addons.l10n_at_hr_payroll.models.elda import (
|
||||
L10N_AT_ELDA_IDTEIL,
|
||||
L10nAtEldaBestand,
|
||||
L10nAtEldaDatei,
|
||||
L10nAtEldaSatz,
|
||||
l10n_at_euro_cent,
|
||||
)
|
||||
|
||||
from odoo.addons.l10n_at_hr_payroll.models.lohnzettel_l16 import \
|
||||
l10n_at_namen
|
||||
|
||||
# Paket (E.32, Selbstabrechner PS) — nach IDTEIL, Länge 305.
|
||||
MBGM_PAKET = [
|
||||
(2, 'REFP', 21, 40, 'a'),
|
||||
(3, 'BKNR', 61, 10, 'a'),
|
||||
(4, 'DGNA', 71, 70, 'a'),
|
||||
(5, 'MPKE', 141, 30, 'a'),
|
||||
(6, 'JAGB', 171, 1, 'a'),
|
||||
(7, 'DTEL', 172, 50, 'a'),
|
||||
(8, 'MAIL', 222, 60, 'a'),
|
||||
(9, 'BZRM', 282, 6, 'n'), # MMJJJJ
|
||||
(10, 'GSVZ', 288, 1, 'a'),
|
||||
(11, 'GSUM', 289, 11, 'n'), # Σ VSUM (Cent)
|
||||
(12, 'ANZM', 300, 6, 'n'), # Anzahl mBGM (G/R-Sätze)
|
||||
]
|
||||
|
||||
# mBGM-Satz (E.32, G1 Selbstabrechner) — nach IDTEIL, Länge 326.
|
||||
MBGM_SATZ = [
|
||||
(2, 'REFW', 21, 40, 'a'),
|
||||
(3, 'REFU', 61, 40, 'a'),
|
||||
(4, 'REFV', 101, 40, 'a'),
|
||||
(5, 'VSNR', 141, 10, 'n'),
|
||||
(6, 'FANA', 151, 70, 'a'),
|
||||
(7, 'VONA', 221, 70, 'a'),
|
||||
(8, 'VSUM', 291, 11, 'n'), # Σ RSUM (Cent)
|
||||
(9, 'VERG', 302, 1, 'a'), # 1 = SV+BV mit Versicherungszeit
|
||||
(10, 'INF1', 303, 12, 'a'),
|
||||
(11, 'INF2', 315, 12, 'a'),
|
||||
]
|
||||
|
||||
# Tarifblock (E.32, T1) — nach IDTEIL, Länge 42.
|
||||
MBGM_TARIFBLOCK = [
|
||||
(2, 'BSGR', 21, 4, 'a'), # Beschäftigtengruppe (SV-Tarifsystem)
|
||||
(3, 'ERGB', 25, 15, 'a'), # 5 × 3-stellige Ergänzungen
|
||||
(4, 'VVON', 40, 2, 'n'), # Beginn der Verrechnung (Tag)
|
||||
(5, 'KEUE', 42, 1, 'a'), # enthält KE/UE
|
||||
]
|
||||
|
||||
# Verrechnungsbasis (E.32, BS) — nach IDTEIL, Länge 33.
|
||||
MBGM_BASIS = [
|
||||
(2, 'VBTY', 21, 2, 'a'), # AB/SZ/BV/UU …
|
||||
(3, 'VBBT', 23, 11, 'n'), # Cent
|
||||
]
|
||||
|
||||
# Verrechnungsposition (E.32, V1) — nach IDTEIL, Länge 42.
|
||||
MBGM_POSITION = [
|
||||
(2, 'VPTY', 21, 3, 'a'), # T01/T02/V01/A01…A15/Z01 …
|
||||
(3, 'VPVZ', 24, 1, 'a'), # Vorzeichen Prozentsatz
|
||||
(4, 'VPTA', 25, 6, 'n'), # Prozentsatz ×1000 (3 Nachkommastellen)
|
||||
(5, 'RSVZ', 31, 1, 'a'), # Vorzeichen RSUM
|
||||
(6, 'RSUM', 32, 11, 'n'), # Cent
|
||||
]
|
||||
|
||||
|
||||
class L10nAtPayrollMbgm(models.Model):
|
||||
_name = 'l10n.at.payroll.mbgm'
|
||||
_description = 'Monatliche Beitragsgrundlagenmeldung (mBGM/JASV)'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'beitragsmonat desc, id desc'
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True,
|
||||
default=lambda self: self.env.company)
|
||||
beitragsmonat = fields.Date(
|
||||
required=True, string='Beitragsmonat',
|
||||
help='Erster Tag des Beitragsmonats (MMJJJJ).')
|
||||
art = fields.Selection(
|
||||
[('MB', 'mBGM (regulär)'),
|
||||
('ST', 'Storno (R-Sätze, iVm REFU)'),
|
||||
('FZ', 'Fehlanzeige (Paket ohne mBGM-Sätze)')],
|
||||
required=True, default='MB', tracking=True)
|
||||
storno_id = fields.Many2one(
|
||||
'l10n.at.payroll.mbgm', string='Stornierte Deklaration',
|
||||
help='Art ST: Referenz auf die ursprüngliche Deklaration; '
|
||||
'REFU der R-Sätze = REFW der stornierten mBGM je AN.')
|
||||
line_ids = fields.One2many(
|
||||
'l10n.at.payroll.mbgm.line', 'mbgm_id', string='mBGM-Sätze')
|
||||
datei = fields.Binary(attachment=True, 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 = [(
|
||||
'mbgm_company_monat_art_uniq',
|
||||
'unique(company_id, beitragsmonat, art)',
|
||||
'Für diesen Beitragsmonat existiert bereits eine mBGM-'
|
||||
'Deklaration dieser Art.')]
|
||||
|
||||
def _l10n_at_slips(self):
|
||||
self.ensure_one()
|
||||
erster = self.beitragsmonat
|
||||
letzter = (date(erster.year, erster.month, 28)
|
||||
+ timedelta(days=4)).replace(day=1) \
|
||||
- timedelta(days=1)
|
||||
return self.env['hr.payslip'].search([
|
||||
('company_id', '=', self.company_id.id),
|
||||
('date_from', '>=', erster),
|
||||
('date_from', '<=', letzter),
|
||||
('state', 'in', ('validated', 'paid')),
|
||||
])
|
||||
|
||||
def _l10n_at_slip(self, employee):
|
||||
"""Der (eindeutige) Lauf des AN im Beitragsmonat."""
|
||||
self.ensure_one()
|
||||
erster = self.beitragsmonat
|
||||
letzter = (date(erster.year, erster.month, 28)
|
||||
+ timedelta(days=4)).replace(day=1) \
|
||||
- timedelta(days=1)
|
||||
return self.env['hr.payslip'].search([
|
||||
('company_id', '=', self.company_id.id),
|
||||
('employee_id', '=', employee.id),
|
||||
('date_from', '>=', erster),
|
||||
('date_from', '<=', letzter),
|
||||
('state', 'in', ('validated', 'paid'))], limit=1)
|
||||
|
||||
def _l10n_at_bsgr(self, version, slip):
|
||||
"""Beschäftigtengruppe (SV-Tarifsystem): B001 Arbeiter, B002
|
||||
Angestellte; Lehrlinge B044/B045 und geringfügig/fallweise
|
||||
(B010/B030) ohne Default-Parameter → Lauf bricht ab, bis
|
||||
`at_mbgm_tarif` gepflegt ist (Tarifsystem-Werte, keine
|
||||
Erfindung)."""
|
||||
if version.l10n_at_lehrling:
|
||||
return 'B044' if getattr(
|
||||
version, 'l10n_at_personalgruppe', '') == 'arbeiter' \
|
||||
else 'B045'
|
||||
if slip.l10n_at_geringfuegig or version.l10n_at_fallweise:
|
||||
return 'B030'
|
||||
return 'B001' if getattr(
|
||||
version, 'l10n_at_personalgruppe', '') == 'arbeiter' \
|
||||
else 'B002'
|
||||
|
||||
def _l10n_at_tarif(self, bsgr):
|
||||
self.ensure_one()
|
||||
tarif = self.env['hr.rule.parameter']._get_parameter_from_code(
|
||||
'at_mbgm_tarif', self.beitragsmonat,
|
||||
raise_if_not_found=False) or {}
|
||||
return tarif.get(bsgr)
|
||||
|
||||
def _l10n_at_positionen(self, slip, tarif):
|
||||
"""Verrechnungspositionen je Tarifblock: T01 (Gesamtsatz,
|
||||
NSchAB-Zweig via Company-Flag) + Abschläge aus den GP4-
|
||||
Stichtagsflags; T02 auf SZ; V01 auf BV-Basis. Rückgabe:
|
||||
[(vpty, vpta, rsum_cent, basis_typ), …] — RSUM aus Basis ×
|
||||
Satz (mBGM-Tarifierung); Abschläge negativ (RSVZ '-')."""
|
||||
version = slip.version_id
|
||||
ab = sum(basis for _m, basis
|
||||
in slip._l10n_at_sv_basis_laufen_monate())
|
||||
nschab = 3.80 if (self.company_id.l10n_at_nschab
|
||||
and not version.l10n_at_lehrling
|
||||
and getattr(version, 'l10n_at_personalgruppe',
|
||||
'') == 'arbeiter') else 0.0
|
||||
satz_laufen = float(tarif['laufend']) + nschab
|
||||
positionen = [('T01', satz_laufen, ab * satz_laufen / 100.0,
|
||||
'AB')]
|
||||
# §-2a-AMPFG: Minderung nur des DN-AV (2,95 %), als eigene
|
||||
# Abschlagsposition auf JEDER betroffenen Verrechnungsbasis
|
||||
# (AB und SZ — der Abschlag senkt den Gesamtsatz je Basis;
|
||||
# DN-seitig, GP4-Konvention). Codes: 0 % → A03, 1 % → A02,
|
||||
# 2 % → A01 (D.60, verifiziert).
|
||||
minderung = 0.0
|
||||
monat = self.beitragsmonat
|
||||
if not (version.l10n_at_alv_entfall_ab
|
||||
and monat >= version.l10n_at_alv_entfall_ab):
|
||||
staffel = self.env['hr.rule.parameter'] \
|
||||
._get_parameter_from_code(
|
||||
'at_sv_av_staffel_ampfg', monat,
|
||||
raise_if_not_found=False) or {}
|
||||
for _von, bis, dn_satz in staffel.get('tiers', []):
|
||||
if ab and ab <= float(bis):
|
||||
minderung = 2.95 - float(dn_satz)
|
||||
break
|
||||
if minderung > 0:
|
||||
code = ('A03' if minderung >= 2.95
|
||||
else 'A02' if abs(minderung - 1.95) < 0.01
|
||||
else 'A01')
|
||||
positionen.append(
|
||||
(code, 0.0, -ab * minderung / 100.0, 'AB'))
|
||||
else:
|
||||
# A10 = Entfall AV+IE (DN-AV + DG-AV + DG-IESG).
|
||||
positionen.append(
|
||||
('A10', 0.0, -ab * 6.00 / 100.0, 'AB'))
|
||||
if version.l10n_at_uv_entfall_ab \
|
||||
and monat >= version.l10n_at_uv_entfall_ab:
|
||||
positionen.append(('A09', 0.0, -ab * 1.10 / 100.0, 'AB'))
|
||||
if version.l10n_at_pv_bonusphase_ab \
|
||||
and monat >= version.l10n_at_pv_bonusphase_ab:
|
||||
positionen.append(('A15', 0.0, -ab * 11.40 / 100.0, 'AB'))
|
||||
sz_basis = slip._l10n_at_svdn_basis_sz()
|
||||
if sz_basis > 0:
|
||||
satz_sz = float(tarif['sz'])
|
||||
positionen.append(
|
||||
('T02', satz_sz, sz_basis * satz_sz / 100.0, 'SZ'))
|
||||
if minderung > 0:
|
||||
code = ('A03' if minderung >= 2.95
|
||||
else 'A02' if abs(minderung - 1.95) < 0.01
|
||||
else 'A01')
|
||||
positionen.append(
|
||||
(code, 0.0, -sz_basis * minderung / 100.0, 'SZ'))
|
||||
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 action_erzeugen(self):
|
||||
for deklaration in self:
|
||||
fehler = []
|
||||
warnungen = []
|
||||
company = deklaration.company_id
|
||||
bknr = company.l10n_at_beitragskontonummer
|
||||
if not bknr:
|
||||
fehler.append('Beitragskontonummer (BKNR) fehlt.')
|
||||
vstr = (bknr or '')[:2]
|
||||
deklaration.line_ids.unlink()
|
||||
datensaetze = []
|
||||
summe_gesamt = 0
|
||||
anzahl = 0
|
||||
if deklaration.art != 'FZ':
|
||||
slips = deklaration._l10n_at_slips()
|
||||
nach_an = {}
|
||||
for slip in slips:
|
||||
nach_an.setdefault(slip.employee_id, []).append(slip)
|
||||
for employee, employee_slips in nach_an.items():
|
||||
version = employee.version_id
|
||||
if not (version and version.ssnid):
|
||||
fehler.append(
|
||||
'Mitarbeiter %s ohne Versicherungsnummer.'
|
||||
% employee.name)
|
||||
continue
|
||||
if len(employee_slips) > 1:
|
||||
fehler.append(
|
||||
'Mitarbeiter %s mit mehreren Läufen im '
|
||||
'Beitragsmonat — mBGM je Lauf ist ein '
|
||||
'dokumentierter Rest.' % employee.name)
|
||||
continue
|
||||
slip = employee_slips[0]
|
||||
if deklaration.art == 'ST':
|
||||
# Storno: R1-Satz je stornierter mBGM mit
|
||||
# REFU = REFW des Originals, VSUM wie Original
|
||||
# (E.32.2.8 — ohne T-/BS-/V-Sätze).
|
||||
original = deklaration.storno_id.line_ids \
|
||||
.filtered(lambda l: l.employee_id == employee)
|
||||
if not original:
|
||||
fehler.append(
|
||||
'Storno %s: keine stornierte mBGM '
|
||||
'am Mitarbeiter gefunden.' % employee.name)
|
||||
continue
|
||||
refw = 'MBGM-ST-%d-%s-%d' % (
|
||||
company.id,
|
||||
deklaration.beitragsmonat.strftime('%Y%m'),
|
||||
employee.id)
|
||||
storno_satz = L10nAtEldaSatz(
|
||||
L10N_AT_ELDA_IDTEIL + MBGM_SATZ, 326,
|
||||
bezeichnung='Storno %s' % employee.name)
|
||||
storno_satz.setze('SART', 'R1')
|
||||
storno_satz.setze('REFW', refw)
|
||||
storno_satz.setze('REFU', original.refw)
|
||||
storno_satz.setze('VSNR', version.ssnid)
|
||||
storno_satz.setze('VSUM', abs(original.vsum),
|
||||
cent=True)
|
||||
datensaetze.append(storno_satz)
|
||||
deklaration.env['l10n.at.payroll.mbgm.line']\
|
||||
.create({
|
||||
'mbgm_id': deklaration.id,
|
||||
'employee_id': employee.id,
|
||||
'versicherungsnummer': version.ssnid,
|
||||
'bsgr': original.bsgr,
|
||||
'refw': refw,
|
||||
'vsum': original.vsum,
|
||||
})
|
||||
summe_gesamt += original.vsum
|
||||
anzahl += 1
|
||||
continue
|
||||
bsgr = deklaration._l10n_at_bsgr(version, slip)
|
||||
tarif = deklaration._l10n_at_tarif(bsgr)
|
||||
if not tarif:
|
||||
fehler.append(
|
||||
'Mitarbeiter %s: kein Tarifsatz für BSGR '
|
||||
'%s parametrisiert (at_mbgm_tarif, Werte '
|
||||
'aus dem SV-Tarifsystem pflegen).'
|
||||
% (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 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))
|
||||
deklaration.env['l10n.at.payroll.mbgm.line'].create({
|
||||
'mbgm_id': deklaration.id,
|
||||
'employee_id': employee.id,
|
||||
'versicherungsnummer': version.ssnid,
|
||||
'bsgr': bsgr,
|
||||
'refw': 'MBGM-%d-%s-%d' % (
|
||||
company.id,
|
||||
deklaration.beitragsmonat.strftime('%Y%m'),
|
||||
employee.id),
|
||||
'vsum': vsum,
|
||||
})
|
||||
datensaetze.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)
|
||||
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])
|
||||
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',
|
||||
})
|
||||
return True
|
||||
|
||||
def _l10n_at_mbgm_datensaetze(self, employee, version, bsgr,
|
||||
positionen, vsum):
|
||||
"""G1 + Tarifblock + je Basis (AB/SZ/BV) die Positionen als
|
||||
eigene Sätze (BS/V folgen hierarchisch dem Tarifblock)."""
|
||||
self.ensure_one()
|
||||
slip = self._l10n_at_slip(employee)
|
||||
mbgm_satz = L10nAtEldaSatz(
|
||||
L10N_AT_ELDA_IDTEIL + MBGM_SATZ, 326,
|
||||
bezeichnung='mBGM %s' % employee.name)
|
||||
mbgm_satz.setze('SART', 'G1')
|
||||
mbgm_satz.setze('REFW', 'MBGM-%d-%s-%d' % (
|
||||
self.company_id.id,
|
||||
self.beitragsmonat.strftime('%Y%m'), employee.id))
|
||||
mbgm_satz.setze('VSNR', version.ssnid)
|
||||
familienname, vorname = l10n_at_namen(employee)
|
||||
mbgm_satz.setze('FANA', familienname[:70])
|
||||
mbgm_satz.setze('VONA', (vorname or '')[:70])
|
||||
mbgm_satz.setze('VSUM', abs(vsum), cent=True)
|
||||
mbgm_satz.setze('VERG', '1')
|
||||
tarifblock = L10nAtEldaSatz(
|
||||
L10N_AT_ELDA_IDTEIL + MBGM_TARIFBLOCK, 326,
|
||||
bezeichnung='Tarifblock %s' % employee.name)
|
||||
tarifblock.setze('SART', 'T1')
|
||||
tarifblock.setze('BSGR', bsgr)
|
||||
tarifblock.setze('VVON', '1')
|
||||
saetze = [mbgm_satz, tarifblock]
|
||||
for code, satz_prozent, rsum, basis_typ in positionen:
|
||||
basis = L10nAtEldaSatz(
|
||||
L10N_AT_ELDA_IDTEIL + MBGM_BASIS, 326,
|
||||
bezeichnung='Basis %s %s' % (basis_typ,
|
||||
employee.name))
|
||||
basis.setze('SART', 'BS')
|
||||
basis.setze('VBTY', basis_typ)
|
||||
basis_wert = abs(rsum) * 100.0 / satz_prozent \
|
||||
if satz_prozent else 0.0
|
||||
if basis_typ == 'AB':
|
||||
basis_wert = sum(
|
||||
b for _m, b in
|
||||
slip._l10n_at_sv_basis_laufen_monate())
|
||||
elif basis_typ == 'SZ':
|
||||
basis_wert = slip._l10n_at_svdn_basis_sz()
|
||||
else:
|
||||
basis_wert = slip._at_bv_basis_laufen() \
|
||||
+ slip._at_bv_basis_sonst()
|
||||
basis.setze('VBBT', basis_wert, cent=True)
|
||||
position = L10nAtEldaSatz(
|
||||
L10N_AT_ELDA_IDTEIL + MBGM_POSITION, 326,
|
||||
bezeichnung='Position %s %s' % (code, employee.name))
|
||||
position.setze('SART', 'V1')
|
||||
position.setze('VPTY', code)
|
||||
position.setze('VPTA', satz_prozent, skala=3)
|
||||
position.setze('RSVZ', '-' if rsum < 0 else '+')
|
||||
position.setze('RSUM', abs(rsum), cent=True)
|
||||
saetze.extend([basis, position])
|
||||
return saetze
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
l10n_at_nschab = fields.Boolean(
|
||||
string='AT Schlechtwetterentschädigungsbeitrag-Branche',
|
||||
help='Betrieb mit Schlechtwetterentschädigung (ÖGK-'
|
||||
'Tarifsystem „Normalbetriebe mit '
|
||||
'Schlechtwetterentschädigung“): Arbeiter (BSGR B001) '
|
||||
'erhalten im mBGM-T01 den um NSchAB 3,80 % erhöhten '
|
||||
'Gesamtsatz.')
|
||||
|
||||
|
||||
class L10nAtPayrollMbgmLine(models.Model):
|
||||
_name = 'l10n.at.payroll.mbgm.line'
|
||||
_description = 'mBGM — Satzdaten je AN'
|
||||
_order = 'employee_name'
|
||||
mbgm_id = fields.Many2one(
|
||||
'l10n.at.payroll.mbgm', required=True, ondelete='cascade',
|
||||
index=True)
|
||||
employee_id = fields.Many2one('hr.employee', required=True)
|
||||
employee_name = fields.Char(related='employee_id.name', store=True,
|
||||
readonly=True)
|
||||
versicherungsnummer = fields.Char()
|
||||
bsgr = fields.Char(string='Beschäftigtengruppe')
|
||||
refw = fields.Char(string='Referenzwert (REFW)')
|
||||
vsum = fields.Monetary(
|
||||
string='VSUM', currency_field='currency_id')
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', related='mbgm_id.company_id.currency_id')
|
||||
@@ -0,0 +1,406 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""Versichertenmeldung reduziert (GP7/AP5) — DM-ORG 42.7.0 Kapitel
|
||||
E.29 (BEST ``VR``, Version 03, Satzlänge 772; PDF-verifizierte
|
||||
Feldtabelle 2026-09-12): Anmeldung M3 (vor Arbeitsantritt), Abmeldung
|
||||
M4 (7 Kalendertage ab PV-Ende), Änderungsmeldung M6 nur für
|
||||
Ausnahmefälle (Inhaltsänderungen laufen über die mBGM; lb-mel-08).
|
||||
Fallweise Anmeldung (BEST ``MA``, SART 32/33, Länge 450): je
|
||||
Beschäftigungstag vor Arbeitsantritt (§ 33 Abs 1a Z 1 ASVG).
|
||||
|
||||
Event-Modell deklarativ (Dimona-auto-Muster, ohne write-diff):
|
||||
offene Anmeldungen = älteste Version eines Beschäftigungsspannen-
|
||||
Beginns ohne M3-Eintrag in der Meldungs-Registry; offene Abmeldungen
|
||||
= beendete Versionen ohne M4-Eintrag. Die Registry
|
||||
(``l10n.at.payroll.vr.event``) protokolliert gesendete Meldungen
|
||||
(REFW/ADAT), damit erneute Läufe idempotent bleiben.
|
||||
|
||||
Felder: BBER 01 Arbeiter/02 Angestellte(r)/03–04 Lehrling (D.39),
|
||||
GERF geringfügig (J/N), FRDV freies Dienstverhältnis, ADAT An-/
|
||||
Abmeldedatum, EBSV arbeitsrechtliches Ende vs ADAT (UEL/KE-Verlänger
|
||||
ung der PV sind nicht modelliert → ADAT = contract_date_end),
|
||||
AGRD eSV-Abmeldegrund (Mapping am hr.departure.reason, Standard 00),
|
||||
BVAB/BVJN aus dem GP5-BMSVG-Flag, VWAZ vereinbarte Wochenarbeitszeit
|
||||
(Stunden ×100, neu für Meldedaten ab 01.01.2026).
|
||||
|
||||
Dateibasiert (Prämisse P7); Bestätigungen/Übertragung beim Mandanten."""
|
||||
from datetime import date
|
||||
import base64
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from odoo.addons.l10n_at_hr_payroll.models.elda import (
|
||||
L10N_AT_ELDA_IDTEIL,
|
||||
L10nAtEldaBestand,
|
||||
L10nAtEldaDatei,
|
||||
L10nAtEldaSatz,
|
||||
)
|
||||
|
||||
from odoo.addons.l10n_at_hr_payroll.models.lohnzettel_l16 import \
|
||||
l10n_at_namen
|
||||
|
||||
# Versichertenmeldung (E.29, Version 03) — nach IDTEIL, Länge 772.
|
||||
VR_SATZ = [
|
||||
(2, 'REFW', 21, 40, 'a'),
|
||||
(3, 'REFU', 61, 40, 'a'),
|
||||
(4, 'BKNR', 101, 10, 'a'),
|
||||
(5, 'DGNA', 111, 70, 'a'),
|
||||
(6, 'DTEL', 181, 50, 'a'),
|
||||
(7, 'MAIL', 231, 60, 'a'),
|
||||
(8, 'INF1', 291, 12, 'a'),
|
||||
(9, 'INF2', 303, 12, 'a'),
|
||||
(10, 'VSNR', 315, 10, 'n'),
|
||||
(11, 'GEBD', 325, 8, 'n'), # TTMMJJJJ
|
||||
(12, 'REFV', 333, 40, 'a'),
|
||||
(13, 'FANA', 373, 70, 'a'),
|
||||
(14, 'VONA', 443, 70, 'a'),
|
||||
(15, 'ADAT', 513, 8, 'n'), # TTMMJJJJ
|
||||
(16, 'BDAT', 521, 8, 'n'),
|
||||
(17, 'RDAT', 529, 8, 'n'),
|
||||
(18, 'BBER', 537, 2, 'a'), # Beschäftigungsbereich (D.39)
|
||||
(19, 'GERF', 539, 1, 'a'), # geringfügig J/N
|
||||
(20, 'FRDV', 540, 1, 'a'), # freier Dienstvertrag J/N
|
||||
(21, 'EBSV', 541, 8, 'n'), # Ende des Beschäftigungsverhältnisses
|
||||
(22, 'AGRD', 549, 2, 'a'), # Abmeldegrund (D.22)
|
||||
(23, 'SAGR', 551, 20, 'a'),
|
||||
(24, 'KEAB', 571, 8, 'n'),
|
||||
(25, 'KEBI', 579, 8, 'n'),
|
||||
(26, 'UEAB', 587, 8, 'n'),
|
||||
(27, 'UEBI', 595, 8, 'n'),
|
||||
(28, 'BVAB', 603, 8, 'n'), # BV-Beitrag ab
|
||||
(29, 'BVEN', 611, 8, 'n'),
|
||||
(30, 'BVJN', 619, 1, 'a'), # Betriebliche Vorsorge J/N
|
||||
(31, 'UMDA', 620, 8, 'n'),
|
||||
(32, 'RUMD', 628, 8, 'n'),
|
||||
(33, 'SOUM', 636, 1, 'a'),
|
||||
(34, 'ZTUM', 637, 2, 'a'),
|
||||
(35, 'ZKUM', 639, 10, 'a'),
|
||||
(36, 'RWUM', 649, 40, 'a'),
|
||||
(37, 'RUUM', 689, 40, 'a'),
|
||||
(38, 'BKUM', 729, 40, 'a'),
|
||||
(39, 'VWAZ', 769, 4, 'n'), # Stunden ×100
|
||||
]
|
||||
|
||||
# Fallweise Anmeldung (E.18, Version 07) — nach IDTEIL, Länge 450.
|
||||
FALLWEISE_SATZ = [
|
||||
(2, 'BKNR', 21, 10, 'a'),
|
||||
(3, 'DGNA', 31, 70, 'a'),
|
||||
(4, 'VSNR', 101, 10, 'n'),
|
||||
(5, 'GEBD', 111, 8, 'n'),
|
||||
(6, 'FANA', 119, 70, 'a'),
|
||||
(7, 'VONA', 189, 70, 'a'),
|
||||
(8, 'GESL', 259, 1, 'n'), # 1 männlich/2 weiblich …
|
||||
(9, 'ADAT', 260, 8, 'n'), # Tag der fallweisen Beschäftigung
|
||||
(10, 'BKFZ', 268, 3, 'a'),
|
||||
(11, 'BPLZ', 271, 9, 'a'),
|
||||
(12, 'BORT', 280, 40, 'a'),
|
||||
(14, 'WOBD', 321, 12, 'a'),
|
||||
(15, 'ZOBD', 333, 15, 'a'),
|
||||
(17, 'REFN', 410, 30, 'a'),
|
||||
(18, 'VTAZ', 440, 4, 'n'), # Stunden ×100
|
||||
]
|
||||
|
||||
|
||||
class L10nAtPayrollVrEvent(models.Model):
|
||||
"""Meldungs-Registry: je gesendeter Meldung ein Eintrag (Art,
|
||||
ADAT, REFW) — Grundlage für die deklarative Event-Erkennung der
|
||||
offenen An-/Abmeldungen."""
|
||||
_name = 'l10n.at.payroll.vr.event'
|
||||
_description = 'Versichertenmeldung — gesendete Meldungen'
|
||||
_order = 'adatum, id'
|
||||
|
||||
company_id = fields.Many2one('res.company', required=True,
|
||||
default=lambda self: self.env.company)
|
||||
employee_id = fields.Many2one('hr.employee', required=True,
|
||||
index=True, ondelete='cascade')
|
||||
version_id = fields.Many2one('hr.version', required=True,
|
||||
ondelete='cascade')
|
||||
art = fields.Selection(
|
||||
[('M3', 'Anmeldung'), ('M4', 'Abmeldung'), ('M6', 'Änderung'),
|
||||
('MA', 'Fallweise Anmeldung')],
|
||||
required=True)
|
||||
adatum = fields.Date(required=True)
|
||||
refw = fields.Char(required=True)
|
||||
|
||||
|
||||
class L10nAtPayrollVr(models.Model):
|
||||
_name = 'l10n.at.payroll.vr'
|
||||
_description = 'Versichertenmeldung (An-/Abmeldung) — ELDA-Lauf'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'id desc'
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True,
|
||||
default=lambda self: self.env.company)
|
||||
stichtag = fields.Date(
|
||||
default=fields.Date.today,
|
||||
help='Anmeldungen bis zu diesem Tag (M3 ist eigentlich VOR '
|
||||
'Arbeitsantritt zu übermitteln — der Lauf meldet '
|
||||
'beginnende Beschäftigungsverhältnisse).')
|
||||
nur_fallweise = fields.Boolean(
|
||||
string='Nur fallweise Anmeldungen (BEST MA)',
|
||||
help='Erzeugt ausschließlich Anmeldungen fallweise '
|
||||
'Beschäftigter (je Beschäftigungstag, SART 32).')
|
||||
datei = fields.Binary(attachment=True, 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)
|
||||
event_ids = fields.Many2many(
|
||||
'l10n.at.payroll.vr.event', string='Gesendete Meldungen',
|
||||
copy=False)
|
||||
|
||||
def _l10n_at_offene_anmeldungen(self):
|
||||
"""Älteste aktive Version je AN ohne M3-Eintrag: Beginn eines
|
||||
Beschäftigungsverhältnisses (früheste contract_date_start)."""
|
||||
self.ensure_one()
|
||||
offene = []
|
||||
versionen = self.env['hr.version'].search([
|
||||
('company_id', '=', self.company_id.id),
|
||||
('contract_date_start', '!=', False),
|
||||
('active', '=', True),
|
||||
]).with_company(self.company_id)
|
||||
nach_an = {}
|
||||
for version in versionen:
|
||||
nach_an.setdefault(version.employee_id, version)
|
||||
if version.contract_date_start \
|
||||
< nach_an[version.employee_id].contract_date_start:
|
||||
nach_an[version.employee_id] = version
|
||||
registry = self.env['l10n.at.payroll.vr.event'].search([
|
||||
('company_id', '=', self.company_id.id),
|
||||
('art', 'in', ('M3', 'MA')),
|
||||
])
|
||||
gesehen = {(e.employee_id.id, e.adatum) for e in registry}
|
||||
for employee, version in nach_an.items():
|
||||
if (employee.id, version.contract_date_start) in gesehen:
|
||||
continue
|
||||
if version.contract_date_start > self.stichtag:
|
||||
continue
|
||||
offene.append(version)
|
||||
return sorted(offene, key=lambda v: v.contract_date_start)
|
||||
|
||||
def _l10n_at_offene_abmeldungen(self):
|
||||
self.ensure_one()
|
||||
abmeldungen = []
|
||||
versionen = self.env['hr.version'].search([
|
||||
('company_id', '=', self.company_id.id),
|
||||
('contract_date_end', '!=', False),
|
||||
]).with_company(self.company_id)
|
||||
registry = self.env['l10n.at.payroll.vr.event'].search([
|
||||
('company_id', '=', self.company_id.id),
|
||||
('art', '=', 'M4'),
|
||||
])
|
||||
gesehen = {(e.employee_id.id, e.adatum) for e in registry}
|
||||
for version in versionen:
|
||||
if (version.employee_id.id, version.contract_date_end) \
|
||||
in gesehen:
|
||||
continue
|
||||
abmeldungen.append(version)
|
||||
return sorted(abmeldungen, key=lambda v: v.contract_date_end)
|
||||
|
||||
def _l10n_at_bber(self, version):
|
||||
if version.l10n_at_lehrling:
|
||||
return '03' if getattr(
|
||||
version, 'l10n_at_personalgruppe', '') == 'arbeiter' \
|
||||
else '04'
|
||||
return '01' if getattr(
|
||||
version, 'l10n_at_personalgruppe', '') == 'arbeiter' \
|
||||
else '02'
|
||||
|
||||
def _l10n_at_geringfuegig(self, version):
|
||||
"""Vorausschauende Geringfügigkeits-Einschätzung zur Anmeldung:
|
||||
Kataloggehalt/Überzahlung gegen die GFG (§ 5 Abs 2 ASVG); die
|
||||
endgültige Bewertung läuft monatlich über den GP4-Kern."""
|
||||
gfg = float(self.env['hr.rule.parameter']
|
||||
._get_parameter_from_code(
|
||||
'at_sv_gfg_monatlich', version.contract_date_start,
|
||||
raise_if_not_found=False) or 0.0)
|
||||
gehalt = version.wage \
|
||||
or (version.kv_gruppe_id._get_gehalt(
|
||||
version.erfahrungsstufe, version.contract_date_start)
|
||||
if getattr(version, 'kv_gruppe_id', False) else 0.0)
|
||||
ueberzahlung = getattr(version, 'ueberzahlung', 0.0) or 0.0
|
||||
if ueberzahlung:
|
||||
gehalt = ueberzahlung
|
||||
return bool(gfg and gehalt and gehalt <= gfg)
|
||||
|
||||
def action_erzeugen(self):
|
||||
for lauf in self:
|
||||
fehler = []
|
||||
company = lauf.company_id
|
||||
bknr = company.l10n_at_beitragskontonummer
|
||||
if not bknr:
|
||||
raise UserError('Beitragskontonummer (BKNR) fehlt.')
|
||||
datensaetze = []
|
||||
ereignisse = []
|
||||
vstr = bknr[:2]
|
||||
if lauf.nur_fallweise:
|
||||
versionen = self.env['hr.version'].search([
|
||||
('company_id', '=', company.id),
|
||||
('l10n_at_fallweise', '=', True),
|
||||
('active', '=', True),
|
||||
])
|
||||
registry = self.env['l10n.at.payroll.vr.event'].search([
|
||||
('company_id', '=', company.id),
|
||||
('art', '=', 'MA'),
|
||||
])
|
||||
gesehen = {(e.employee_id.id, e.adatum)
|
||||
for e in registry}
|
||||
for version in versionen:
|
||||
if not version.ssnid:
|
||||
fehler.append('%s ohne Versicherungsnummer.'
|
||||
% version.employee_id.name)
|
||||
continue
|
||||
# MVP: je AN ein Anmeldetag (der Vertragsbeginn);
|
||||
# tagesgenaue Einsatztage = documented Rest
|
||||
# (Work-Entry-Auswertung).
|
||||
adat = version.contract_date_start
|
||||
if not adat or (version.employee_id.id, adat) \
|
||||
in gesehen:
|
||||
continue
|
||||
satz = L10nAtEldaSatz(
|
||||
L10N_AT_ELDA_IDTEIL + FALLWEISE_SATZ, 450,
|
||||
bezeichnung='Fallweise %s'
|
||||
% version.employee_id.name)
|
||||
satz.setze('SART', '32')
|
||||
satz.setze('VSTR', vstr)
|
||||
satz.setze('BKNR', bknr)
|
||||
satz.setze('DGNA', company.name[:70])
|
||||
satz.setze('VSNR', version.ssnid)
|
||||
satz.setze_datum('GEBD', version.employee_id.birthday)
|
||||
satz.setze('FANA', (version.private_last_name
|
||||
or '')[:70])
|
||||
satz.setze('VONA', (version.private_first_name
|
||||
or '')[:70])
|
||||
satz.setze('GESL', '2'
|
||||
if version.sex == 'female' else '1')
|
||||
satz.setze_datum('ADAT', adat)
|
||||
kalender = version.resource_calendar_id
|
||||
if kalender:
|
||||
satz.setze('VTAZ', kalender.hours_per_day,
|
||||
skala=2)
|
||||
refn = 'MA-%d-%s-%d' % (
|
||||
company.id, adat.strftime('%Y%m%d'),
|
||||
version.employee_id.id)
|
||||
satz.setze('REFN', refn)
|
||||
datensaetze.append(satz)
|
||||
ereignisse.append((version, 'MA', adat, refn))
|
||||
else:
|
||||
for version in lauf._l10n_at_offene_anmeldungen():
|
||||
if not version.ssnid:
|
||||
fehler.append(
|
||||
'Anmeldung %s ohne Versicherungsnummer.'
|
||||
% version.employee_id.name)
|
||||
continue
|
||||
satz = L10nAtEldaSatz(
|
||||
L10N_AT_ELDA_IDTEIL + VR_SATZ, 772,
|
||||
bezeichnung='M3 %s' % version.employee_id.name)
|
||||
refw = 'M3-%d-%s-%d' % (
|
||||
company.id,
|
||||
version.contract_date_start.strftime('%Y%m%d'),
|
||||
version.employee_id.id)
|
||||
satz.setze('SART', 'M3')
|
||||
satz.setze('VSTR', vstr)
|
||||
satz.setze('REFW', refw)
|
||||
satz.setze('BKNR', bknr)
|
||||
satz.setze('DGNA', company.name[:70])
|
||||
satz.setze('VSNR', version.ssnid)
|
||||
satz.setze_datum(
|
||||
'GEBD', version.employee_id.birthday)
|
||||
familienname, vorname = l10n_at_namen(
|
||||
version.employee_id)
|
||||
satz.setze('FANA', familienname[:70])
|
||||
satz.setze('VONA', (vorname or '')[:70])
|
||||
satz.setze_datum('ADAT', version.contract_date_start)
|
||||
satz.setze('BBER', lauf._l10n_at_bber(version))
|
||||
satz.setze('GERF', 'J' if lauf
|
||||
._l10n_at_geringfuegig(version) else 'N')
|
||||
satz.setze('FRDV', 'N')
|
||||
if getattr(version, 'l10n_at_abfertigung_neu',
|
||||
False):
|
||||
satz.setze('BVAB',
|
||||
version.contract_date_start
|
||||
.strftime('%d%m%Y'))
|
||||
satz.setze('BVJN', 'J')
|
||||
kalender = version.resource_calendar_id
|
||||
if kalender:
|
||||
satz.setze('VWAZ', kalender.hours_per_week,
|
||||
skala=2)
|
||||
datensaetze.append(satz)
|
||||
ereignisse.append((version, 'M3',
|
||||
version.contract_date_start,
|
||||
refw))
|
||||
for version in lauf._l10n_at_offene_abmeldungen():
|
||||
if not version.ssnid:
|
||||
fehler.append(
|
||||
'Abmeldung %s ohne Versicherungsnummer.'
|
||||
% version.employee_id.name)
|
||||
continue
|
||||
satz = L10nAtEldaSatz(
|
||||
L10N_AT_ELDA_IDTEIL + VR_SATZ, 772,
|
||||
bezeichnung='M4 %s' % version.employee_id.name)
|
||||
refw = 'M4-%d-%s-%d' % (
|
||||
company.id,
|
||||
version.contract_date_end.strftime('%Y%m%d'),
|
||||
version.employee_id.id)
|
||||
agrd = getattr(
|
||||
version.departure_reason_id, 'l10n_at_agrd',
|
||||
False) or '00'
|
||||
satz.setze('SART', 'M4')
|
||||
satz.setze('VSTR', vstr)
|
||||
satz.setze('REFW', refw)
|
||||
satz.setze('BKNR', bknr)
|
||||
satz.setze('DGNA', company.name[:70])
|
||||
satz.setze('VSNR', version.ssnid)
|
||||
satz.setze_datum(
|
||||
'GEBD', version.employee_id.birthday)
|
||||
familienname, vorname = l10n_at_namen(
|
||||
version.employee_id)
|
||||
satz.setze('FANA', familienname[:70])
|
||||
satz.setze('VONA', (vorname or '')[:70])
|
||||
satz.setze_datum('ADAT', version.contract_date_end)
|
||||
satz.setze_datum('EBSV', version.contract_date_end)
|
||||
satz.setze('AGRD', agrd)
|
||||
datensaetze.append(satz)
|
||||
ereignisse.append((version, 'M4',
|
||||
version.contract_date_end,
|
||||
refw))
|
||||
if fehler:
|
||||
lauf.protokoll = 'FEHLER:\n' + '\n'.join(fehler)
|
||||
raise UserError('Versichertenmeldung mit Fehlern '
|
||||
'abgebrochen:\n' + '\n'.join(fehler))
|
||||
if not datensaetze:
|
||||
raise UserError('Keine offenen Meldungen zum '
|
||||
'Stichtag %s.' % lauf.stichtag)
|
||||
bestand = L10nAtEldaBestand(
|
||||
'MA' if lauf.nur_fallweise else 'VR', vstr, '07'
|
||||
if lauf.nur_fallweise else '03',
|
||||
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])
|
||||
ereignis_werte = []
|
||||
for version, art, adatum, refw in ereignisse:
|
||||
ereignis_werte.append({
|
||||
'company_id': company.id,
|
||||
'employee_id': version.employee_id.id,
|
||||
'version_id': version.id,
|
||||
'art': art,
|
||||
'adatum': adatum,
|
||||
'refw': refw,
|
||||
})
|
||||
ereignisse = self.env['l10n.at.payroll.vr.event'].create(
|
||||
ereignis_werte) if ereignis_werte else \
|
||||
self.env['l10n.at.payroll.vr.event']
|
||||
lauf.write({
|
||||
'datei': base64.b64encode(datei.render()),
|
||||
'datei_name': 'versichertenmeldung_%s.txt' % (
|
||||
fields.Date.today().strftime('%Y%m%d'),),
|
||||
'event_ids': [(6, 0, ereignisse.ids)],
|
||||
'state': 'done',
|
||||
'protokoll': 'Meldungen: %d' % len(datensaetze),
|
||||
})
|
||||
return True
|
||||
@@ -0,0 +1,149 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- ============================================================ -->
|
||||
<!-- GP8: All-in-Deckungsrechnung (QWeb auf hr.payslip) und -->
|
||||
<!-- DAG-Ausweis (QWeb auf hr.payslip.run). -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="action_report_allin_deckung" model="ir.actions.report">
|
||||
<field name="name">All-in-Deckungsrechnung (§ 2g AVRAG)</field>
|
||||
<field name="model">hr.payslip</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">l10n_at_hr_payroll_private.report_allin_deckung</field>
|
||||
<field name="report_file">l10n_at_hr_payroll_private.report_allin_deckung</field>
|
||||
<field name="paperformat_id" ref="hr_payroll.paperformat_euro_light"/>
|
||||
<field name="binding_model_id" ref="hr_payroll.model_hr_payslip"/>
|
||||
<field name="binding_type">report</field>
|
||||
</record>
|
||||
|
||||
<record id="action_report_dag_ausweis" model="ir.actions.report">
|
||||
<field name="name">DAG-Ausweis (§ 1 DAG)</field>
|
||||
<field name="model">hr.payslip.run</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">l10n_at_hr_payroll_private.report_dag_ausweis</field>
|
||||
<field name="report_file">l10n_at_hr_payroll_private.report_dag_ausweis</field>
|
||||
<field name="paperformat_id" ref="hr_payroll.paperformat_euro_light"/>
|
||||
<field name="binding_model_id" ref="hr_payroll.model_hr_payslip_run"/>
|
||||
<field name="binding_type">report</field>
|
||||
</record>
|
||||
|
||||
<!-- All-in-Deckungsrechnung: GP2 `_at_allin_deckung()` als
|
||||
Ausweis — wie viel der Pauschale von welchen Bezügen gedeckt
|
||||
ist; Rest = Über-/Unterdeckung. -->
|
||||
<template id="report_allin_deckung">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="slip">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page">
|
||||
<h2>All-in-Deckungsrechnung</h2>
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<td><strong>Mitarbeiter:in</strong></td>
|
||||
<td><t t-out="slip.employee_id.name"/></td>
|
||||
<td><strong>Periode</strong></td>
|
||||
<td><t t-out="slip.date_from"/> – <t t-out="slip.date_to"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>All-in-Pauschale</strong></td>
|
||||
<td>
|
||||
<span t-out="slip.version_id.allin_pauchale"
|
||||
t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
|
||||
</td>
|
||||
<td><strong>Grundlohn (KV)</strong></td>
|
||||
<td>
|
||||
<span t-out="slip.version_id.allin_grundlohn"
|
||||
t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3>Bezüge vs. Pauschale</h3>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bezug</th>
|
||||
<th class="text-end">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="slip.line_ids.filtered(lambda l: l.total and l.salary_rule_id.l10n_at_bemessung != 'neutral' and l.salary_rule_id.category_id.code == 'ATP_BRUTTO')" t-as="line">
|
||||
<td><t t-out="line.name"/></td>
|
||||
<td class="text-end">
|
||||
<span t-out="line.total" t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<t t-set="deckung" t-value="slip._at_allin_deckung()"/>
|
||||
<t t-if="deckung and deckung.get('rest') is not None">
|
||||
<p class="h4 text-end">
|
||||
<strong>Rest:</strong>
|
||||
<span t-out="deckung['rest']"
|
||||
t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
|
||||
<t t-if="deckung['rest'] > 0">
|
||||
<span class="text-warning">(Überdeckung — Pauschale übersteigt die Bezüge)</span>
|
||||
</t>
|
||||
<t t-if="deckung['rest'] < 0">
|
||||
<span class="text-danger">(Unterdeckung — Bezüge übersteigen die Pauschale)</span>
|
||||
</t>
|
||||
</p>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- DAG-Ausweis: § 1 DAG je Monat — Lohnsumme der Geringfügigen,
|
||||
Schwelle, Abgabe. -->
|
||||
<template id="report_dag_ausweis">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="run">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page">
|
||||
<h2>DAG-Ausweis (§ 1 DAG) — <t t-out="run.name"/></h2>
|
||||
<t t-set="dag" t-value="run.company_id._l10n_at_dag(run.slip_ids[:1].date_from if run.slip_ids else None)"/>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kennzahl</th>
|
||||
<th class="text-end">Wert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Anzahl geringfügig Beschäftigte</td>
|
||||
<td class="text-end"><t t-out="dag and dag.get('anzahl', 0) or 0"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Lohnsumme (ohne SZ)</td>
|
||||
<td class="text-end">
|
||||
<span t-out="dag and dag.get('lohnsumme', 0.0) or 0.0"
|
||||
t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Schwelle (1,5 × GFG)</td>
|
||||
<td class="text-end">
|
||||
<span t-out="dag and dag.get('schwelle', 0.0) or 0.0"
|
||||
t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Dienstgeberabgabe (19,4 %)</strong></td>
|
||||
<td class="text-end">
|
||||
<strong><span t-out="dag and dag.get('abgabe', 0.0) or 0.0"
|
||||
t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="small text-muted">
|
||||
Voraussetzungen: mindestens 2 geringfügig Beschäftigte
|
||||
und Lohnsumme über der Schwelle (1,5 × GFG).
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -12,3 +12,8 @@ access_l10n_at_payroll_kv_import_line,L10n AT Payroll KV-Import Line,model_l10n_
|
||||
access_l10n_at_bv_kasse_user,L10n AT BV-Kasse User,model_l10n_at_bv_kasse,hr_payroll.group_hr_payroll_user,1,0,0,0
|
||||
access_l10n_at_bv_kasse_manager,L10n AT BV-Kasse Manager,model_l10n_at_bv_kasse,hr_payroll.group_hr_payroll_manager,1,1,1,1
|
||||
access_l10n_at_bv_endabrechnung_wizard,L10n AT BV-Endabrechnung Wizard,model_l10n_at_bv_endabrechnung_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||
access_l10n_at_payroll_mbgm_user,L10n AT Payroll mBGM User,model_l10n_at_payroll_mbgm,hr_payroll.group_hr_payroll_user,1,1,1,0
|
||||
access_l10n_at_payroll_mbgm_manager,L10n AT Payroll mBGM Manager,model_l10n_at_payroll_mbgm,hr_payroll.group_hr_payroll_manager,1,1,1,1
|
||||
access_l10n_at_payroll_mbgm_line_user,L10n AT Payroll mBGM-Line User,model_l10n_at_payroll_mbgm_line,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||
access_l10n_at_payroll_vr_user,L10n AT Payroll VR User,model_l10n_at_payroll_vr,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||
access_l10n_at_payroll_vr_event_user,L10n AT Payroll VR-Event User,model_l10n_at_payroll_vr_event,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||
|
@@ -5,3 +5,4 @@ from . import test_lst_private
|
||||
from . import test_sv_private
|
||||
from . import test_bv_private
|
||||
from . import test_account_private
|
||||
from . import test_meldewesen_private
|
||||
@@ -0,0 +1,183 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""GP7 privat: mBGM (Paket/mBGM/Tarifblock/Basis/Position-Hierarchie
|
||||
aus GP4-Beitragssummen, T01-Gesamtsatz aus at_mbgm_tarif, Abschläge
|
||||
A01/A02/A03 §-2a-AMPFG, A09/A10/A15, BV-Basis V01) und
|
||||
Versichertenmeldung M3/M4 (deklarative Event-Erkennung,
|
||||
Meldungs-Registry, AGRD)."""
|
||||
import base64
|
||||
import datetime
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class MeldewesenPrivateTest(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.company = self.env.company
|
||||
self.company.write({
|
||||
'l10n_at_beitragskontonummer': '1412345678',
|
||||
'l10n_at_lohnsteuernummer': '123456789',
|
||||
})
|
||||
self.structure = self.env.ref(
|
||||
'l10n_at_hr_payroll_private.structure_at_privat')
|
||||
self.kv2203 = self.env.ref(
|
||||
'l10n_at_hr_payroll_private.kv_si_2203')
|
||||
self.gruppe_ii = self.env.ref(
|
||||
'l10n_at_hr_payroll_private.kv2203_grp_ii')
|
||||
self.calendar = self.env['resource.calendar'].create({
|
||||
'name': 'AT Test 40h',
|
||||
'tz': 'Europe/Vienna',
|
||||
'hours_per_day': 8,
|
||||
'attendance_ids': [
|
||||
(0, 0, {'name': 'Tag %d' % wd, 'dayofweek': str(wd),
|
||||
'hour_from': 8.0, 'hour_to': 16.0})
|
||||
for wd in range(5)
|
||||
],
|
||||
})
|
||||
self.company.resource_calendar_id = self.calendar
|
||||
employee = self.env['hr.employee'].create({
|
||||
'name': 'mBGM 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',
|
||||
'kv_id': self.kv2203.id,
|
||||
'kv_gruppe_id': self.gruppe_ii.id,
|
||||
'erfahrungsstufe': 1,
|
||||
'resource_calendar_id': self.calendar.id,
|
||||
})
|
||||
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': 'mBGM %d/%d' % (monat, jahr),
|
||||
'employee_id': self.employee.id,
|
||||
'struct_id': self.structure.id,
|
||||
'date_from': erster,
|
||||
'date_to': letzter,
|
||||
})
|
||||
slip.compute_sheet()
|
||||
if confirm:
|
||||
slip.action_payslip_done()
|
||||
return slip
|
||||
|
||||
def _total(self, slip, code):
|
||||
return sum(slip.line_ids.filtered(
|
||||
lambda l: l.code == code).mapped('total'))
|
||||
|
||||
def test_01_mbgm_november(self):
|
||||
"""November mit B13 (KV-Katalog 2.059,41): Paket PS mit
|
||||
BZRM/GSUM/ANZM, mBGM G1 (VSNR, VSUM), Tarifblock T1 (BSGR
|
||||
B002), Basen AB/SZ/BV mit Positionen T01 (39,30 % Gesamtsatz),
|
||||
T02 (34,60 %), V01 (1,53 %) — Konsistenz gegen die
|
||||
GP4-Beitragssummen."""
|
||||
slip = self._slip(11)
|
||||
deklaration = self.env['l10n.at.payroll.mbgm'].create({
|
||||
'company_id': self.company.id,
|
||||
'beitragsmonat': datetime.date(2026, 11, 1),
|
||||
'art': 'MB',
|
||||
})
|
||||
deklaration.action_erzeugen()
|
||||
self.assertEqual(deklaration.state, 'done')
|
||||
self.assertEqual(len(deklaration.line_ids), 1)
|
||||
self.assertEqual(deklaration.line_ids.bsgr, 'B002')
|
||||
roh = base64.b64decode(deklaration.datei).decode('ISO-8859-15')
|
||||
zeilen = roh.strip('\r\n').split('\r\n')
|
||||
# Dateiheader + Vorlauf + PS + G1 + T1 + 5×(BS+V1)
|
||||
# (T01, A03, T02, A03-SZ, V01 BV) + Schluss: 13 Daten-Sätze
|
||||
# + 3 Rahmen-Zeilen = 16.
|
||||
self.assertEqual(len(zeilen), 16)
|
||||
vorlauf = zeilen[1]
|
||||
self.assertEqual(vorlauf[0:2], '00')
|
||||
self.assertEqual(vorlauf[18:20], '14')
|
||||
self.assertEqual(vorlauf[22:24], 'MB')
|
||||
daten = zeilen[2:-1]
|
||||
paket = daten[0]
|
||||
self.assertEqual(paket[0:2], 'PS')
|
||||
self.assertEqual(paket[60:70], '1412345678') # BKNR
|
||||
self.assertEqual(paket[281:287], '112026') # BZRM MMJJJJ
|
||||
self.assertEqual(paket[299:305], '000001') # ANZM
|
||||
gsum = int(paket[288:299])
|
||||
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())
|
||||
self.assertLessEqual(abs(gsum / 100.0 - ist), 0.10)
|
||||
satzarten = [zeile[0:2] for zeile in daten]
|
||||
self.assertEqual(satzarten, ['PS', 'G1', 'T1', 'BS',
|
||||
'V1', 'BS', 'V1', 'BS', 'V1',
|
||||
'BS', 'V1', 'BS', 'V1'])
|
||||
g1 = daten[1]
|
||||
self.assertEqual(g1[140:150], '1234150590')
|
||||
t1 = daten[2]
|
||||
self.assertEqual(t1[20:24], 'B002')
|
||||
v1_t01 = daten[4]
|
||||
self.assertEqual(v1_t01[20:23], 'T01')
|
||||
self.assertEqual(v1_t01[24:30], '039050') # 39,05 %
|
||||
# §-2a-AMPFG-Abschlag A03 (BG 2.059,41 ≤ 2.225 → AV 0 %),
|
||||
# auf AB und SZ je als eigene Position.
|
||||
v1_a03 = daten[6]
|
||||
self.assertEqual(v1_a03[20:23], 'A03')
|
||||
self.assertEqual(v1_a03[30:31], '-') # RSVZ negativ
|
||||
v1_t02 = daten[8]
|
||||
self.assertEqual(v1_t02[20:23], 'T02')
|
||||
self.assertEqual(v1_t02[24:30], '037550') # 37,55 %
|
||||
v1_a03_sz = daten[10]
|
||||
self.assertEqual(v1_a03_sz[20:23], 'A03')
|
||||
|
||||
def test_02_vr_an_abmeldung(self):
|
||||
"""M3 (vor Arbeitsantritt) mit BBER/GERF/BVJN/VWAZ und M4
|
||||
(7-Tage-Frist) mit AGRD aus dem Abgangsgrund-Mapping; die
|
||||
Registry verhindert Doppelanmeldungen."""
|
||||
lauf = self.env['l10n.at.payroll.vr'].create({
|
||||
'company_id': self.company.id,
|
||||
'stichtag': datetime.date(2026, 1, 31),
|
||||
})
|
||||
lauf.action_erzeugen()
|
||||
roh = base64.b64decode(lauf.datei).decode('ISO-8859-15')
|
||||
zeilen = roh.strip('\r\n').split('\r\n')
|
||||
m3 = [z for z in zeilen if z[0:2] == 'M3']
|
||||
self.assertEqual(len(m3), 1)
|
||||
self.assertEqual(m3[0][100:110], '1412345678') # BKNR
|
||||
self.assertEqual(m3[0][314:324], '1234150590') # VSNR
|
||||
self.assertEqual(m3[0][324:332], '15051990') # GEBD TTMMJJJJ
|
||||
self.assertEqual(m3[0][512:520], '01012020') # ADAT
|
||||
self.assertEqual(m3[0][536:538], '02') # BBER Angestellte
|
||||
self.assertEqual(m3[0][538:539], 'N') # GERF
|
||||
self.assertEqual(m3[0][539:540], 'N') # FRDV
|
||||
self.assertEqual(m3[0][768:772], '4000') # VWAZ 40 h
|
||||
self.assertEqual(m3[0][618:619], 'J') # BVJN (GP5)
|
||||
# Registry verhindert Doppelanmeldung; Abmeldung nach Ende.
|
||||
zweiter = self.env['l10n.at.payroll.vr'].create({
|
||||
'company_id': self.company.id,
|
||||
'stichtag': datetime.date(2026, 2, 28),
|
||||
})
|
||||
with self.assertRaises(UserError):
|
||||
zweiter.action_erzeugen()
|
||||
self.version.write({
|
||||
'contract_date_end': datetime.date(2026, 2, 15),
|
||||
'departure_reason_id': self.env['hr.departure.reason']
|
||||
.search([], limit=1).id,
|
||||
})
|
||||
dritter = self.env['l10n.at.payroll.vr'].create({
|
||||
'company_id': self.company.id,
|
||||
'stichtag': datetime.date(2026, 3, 31),
|
||||
})
|
||||
dritter.action_erzeugen()
|
||||
roh = base64.b64decode(dritter.datei).decode('ISO-8859-15')
|
||||
m4 = [z for z in roh.strip('\r\n').split('\r\n')
|
||||
if z[0:2] == 'M4']
|
||||
self.assertEqual(len(m4), 1)
|
||||
self.assertEqual(m4[0][512:520], '15022026') # ADAT
|
||||
self.assertEqual(m4[0][548:550], '00') # AGRD Default
|
||||
@@ -0,0 +1,147 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- ============================================================ -->
|
||||
<!-- GP7 Meldewesen (privat): mBGM + Versichertenmeldung. -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="mbgm_list_view" model="ir.ui.view">
|
||||
<field name="name">l10n.at.payroll.mbgm.list</field>
|
||||
<field name="model">l10n.at.payroll.mbgm</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="mBGM (JASV)">
|
||||
<field name="beitragsmonat"/>
|
||||
<field name="art"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'done'"
|
||||
decoration-info="state == 'draft'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="mbgm_form_view" model="ir.ui.view">
|
||||
<field name="name">l10n.at.payroll.mbgm.form</field>
|
||||
<field name="model">l10n.at.payroll.mbgm</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Monatliche Beitragsgrundlagenmeldung (mBGM)">
|
||||
<header>
|
||||
<button name="action_erzeugen" type="object"
|
||||
string="Lauf erzeugen (Datei)"
|
||||
class="btn-primary"
|
||||
invisible="state == 'done'"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,done"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="beitragsmonat"/>
|
||||
<field name="art"/>
|
||||
<field name="storno_id"
|
||||
invisible="art != 'ST'"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="company_id"
|
||||
groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="mBGM-Sätze" name="linien">
|
||||
<field name="line_ids">
|
||||
<list editable="bottom">
|
||||
<field name="employee_name"/>
|
||||
<field name="versicherungsnummer"/>
|
||||
<field name="bsgr"/>
|
||||
<field name="refw"/>
|
||||
<field name="vsum"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Datei & Protokoll" name="datei">
|
||||
<group>
|
||||
<field name="datei"
|
||||
filename="datei_name"/>
|
||||
<field name="datei_name" invisible="1"/>
|
||||
</group>
|
||||
<field name="protokoll"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_mbgm" model="ir.actions.act_window">
|
||||
<field name="name">mBGM (JASV)</field>
|
||||
<field name="res_model">l10n.at.payroll.mbgm</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<record id="vr_form_view" model="ir.ui.view">
|
||||
<field name="name">l10n.at.payroll.vr.form</field>
|
||||
<field name="model">l10n.at.payroll.vr</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Versichertenmeldung (An-/Abmeldung)">
|
||||
<header>
|
||||
<button name="action_erzeugen" type="object"
|
||||
string="Meldungen erzeugen (Datei)"
|
||||
class="btn-primary"
|
||||
invisible="state == 'done'"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,done"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="stichtag"/>
|
||||
<field name="nur_fallweise"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="company_id"
|
||||
groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Datei & Protokoll" name="datei">
|
||||
<group>
|
||||
<field name="datei"
|
||||
filename="datei_name"/>
|
||||
<field name="datei_name" invisible="1"/>
|
||||
</group>
|
||||
<field name="protokoll"/>
|
||||
<field name="event_ids">
|
||||
<list>
|
||||
<field name="employee_id"/>
|
||||
<field name="art"/>
|
||||
<field name="adatum"/>
|
||||
<field name="refw"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_vr" model="ir.actions.act_window">
|
||||
<field name="name">Versichertenmeldung (An-/Abmeldung)</field>
|
||||
<field name="res_model">l10n.at.payroll.vr</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_l10n_at_payroll_private_meldewesen"
|
||||
name="Meldewesen (ELDA/eSV)"
|
||||
parent="l10n_at_hr_payroll_private.menu_l10n_at_payroll_private_root"
|
||||
sequence="70"/>
|
||||
|
||||
<menuitem id="menu_l10n_at_payroll_private_mbgm"
|
||||
name="mBGM (JASV)"
|
||||
parent="menu_l10n_at_payroll_private_meldewesen"
|
||||
action="action_mbgm" sequence="10"/>
|
||||
|
||||
<menuitem id="menu_l10n_at_payroll_private_vr"
|
||||
name="Versichertenmeldung"
|
||||
parent="menu_l10n_at_payroll_private_meldewesen"
|
||||
action="action_vr" sequence="20"/>
|
||||
</odoo>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- ============================================================ -->
|
||||
<!-- GP8: UI der GP4-Vertragsstichtage + GP5/GP6-Felder am -->
|
||||
<!-- Mitarbeiterformular (Payroll-Gruppe, Muster KV-Einstufung). -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_employee_view_form_stichtage" model="ir.ui.view">
|
||||
<field name="name">hr.employee.form.l10n_at_stichtage</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="inherit_id"
|
||||
ref="l10n_at_hr_payroll_private.payroll_hr_employee_view_form_bv"/>
|
||||
<field name="arch" type="xml">
|
||||
<group name="bv_einstufung" position="after">
|
||||
<group name="stichtage" string="SV-Stichtage & Status (GP4)"
|
||||
groups="hr_payroll.group_hr_payroll_user">
|
||||
<group string="Satzausschaltungen">
|
||||
<field name="l10n_at_alv_entfall_ab"/>
|
||||
<field name="l10n_at_uv_entfall_ab"/>
|
||||
<field name="l10n_at_pv_bonusphase_ab"/>
|
||||
<field name="l10n_at_lnkp_frei_ab"/>
|
||||
</group>
|
||||
<group string="Status & Abrechnung">
|
||||
<field name="l10n_at_lehrling"/>
|
||||
<field name="l10n_at_fallweise"/>
|
||||
<field name="l10n_at_familienabsetzbetrag"/>
|
||||
<field name="l10n_at_ueberw_nachtarbeit"/>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,5 +1,62 @@
|
||||
# CHANGELOG — odoo-at-payroll
|
||||
|
||||
## 2026-09-12 — General-AT GP7: Meldewesen/Belege umgesetzt (M7; Kern-Bau AP5/AP6)
|
||||
|
||||
- `addons/l10n_at_hr_payroll` (19.0.4.0.0): **ELDA-Fixsatz-Framework**
|
||||
`models/elda.py` (DM-ORG 42.7.0: Fixlängen ohne Trennzeichen, a/n
|
||||
linksbündig-blank / n rechtsbündig-0, Cent ohne Interpunktion,
|
||||
Prozente ×1000, ISO-8859-15, Dateiheader optional, Vorlauf SART 00/
|
||||
Schluss SART 99 mit SANR-Kette ab 1 und SANZ inkl. Rahmen;
|
||||
**CRLF als Satztrenner = Praxis-Annahme** — DM-ORG normiert keines).
|
||||
**Lohnzettel Finanz L16 + Arbeitsstättenmeldung § 34 Abs 6 ASVG**
|
||||
`models/lohnzettel_l16.py`: I1 (1.100) + L1 (**3.500**,
|
||||
Lohnzettelversion 28, BEST LF/VSTR 94) inkl. Kinderblock 15×123
|
||||
ab Pos. 1501 — PDF-verifizierte Positions-Ketten; **KZ 210 =
|
||||
Bruttobezüge gem. § 25 gesamt** (KZ 245 = 210−215−220−230−243 =
|
||||
laufende Steuerbemessung, konsistent mit der GP3-§-66-Kette per
|
||||
Konstruktion; Ableitung aus l10n_at_bemessung/steuerbefreiung_68/
|
||||
s67_abs, KEIN paralleles KZ-Feld). Arbeitsstätte: SART 45 (300,
|
||||
BEST AD/VSTR ST) je AN am 31. 12. aus dem Dienstort-Partner
|
||||
(GKZ manuell, Warnung wenn fehlt).
|
||||
**Kind-Datenmodell** `models/kind.py` (Nutzer-Entscheidung: FB+
|
||||
rechnet NICHT im Lohnsteuerkern — Steuer < 0 → Arbeitnehmer-
|
||||
veranlagung; Modell dient der FABO+-Wartung: Monate voll/halb,
|
||||
Alterstaffel, L16-Kinderblock). **Stammdaten** `models/meldewesen.py`:
|
||||
BKNR + Lohnsteuernummer (Company), GKZ (res.partner), VSNR-Format-
|
||||
prüfung LLLPTTMMJJ auf hr.version.ssnid (Prüfziffer = Rest, SVS-
|
||||
Quelle), eSV-AGRD-Mapping auf hr.departure.reason. **Belege**
|
||||
(AP6): Lohnzettel AT (ir.actions.report auf hr.payslip) +
|
||||
Monatsdienstliste + Monatsjournal (QWeb auf hr.payslip.run,
|
||||
AbstractModel `models/monatsreports.py`; kein HR-Modul nutzt
|
||||
account.report).
|
||||
- `addons/l10n_at_hr_payroll_private` (19.0.7.0.0): **mBGM/JASV**
|
||||
(BEST MB, Version 02; `models/mbgm.py`): Paket PS/mBGM G1/Tarif-
|
||||
block T1/Verrechnungsbasis BS/Position V1 aus GP4-Beitragsmonats-
|
||||
Daten; T01/T02-GESAMT-Prozentsatz je BSGR aus `at_mbgm_tarif`
|
||||
(normativ laufend **39,05 %**, SZ **37,55 %** = DN KV 3,87 + PV
|
||||
10,25 + AV 2,95 + DG 20,48); **Abschläge je Verrechnungsbasis**
|
||||
(A03/A02/A01 §-2a-AMPFG DN-AV auf AB UND SZ, A09 UV-60, A10
|
||||
AV+IE-Entfall, A15 PV-Halbierung; Konkurrenz a4: A10 sperrt
|
||||
Staffelabschlag); BV-Basis (ungedeckelt, GP5) + V01; RSUM =
|
||||
Basis×Satz mit Konsistenz-Check gegen GP4-Payslip-Summen (±0,10 €);
|
||||
Storno R1 (REFU/VSUM wie Original), Fehlanzeige als Null-Paket;
|
||||
Geringfügige/fallweise ohne Parametrisierung → listenweiser
|
||||
Abbruch (keine Tarif-Erfindung). **Versichertenmeldung**
|
||||
`models/versichertenmeldung.py` (BEST VR, Version 03): M3 vor
|
||||
Arbeitsantritt / M4 (7 Tage) mit deklarativer Event-Erkennung über
|
||||
die Meldungs-Registry (idempotente Läufe, kein write-diff), BBER
|
||||
01–04, GERF (Gehalt vs. GFG), FRDV, BVAB/BVJN aus GP5, VWAZ
|
||||
Stunden×100, AGRD aus dem Abgangsgrund-Mapping; **fallweise
|
||||
Anmeldung** (BEST MA, SART 32, Version 07; MVP je Anmeldetag).
|
||||
NSchAB-Branche als Company-Flag (B001-T01 + 3,80).
|
||||
- **Bewusst nicht in GP7:** Schwerarbeitsmeldung (DM-ORG E.22-
|
||||
Struktur vorhanden, aber keine ASchV-Datenbasis), § 109a (separate
|
||||
Honorar-Datenbasis), L17 (keine AG-Pflicht), Mitteilungsfile-Import
|
||||
(Nutzer-Entscheidung 5.2 → GP8), FB+-Lohnsteuermechanik (ANV).
|
||||
- Tests: `test_elda_l16` (4) + `test_meldewesen_private` (2) —
|
||||
**94/94 grün** (frischer `-i`-Lauf, Wegwerf-DB). Rechenfälle
|
||||
`docs/rechenfaelle/meldewesen.md`.
|
||||
|
||||
## 2026-09-12 — General-AT GP6: FI-Buchung UGB nach KFS/BW 6 umgesetzt (M6)
|
||||
|
||||
- `addons/l10n_at_hr_payroll_private` (19.0.6.0.0): **GP6 FI-Buchung
|
||||
|
||||
@@ -783,6 +783,34 @@ Budget: die Kandidaten 8.13, der DZ (8.11) und die Regel-Optionen
|
||||
(NSchAB, DAG) laufen über die GP0-/Pilotmandant-Entscheidung bzw.
|
||||
Change-Requests.
|
||||
|
||||
**Update 2026-09-12 (GP7 umgesetzt):** Meldewesen/Belege komplett
|
||||
umgesetzt und grün (94/94) — Nutzer-Entscheidungen 5.1-5.4 (2026-
|
||||
09-12): Arbeitsstättenmeldung § 34 Abs 6 in GP7 (SART 45, BEST AD/
|
||||
VSTR ST — im selben Jahreslauf wie die L16); Mitteilungsfile-Import
|
||||
= GP8-Rest; **FB+ rechnet NICHT im Lohnsteuerkern** (Steuer < 0 →
|
||||
Arbeitnehmerveranlagung — das Kind-Modell dient der FABO+-Wartung
|
||||
und dem L16-Kinderblock); alles in einem GP7. Kern 19.0.4.0.0:
|
||||
ELDA-Fixsatz-Framework (`models/elda.py`), L16 (I1 1.100 + L1
|
||||
3.500, Lohnzettelversion 28, BEST LF/VSTR 94; **KZ 210 = § 25-
|
||||
Gesamtbrutto** — KZ-245-Formel damit die laufende Steuerbemessung,
|
||||
per Konstruktion GP3-konsistent; Ableitung aus l10n_at_bemessung/
|
||||
steuerbefreiung_68/s67_abs, kein paralleles KZ-Feld), Arbeitsstätte,
|
||||
Kind-Modell, Stammdaten (BKNR/Lohnsteuernummer/GKZ/VSNR-Pruefung/
|
||||
AGRD-Mapping), 3 Belege (Lohnzettel AT/Monatsdienstliste/Monats-
|
||||
journal, QWeb auf hr.payslip bzw. hr.payslip.run — account.report
|
||||
in keinem HR-Modul). Privat 19.0.7.0.0: mBGM/JASV (PS/G1/T1/BS/V1,
|
||||
T01/T02-Gesamtsatz aus `at_mbgm_tarif` — normativ 39,05 % laufend/
|
||||
37,55 % SZ; **Abschläge je Basis AB UND SZ** A03/A02/A01 §-2a-DN-AV,
|
||||
A09/A10/A15; BV-Basis + V01 aus GP5; RSUM=Basis×Satz mit GP4-
|
||||
Konsistenzcheck; Storno R1/REFU; Fehlanzeige Null-Paket), Ver-
|
||||
sichertenmeldung M3/M4 (deklarative Event-Erkennung über die
|
||||
Meldungs-Registry — kein write-diff; BBER/GERF/FRDV/BVAB/BVJN/VWAZ/
|
||||
AGRD), fallweise Anmeldung BEST MA/SART 32; NSchAB-Company-Flag.
|
||||
Bewusst nicht: Schwerarbeitsmeldung (DM-ORG E.22-Struktur vorhanden,
|
||||
keine ASchV-Datenbasis), § 109a (Honorar-Basis), L17 (keine AG-
|
||||
Pflicht), geringfügig-mBGM bis Parametrisierung (keine Tarif-
|
||||
erfindung). Rechenfälle `docs/rechenfaelle/meldewesen.md`.
|
||||
|
||||
**Update 2026-09-12 (GP6 umgesetzt):** FI-Buchung UGB nach dem
|
||||
Österreichischen Einheitskontenrahmen (KFS/BW 6 — Nutzer-Referenz-
|
||||
Exemplar `.additional_docs/KFSBW6_13072017_RF.pdf`) umgesetzt und
|
||||
|
||||
Reference in New Issue
Block a user