Repository: bo4e/BO4E-python (language: python)

Issue:
Implement BO Tarif
https://www.bo4e.de/dokumentation/geschaeftsobjekte/bo-tarifinfo-1
- #207 
- #163 
- #162 
- #176 
- #169 
- #186 
✨Implement BO Regionaltarif
Fixes: https://github.com/Hochfrequenz/BO4E-python/issues/208


Discussion / hints:

Fix patch:
diff --git a/src/bo4e/bo/tarif.py b/src/bo4e/bo/tarif.py
new file mode 100644
index 000000000..1bb523d4b
--- /dev/null
+++ b/src/bo4e/bo/tarif.py
@@ -0,0 +1,89 @@
+"""
+Contains Tarif class and corresponding marshmallow schema for de-/serialization
+"""
+
+from datetime import datetime
+from typing import List, Optional
+
+import attr
+from marshmallow import fields
+
+from bo4e.bo.tarifinfo import Tarifinfo, TarifinfoSchema
+from bo4e.com.aufabschlagregional import AufAbschlagRegional, AufAbschlagRegionalSchema
+from bo4e.com.preisgarantie import Preisgarantie, PreisgarantieSchema
+from bo4e.com.tarifberechnungsparameter import Tarifberechnungsparameter, TarifberechnungsparameterSchema
+from bo4e.com.tarifeinschraenkung import Tarifeinschraenkung, TarifeinschraenkungSchema
+from bo4e.com.tarifpreispositionproort import TarifpreispositionProOrt, TarifpreispositionProOrtSchema
+from bo4e.enum.botyp import BoTyp
+from bo4e.validators import check_list_length_at_least_one
+
+
+# pylint: disable=too-few-public-methods
+@attr.s(auto_attribs=True, kw_only=True)
+class Tarif(Tarifinfo):
+    """
+    Abbildung eines Tarifs mit regionaler Zuordnung von Preisen und Auf- und Abschlägen
+    """
+
+    bo_typ: BoTyp = attr.ib(default=BoTyp.TARIF)
+    # required attributes
+    #: Gibt an, wann der Preis zuletzt angepasst wurde
+    preisstand: datetime = attr.ib(validator=attr.validators.instance_of(datetime))
+    #: Für die Berechnung der Kosten sind die hier abgebildeten Parameter heranzuziehen
+    berechnungsparameter: Tarifberechnungsparameter = attr.ib(
+        validator=attr.validators.instance_of(Tarifberechnungsparameter)
+    )
+    #: Die festgelegten Preise mit regionaler Eingrenzung z.B. für Arbeitspreis, Grundpreis etc.
+    tarifpreise: List[TarifpreispositionProOrt] = attr.ib(
+        validator=attr.validators.deep_iterable(
+            member_validator=attr.validators.instance_of(TarifpreispositionProOrt),
+            iterable_validator=check_list_length_at_least_one,
+        )
+    )
+
+    # optional attributes
+    #: Auf- und Abschläge auf die Preise oder Kosten mit regionaler Eingrenzung
+    tarif_auf_abschlaege: Optional[List[AufAbschlagRegional]] = attr.ib(
+        default=None,
+        validator=attr.validators.optional(
+            attr.validators.deep_iterable(
+                member_validator=attr.validators.instance_of(AufAbschlagRegional),
+                iterable_validator=attr.validators.instance_of(list),
+            )
+        ),
+    )
+    # todo: fix inconsistency: RegionalerAufAbschlag vs. AufAbschlagRegional
+    # https://github.com/Hochfrequenz/BO4E-python/issues/345
+
+    #: Preisgarantie für diesen Tarif
+    preisgarantie: Optional[Preisgarantie] = attr.ib(
+        default=None,
+        validator=attr.validators.optional(
+            attr.validators.instance_of(Preisgarantie),
+        ),
+    )
+    # todo: fix inconsistency with regionaltarif https://github.com/Hochfrequenz/BO4E-python/issues/346
+    #: Die Bedingungen und Einschränkungen unter denen ein Tarif angewendet werden kann
+    tarifeinschraenkung: Optional[Tarifeinschraenkung] = attr.ib(
+        default=None, validator=attr.validators.optional(attr.validators.instance_of(Tarifeinschraenkung))
+    )
+
+
+class TarifSchema(TarifinfoSchema):
+    """
+    Schema for de-/serialization of Tarif
+    """
+
+    class_name = Tarif  # type:ignore[assignment]
+
+    # required attributes
+    preisstand = fields.DateTime()
+    berechnungsparameter = fields.Nested(TarifberechnungsparameterSchema)
+    tarifpreise = fields.List(fields.Nested(TarifpreispositionProOrtSchema))
+
+    # optional attributes
+    tarif_auf_abschlaege = fields.List(
+        fields.Nested(AufAbschlagRegionalSchema), allow_none=True, data_key="tarifAufAbschlaege"
+    )
+    preisgarantie = fields.Nested(PreisgarantieSchema, allow_none=True)
+    tarifeinschraenkung = fields.Nested(TarifeinschraenkungSchema, allow_none=True)
diff --git a/src/bo4e/com/aufabschlagstaffelproort.py b/src/bo4e/com/aufabschlagstaffelproort.py
index e68ff15d6..2cb35ac00 100644
--- a/src/bo4e/com/aufabschlagstaffelproort.py
+++ b/src/bo4e/com/aufabschlagstaffelproort.py
@@ -15,7 +15,7 @@
 @attr.s(auto_attribs=True, kw_only=True)
 class AufAbschlagstaffelProOrt(COM):
     """
-    Gibt den Wert eines Auf- oder Abschlags und dessen Staffelgrenzen an.
+    Gibt den Wert eines Auf- oder Abschlags und dessen Staffelgrenzen an
     """
 
     # required attributes
@@ -29,7 +29,7 @@ class AufAbschlagstaffelProOrt(COM):
 
 class AufAbschlagstaffelProOrtSchema(COMSchema):
     """
-    Schema for de-/serialization of AufAbschlagstaffelProOrt.
+    Schema for de-/serialization of AufAbschlagstaffelProOrt
     """
 
     class_name = AufAbschlagstaffelProOrt
diff --git a/src/bo4e/com/tarifpreisposition.py b/src/bo4e/com/tarifpreisposition.py
index 232baf8eb..fd21aea3b 100644
--- a/src/bo4e/com/tarifpreisposition.py
+++ b/src/bo4e/com/tarifpreisposition.py
@@ -25,13 +25,13 @@ class Tarifpreisposition(COM):
     """
 
     # required attributes
-    # Angabe des Preistypes (z.B. Grundpreis)
+    #: Angabe des Preistypes (z.B. Grundpreis)
     preistyp: Preistyp = attr.ib(validator=attr.validators.instance_of(Preistyp))
-    # Einheit des Preises (z.B. EURO)
+    #: Einheit des Preises (z.B. EURO)
     einheit: Waehrungseinheit = attr.ib(validator=attr.validators.instance_of(Waehrungseinheit))
-    # Größe, auf die sich die Einheit bezieht, beispielsweise kWh, Jahr
+    #: Größe, auf die sich die Einheit bezieht, beispielsweise kWh, Jahr
     bezugseinheit: Mengeneinheit = attr.ib(validator=attr.validators.instance_of(Mengeneinheit))
-    # Hier sind die Staffeln mit ihren Preisenangaben definiert
+    #: Hier sind die Staffeln mit ihren Preisenangaben definiert
     preisstaffeln: List[Preisstaffel] = attr.ib(
         validator=[
             attr.validators.deep_iterable(
@@ -43,7 +43,7 @@ class Tarifpreisposition(COM):
     )
 
     # optional attributes
-    # Gibt an, nach welcher Menge die vorgenannte Einschränkung erfolgt (z.B. Jahresstromverbrauch in kWh)
+    #: Gibt an, nach welcher Menge die vorgenannte Einschränkung erfolgt (z.B. Jahresstromverbrauch in kWh)
     mengeneinheitstaffel: Optional[Mengeneinheit] = attr.ib(
         default=None, validator=attr.validators.optional(attr.validators.instance_of(Mengeneinheit))
     )
diff --git a/src/bo4e/com/tarifpreispositionproort.py b/src/bo4e/com/tarifpreispositionproort.py
new file mode 100644
index 000000000..d253e13b9
--- /dev/null
+++ b/src/bo4e/com/tarifpreispositionproort.py
@@ -0,0 +1,52 @@
+"""
+Contains TarifpreispositionProOrt class
+and corresponding marshmallow schema for de-/serialization
+"""
+
+from typing import List
+
+import attr
+from marshmallow import fields
+
+from bo4e.com.com import COM, COMSchema
+from bo4e.com.tarifpreisstaffelproort import TarifpreisstaffelProOrt, TarifpreisstaffelProOrtSchema
+from bo4e.validators import check_list_length_at_least_one
+
+
+# pylint: disable=too-few-public-methods
+@attr.s(auto_attribs=True, kw_only=True)
+class TarifpreispositionProOrt(COM):
+    """
+    Mit dieser Komponente können Tarifpreise verschiedener Typen abgebildet werden
+    """
+
+    # required attributes
+    #: Postleitzahl des Ortes für den der Preis gilt
+    postleitzahl: str = attr.ib(validator=attr.validators.matches_re(r"^\d{5}$"))
+    #: Ort für den der Preis gilt
+    ort: str = attr.ib(validator=attr.validators.instance_of(str))
+    #: ene't-Netznummer des Netzes in dem der Preis gilt
+    netznr: str = attr.ib(validator=attr.validators.instance_of(str))
+    # Hier sind die Staffeln mit ihren Preisenangaben definiert
+    preisstaffeln: List[TarifpreisstaffelProOrt] = attr.ib(
+        validator=[
+            attr.validators.deep_iterable(
+                member_validator=attr.validators.instance_of(TarifpreisstaffelProOrt),
+                iterable_validator=check_list_length_at_least_one,
+            ),
+        ]
+    )
+    # there are no optional attributes
+
+
+class TarifpreispositionProOrtSchema(COMSchema):
+    """
+    Schema for de-/serialization of TarifpreispositionProOrt.
+    """
+
+    class_name = TarifpreispositionProOrt
+    # required attributes
+    postleitzahl = fields.Str()
+    ort = fields.Str()
+    netznr = fields.Str()
+    preisstaffeln = fields.List(fields.Nested(TarifpreisstaffelProOrtSchema))
diff --git a/src/bo4e/com/tarifpreisstaffelproort.py b/src/bo4e/com/tarifpreisstaffelproort.py
new file mode 100644
index 000000000..2114b0159
--- /dev/null
+++ b/src/bo4e/com/tarifpreisstaffelproort.py
@@ -0,0 +1,50 @@
+"""
+Contains TarifpreisstaffelProOrt class
+and corresponding marshmallow schema for de-/serialization
+"""
+from decimal import Decimal
+
+import attr
+from marshmallow import fields
+
+from bo4e.com.com import COM, COMSchema
+
+
+# pylint: disable=too-few-public-methods
+@attr.s(auto_attribs=True, kw_only=True)
+class TarifpreisstaffelProOrt(COM):
+    """
+    Gibt die Staffelgrenzen der jeweiligen Preise an
+    """
+
+    # todo: decimal doesn't make sense here imo
+    # https://github.com/Hochfrequenz/BO4E-python/issues/344
+
+    # required attributes
+    #: Der Arbeitspreis in ct/kWh
+    arbeitspreis: Decimal = attr.ib(validator=attr.validators.instance_of(Decimal))
+    #: Der Arbeitspreis für Verbräuche in der Niedertarifzeit in ct/kWh
+    arbeitspreis_n_t: Decimal = attr.ib(validator=attr.validators.instance_of(Decimal))
+    #: Der Grundpreis in Euro/Jahr
+    grundpreis: Decimal = attr.ib(validator=attr.validators.instance_of(Decimal))
+    #: Unterer Wert, ab dem die Staffel gilt (inklusive)
+    staffelgrenze_von: Decimal = attr.ib(validator=attr.validators.instance_of(Decimal))
+    #: Oberer Wert, bis zu dem die Staffel gilt (exklusive)
+    staffelgrenze_bis: Decimal = attr.ib(validator=attr.validators.instance_of(Decimal))
+
+    # there are no optional attributes
+
+
+class TarifpreisstaffelProOrtSchema(COMSchema):
+    """
+    Schema for (de)serialization of TarifpreisstaffelProOrt
+    """
+
+    class_name = TarifpreisstaffelProOrt
+
+    # required attributes
+    arbeitspreis = fields.Decimal(as_string=True)
+    arbeitspreis_n_t = fields.Decimal(as_string=True, data_key="arbeitspreisNT")
+    grundpreis = fields.Decimal(as_string=True)
+    staffelgrenze_von = fields.Decimal(as_string=True, data_key="staffelgrenzeVon")
+    staffelgrenze_bis = fields.Decimal(as_string=True, data_key="staffelgrenzeBis")
diff --git a/src/bo4e/enum/botyp.py b/src/bo4e/enum/botyp.py
index 84def944e..4b83d4de8 100644
--- a/src/bo4e/enum/botyp.py
+++ b/src/bo4e/enum/botyp.py
@@ -36,6 +36,7 @@ class BoTyp(StrEnum):
     REGION = "REGION"
     REGIONALTARIF = "REGIONALTARIF"
     STANDORTEIGENSCHAFTEN = "STANDORTEIGENSCHAFTEN"
+    TARIF = "TARIF"
     TARIFINFO = "TARIFINFO"
     TARIFKOSTEN = "TARIFKOSTEN"
     TARIFPREISBLATT = "TARIFPREISBLATT"


Test patch:
diff --git a/tests/test_aufabschlagregional.py b/tests/test_aufabschlagregional.py
index 58974f3e1..51482ecdf 100644
--- a/tests/test_aufabschlagregional.py
+++ b/tests/test_aufabschlagregional.py
@@ -20,29 +20,31 @@
 from bo4e.enum.waehrungseinheit import Waehrungseinheit
 from tests.serialization_helper import assert_serialization_roundtrip  # type:ignore[import]
 
+example_aufabschlagregional = AufAbschlagRegional(
+    bezeichnung="foo",
+    betraege=[
+        AufAbschlagProOrt(
+            postleitzahl="01187",
+            ort="Dresden",
+            netznr="2",
+            staffeln=[
+                AufAbschlagstaffelProOrt(
+                    wert=Decimal(2.5),
+                    staffelgrenze_von=Decimal(1),
+                    staffelgrenze_bis=Decimal(5),
+                )
+            ],
+        ),
+    ],
+)
+
 
 class TestAufAbschlagRegional:
     @pytest.mark.parametrize(
         "aufabschlagregional, expected_json_dict",
         [
             pytest.param(
-                AufAbschlagRegional(
-                    bezeichnung="foo",
-                    betraege=[
-                        AufAbschlagProOrt(
-                            postleitzahl="01187",
-                            ort="Dresden",
-                            netznr="2",
-                            staffeln=[
-                                AufAbschlagstaffelProOrt(
-                                    wert=Decimal(2.5),
-                                    staffelgrenze_von=Decimal(1),
-                                    staffelgrenze_bis=Decimal(5),
-                                )
-                            ],
-                        ),
-                    ],
-                ),
+                example_aufabschlagregional,
                 {
                     "bezeichnung": "foo",
                     "betraege": [
diff --git a/tests/test_tarif.py b/tests/test_tarif.py
new file mode 100644
index 000000000..d6368cf7b
--- /dev/null
+++ b/tests/test_tarif.py
@@ -0,0 +1,81 @@
+from datetime import datetime, timezone
+
+import pytest  # type:ignore[import]
+
+from bo4e.bo.tarif import Tarif, TarifSchema
+from bo4e.enum.kundentyp import Kundentyp
+from bo4e.enum.sparte import Sparte
+from bo4e.enum.tarifart import Tarifart
+from bo4e.enum.tarifmerkmal import Tarifmerkmal
+from bo4e.enum.tariftyp import Tariftyp
+from tests.serialization_helper import assert_serialization_roundtrip  # type:ignore[import]
+from tests.test_aufabschlagregional import example_aufabschlagregional  # type:ignore[import]
+from tests.test_energiemix import example_energiemix  # type:ignore[import]
+from tests.test_marktteilnehmer import example_marktteilnehmer  # type:ignore[import]
+from tests.test_preisgarantie import example_preisgarantie  # type:ignore[import]
+from tests.test_regionaletarifpreisposition import example_regionale_tarifpreisposition  # type:ignore[import]
+from tests.test_tarifberechnungsparameter import example_tarifberechnungsparameter  # type:ignore[import]
+from tests.test_tarifeinschraenkung import example_tarifeinschraenkung  # type:ignore[import]
+from tests.test_tarifpreispositionproort import example_tarifpreispositionproort  # type:ignore[import]
+from tests.test_vertragskonditionen import example_vertragskonditionen  # type:ignore[import]
+from tests.test_zeitraum import example_zeitraum  # type:ignore[import]
+
+
+class TestTarif:
+    @pytest.mark.parametrize(
+        "tarif",
+        [
+            pytest.param(
+                Tarif(
+                    preisstand=datetime(2022, 2, 1, 0, 0, 0, tzinfo=timezone.utc),
+                    berechnungsparameter=example_tarifberechnungsparameter,
+                    tarif_auf_abschlaege=[example_aufabschlagregional],
+                    tarifpreise=[example_tarifpreispositionproort],
+                    preisgarantie=example_preisgarantie,
+                    tarifeinschraenkung=example_tarifeinschraenkung,
+                    # below are the attributes of tarifinfo
+                    bezeichnung="foo",
+                    anbietername="der beste stromanbieter",
+                    sparte=Sparte.STROM,
+                    kundentypen=[Kundentyp.PRIVAT, Kundentyp.GEWERBE],
+                    tarifart=Tarifart.MEHRTARIF,
+                    tariftyp=Tariftyp.GRUND_ERSATZVERSORGUNG,
+                    tarifmerkmale=[Tarifmerkmal.HEIZSTROM],
+                    website="https://foo.inv",
+                    bemerkung="super billig aber auch super dreckig",
+                    vertragskonditionen=example_vertragskonditionen,
+                    zeitliche_gueltigkeit=example_zeitraum,
+                    energiemix=example_energiemix,
+                    anbieter=example_marktteilnehmer,
+                ),
+                id="required and optional attributes",
+            ),
+            pytest.param(
+                Tarif(
+                    preisstand=datetime(2022, 2, 1, 0, 0, 0, tzinfo=timezone.utc),
+                    berechnungsparameter=example_tarifberechnungsparameter,
+                    tarifpreise=[example_tarifpreispositionproort],
+                    # below are the attributes of tarifinfo
+                    bezeichnung="foo",
+                    anbietername="der beste stromanbieter",
+                    sparte=Sparte.STROM,
+                    kundentypen=[Kundentyp.PRIVAT, Kundentyp.GEWERBE],
+                    tarifart=Tarifart.MEHRTARIF,
+                    tariftyp=Tariftyp.GRUND_ERSATZVERSORGUNG,
+                    tarifmerkmale=[Tarifmerkmal.HEIZSTROM],
+                    anbieter=example_marktteilnehmer,
+                ),
+                id="only required attributes",
+            ),
+        ],
+    )
+    def test_serialization_roundtrip(self, tarif: Tarif):
+        """
+        Test de-/serialisation
+        """
+        assert_serialization_roundtrip(tarif, TarifSchema())
+
+    def test_missing_required_attribute(self):
+        with pytest.raises(TypeError) as excinfo:
+            _ = Tarif()
+        assert "missing 11 required" in str(excinfo.value)  # 3 from Tarif + 8 from tarifinfo
diff --git a/tests/test_tarifpreispositionproort.py b/tests/test_tarifpreispositionproort.py
new file mode 100644
index 000000000..0ba93a18c
--- /dev/null
+++ b/tests/test_tarifpreispositionproort.py
@@ -0,0 +1,35 @@
+import pytest  # type:ignore[import]
+
+from bo4e.com.tarifpreispositionproort import TarifpreispositionProOrt, TarifpreispositionProOrtSchema
+from tests.serialization_helper import assert_serialization_roundtrip  # type:ignore[import]
+from tests.test_tarifpreisstaffelproort import example_tarifpreisstaffelproort  # type:ignore[import]
+
+example_tarifpreispositionproort = TarifpreispositionProOrt(
+    postleitzahl="82031",
+    ort="Grünwald",
+    netznr="0815",
+    preisstaffeln=[example_tarifpreisstaffelproort],
+)
+
+
+class TestTarifpreispositionProOrt:
+    @pytest.mark.parametrize(
+        "tarifpreispositionproort",
+        [
+            pytest.param(
+                example_tarifpreispositionproort,
+                id="minimal and maximal attributes",
+            ),
+        ],
+    )
+    def test_serialization_roundtrip(self, tarifpreispositionproort: TarifpreispositionProOrt):
+        """
+        Test de-/serialisation
+        """
+        assert_serialization_roundtrip(tarifpreispositionproort, TarifpreispositionProOrtSchema())
+
+    def test_missing_required_attribute(self):
+        with pytest.raises(TypeError) as excinfo:
+            _ = TarifpreispositionProOrt()
+
+        assert "missing 4 required" in str(excinfo.value)
diff --git a/tests/test_tarifpreisstaffelproort.py b/tests/test_tarifpreisstaffelproort.py
new file mode 100644
index 000000000..f5fc787aa
--- /dev/null
+++ b/tests/test_tarifpreisstaffelproort.py
@@ -0,0 +1,37 @@
+from decimal import Decimal
+
+import pytest  # type:ignore[import]
+
+from bo4e.com.tarifpreisstaffelproort import TarifpreisstaffelProOrt, TarifpreisstaffelProOrtSchema
+from tests.serialization_helper import assert_serialization_roundtrip  # type:ignore[import]
+
+example_tarifpreisstaffelproort = TarifpreisstaffelProOrt(
+    arbeitspreis=Decimal(10),
+    arbeitspreis_n_t=Decimal(11),
+    grundpreis=Decimal(12),
+    staffelgrenze_von=Decimal(13),
+    staffelgrenze_bis=Decimal(14),
+)
+
+
+class TestTarifpreisstaffelProOrt:
+    @pytest.mark.parametrize(
+        "tarifpreisstaffelproort",
+        [
+            pytest.param(
+                example_tarifpreisstaffelproort,
+                id="maximal (and minimal) attributes",
+            ),
+        ],
+    )
+    def test_serialization_roundtrip(self, tarifpreisstaffelproort: TarifpreisstaffelProOrt):
+        """
+        Test de-/serialisation
+        """
+        assert_serialization_roundtrip(tarifpreisstaffelproort, TarifpreisstaffelProOrtSchema())
+
+    def test_missing_required_attribute(self):
+        with pytest.raises(TypeError) as excinfo:
+            _ = TarifpreisstaffelProOrt()
+
+        assert "missing 5 required" in str(excinfo.value)