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

Issue:
Why does COM Fremdkostenposition not inherit from COM Kostenposition?
https://www.bo4e.de/dokumentation/komponenten/com-fremdkostenposition
![grafik](https://user-images.githubusercontent.com/23094997/147044723-d4327f2d-2c71-4e3b-bc00-40e49cdfaf95.png)

https://www.bo4e.de/dokumentation/komponenten/com-kostenposition
![grafik](https://user-images.githubusercontent.com/23094997/147044745-799359ef-9620-4cf8-9e2b-cab9a94e2e72.png)

Implement COM Kostenblock
review #286 first, then merge updates from there into here before merging (286)

Fixes #171


Discussion / hints:

Fix patch:
diff --git a/src/bo4e/com/fremdkostenposition.py b/src/bo4e/com/fremdkostenposition.py
index 3a35cd482..666bd4245 100644
--- a/src/bo4e/com/fremdkostenposition.py
+++ b/src/bo4e/com/fremdkostenposition.py
@@ -1,69 +1,22 @@
 """
 Contains Fremdkostenposition and corresponding marshmallow schema for de-/serialization
 """
-from datetime import datetime
 from typing import Optional
 
 import attr
 from marshmallow import fields
 
-from bo4e.com.betrag import Betrag, BetragSchema
-from bo4e.com.com import COM, COMSchema
-from bo4e.com.menge import Menge, MengeSchema
-from bo4e.com.preis import Preis, PreisSchema
+from bo4e.com.kostenposition import Kostenposition, KostenpositionSchema
 
 
 # pylint: disable=too-few-public-methods, too-many-instance-attributes
 @attr.s(auto_attribs=True, kw_only=True)
-class Fremdkostenposition(COM):
+class Fremdkostenposition(Kostenposition):
     """
     Eine Kostenposition im Bereich der Fremdkosten
     """
 
-    # required attributes
-    #: Ein Titel für die Zeile. Hier kann z.B. der Netzbetreiber eingetragen werden, wenn es sich um Netzkosten handelt
-    positionstitel: str = attr.ib(validator=attr.validators.instance_of(str))
-
-    betrag_kostenposition: Betrag = attr.ib(validator=attr.validators.instance_of(Betrag))
-    """Der errechnete Gesamtbetrag der Position als Ergebnis der Berechnung <Menge * Einzelpreis> oder
-    <Einzelpreis / (Anzahl Tage Jahr) * zeitmenge>"""
-    # todo: validate above calculation, see https://github.com/Hochfrequenz/BO4E-python/issues/282
-
-    #: Bezeichnung für den Artikel für den die Kosten ermittelt wurden. Beispiel: Arbeitspreis HT
-    artikelbezeichnung: str = attr.ib(validator=attr.validators.instance_of(str))
-
-    #: Der Preis für eine Einheit. Beispiele: 5,8200 ct/kWh oder 55 €/Jahr.
-    einzelpreis: Preis = attr.ib(validator=attr.validators.instance_of(Preis))
-
-    # optional attributes
-    #: inklusiver von-Zeitpunkt der Kostenzeitscheibe
-    von: Optional[datetime] = attr.ib(
-        default=None, validator=attr.validators.optional(attr.validators.instance_of(datetime))
-    )
-    #: exklusiver bis-Zeitpunkt der Kostenzeitscheibe
-    bis: Optional[datetime] = attr.ib(
-        default=None, validator=attr.validators.optional(attr.validators.instance_of(datetime))
-    )
-    # todo: implement von/bis validation as soon as https://github.com/Hochfrequenz/BO4E-python/pull/266 is merged
-
-    #: Die Menge, die in die Kostenberechnung eingeflossen ist. Beispiel: 3.660 kWh
-    menge: Optional[Menge] = attr.ib(
-        default=None, validator=attr.validators.optional(attr.validators.instance_of(Menge))
-    )
-
-    zeitmenge: Optional[Menge] = attr.ib(
-        default=None, validator=attr.validators.optional(attr.validators.instance_of(Menge))
-    )
-    """
-    Wenn es einen zeitbasierten Preis gibt (z.B. €/Jahr), dann ist hier die Menge angegeben mit der die Kosten berechnet
-    wurden. Z.B. 138 Tage.
-    """
-
-    #: Detaillierung des Artikels (optional). Beispiel: 'Drehstromzähler'
-    artikeldetail: Optional[str] = attr.ib(
-        default=None, validator=attr.validators.optional(attr.validators.instance_of(str))
-    )
-
+    # optional attributes (additional to those from Kostenposition)
     #: Der Name des Marktpartners, der die Preise festlegt, bzw. die Kosten in Rechnung stellt
     marktpartnername: Optional[str] = attr.ib(
         default=None, validator=attr.validators.optional(attr.validators.instance_of(str))
@@ -86,24 +39,13 @@ class Fremdkostenposition(COM):
     )
 
 
-class FremdkostenpositionSchema(COMSchema):
+class FremdkostenpositionSchema(KostenpositionSchema):
     """
     Schema for de-/serialization of Fremdkostenposition
     """
 
-    class_name = Fremdkostenposition
-    # required attributes
-    positionstitel = fields.Str()
-    betrag_kostenposition = fields.Nested(BetragSchema)
-    artikelbezeichnung = fields.Str()
-    einzelpreis = fields.Nested(PreisSchema)
-
-    # optional attributes
-    von = fields.DateTime(allow_none=True)
-    bis = fields.DateTime(allow_none=True)
-    menge = fields.Nested(MengeSchema, allow_none=True)
-    zeitmenge = fields.Nested(MengeSchema, allow_none=True)
-    artikeldetail = fields.Str(allow_none=True)
+    # optional attributes (additional to those from Kostenposition)
+    class_name = Fremdkostenposition  # type:ignore[assignment]
     marktpartnername = fields.Str(allow_none=True)
     marktpartnercode = fields.Str(allow_none=True)
     gebietcode_eic = fields.Str(allow_none=True)
diff --git a/src/bo4e/com/kostenposition.py b/src/bo4e/com/kostenposition.py
new file mode 100644
index 000000000..694827638
--- /dev/null
+++ b/src/bo4e/com/kostenposition.py
@@ -0,0 +1,93 @@
+"""
+Contains Kostenposition and corresponding marshmallow schema for de-/serialization
+"""
+from datetime import datetime
+from typing import Optional
+
+import attr
+from marshmallow import fields
+
+from bo4e.com.betrag import Betrag, BetragSchema
+from bo4e.com.com import COM, COMSchema
+from bo4e.com.menge import Menge, MengeSchema
+from bo4e.com.preis import Preis, PreisSchema
+from bo4e.validators import check_bis_is_later_than_von
+
+
+# pylint: disable=too-few-public-methods, too-many-instance-attributes
+@attr.s(auto_attribs=True, kw_only=True)
+class Kostenposition(COM):
+    """
+    Diese Komponente wird zur Übertagung der Details zu einer Kostenposition verwendet.
+    """
+
+    # required attributes
+    #: Ein Titel für die Zeile. Hier kann z.B. der Netzbetreiber eingetragen werden, wenn es sich um Netzkosten handelt.
+    positionstitel: str = attr.ib(validator=attr.validators.instance_of(str))
+
+    betrag_kostenposition: Betrag = attr.ib(validator=attr.validators.instance_of(Betrag))
+    """Der errechnete Gesamtbetrag der Position als Ergebnis der Berechnung <Menge * Einzelpreis> oder
+    <Einzelpreis / (Anzahl Tage Jahr) * zeitmenge>"""
+    # todo: validate above calculation, see https://github.com/Hochfrequenz/BO4E-python/issues/282
+
+    #: Bezeichnung für den Artikel für den die Kosten ermittelt wurden. Beispiel: Arbeitspreis HT
+    artikelbezeichnung: str = attr.ib(validator=attr.validators.instance_of(str))
+
+    #: Der Preis für eine Einheit. Beispiele: 5,8200 ct/kWh oder 55 €/Jahr.
+    einzelpreis: Preis = attr.ib(validator=attr.validators.instance_of(Preis))
+
+    # optional attributes
+    #: inklusiver von-Zeitpunkt der Kostenzeitscheibe
+    von: Optional[datetime] = attr.ib(
+        default=None,
+        validator=attr.validators.optional([attr.validators.instance_of(datetime), check_bis_is_later_than_von]),
+    )
+    #: exklusiver bis-Zeitpunkt der Kostenzeitscheibe
+    bis: Optional[datetime] = attr.ib(
+        default=None,
+        validator=attr.validators.optional([attr.validators.instance_of(datetime), check_bis_is_later_than_von]),
+    )
+
+    #: Die Menge, die in die Kostenberechnung eingeflossen ist. Beispiel: 3.660 kWh
+    menge: Optional[Menge] = attr.ib(
+        default=None, validator=attr.validators.optional(attr.validators.instance_of(Menge))
+    )
+
+    zeitmenge: Optional[Menge] = attr.ib(
+        default=None, validator=attr.validators.optional(attr.validators.instance_of(Menge))
+    )
+    """
+    Wenn es einen zeitbasierten Preis gibt (z.B. €/Jahr), dann ist hier die Menge angegeben mit der die Kosten berechnet
+    wurden. Z.B. 138 Tage.
+    """
+
+    #: Detaillierung des Artikels (optional). Beispiel: 'Drehstromzähler'
+    artikeldetail: Optional[str] = attr.ib(
+        default=None, validator=attr.validators.optional(attr.validators.instance_of(str))
+    )
+
+    def _get_inclusive_start(self) -> Optional[datetime]:
+        return self.von
+
+    def _get_exclusive_end(self) -> Optional[datetime]:
+        return self.bis
+
+
+class KostenpositionSchema(COMSchema):
+    """
+    Schema for de-/serialization of Kostenposition
+    """
+
+    class_name = Kostenposition
+    # required attributes
+    positionstitel = fields.Str()
+    betrag_kostenposition = fields.Nested(BetragSchema)
+    artikelbezeichnung = fields.Str()
+    einzelpreis = fields.Nested(PreisSchema)
+
+    # optional attributes
+    von = fields.DateTime(allow_none=True)
+    bis = fields.DateTime(allow_none=True)
+    menge = fields.Nested(MengeSchema, allow_none=True)
+    zeitmenge = fields.Nested(MengeSchema, allow_none=True)
+    artikeldetail = fields.Str(allow_none=True)


Test patch:
diff --git a/tests/test_fremdkostenposition.py b/tests/test_fremdkostenposition.py
index 754115ee3..d03d01395 100644
--- a/tests/test_fremdkostenposition.py
+++ b/tests/test_fremdkostenposition.py
@@ -35,19 +35,19 @@ class TestFremdkostenposition:
                     ),
                 ),
                 {
-                    "einzelpreis": {"wert": "3.5", "status": "ENDGUELTIG", "bezugswert": "KWH", "einheit": "EUR"},
-                    "bis": None,
-                    "artikeldetail": None,
-                    "positionstitel": "Mudders Preisstaffel",
-                    "artikelbezeichnung": "Dei Mudder ihr Preisstaffel",
-                    "von": None,
                     "marktpartnercode": None,
+                    "positionstitel": "Mudders Preisstaffel",
+                    "einzelpreis": {"wert": "3.5", "einheit": "EUR", "bezugswert": "KWH", "status": "ENDGUELTIG"},
+                    "bis": None,
                     "menge": None,
-                    "marktpartnername": None,
                     "zeitmenge": None,
-                    "gebietcodeEic": None,
-                    "betragKostenposition": {"wert": "12.5", "waehrung": "EUR"},
+                    "artikelbezeichnung": "Dei Mudder ihr Preisstaffel",
+                    "marktpartnername": None,
+                    "artikeldetail": None,
+                    "von": None,
                     "linkPreisblatt": None,
+                    "betragKostenposition": {"wert": "12.5", "waehrung": "EUR"},
+                    "gebietcodeEic": None,
                 },
                 id="only required attributes",
             ),
@@ -76,16 +76,16 @@ class TestFremdkostenposition:
                     gebietcode_eic="not an eic code but validation will follow in ticket 146",
                 ),
                 {
-                    "marktpartnername": "Mein MP",
                     "artikelbezeichnung": "Deim Vadder sei Preisstaffel",
-                    "menge": {"einheit": "MWH", "wert": "3.410000000000000142108547152020037174224853515625"},
                     "artikeldetail": "foo",
-                    "zeitmenge": {"einheit": "MWH", "wert": "3.410000000000000142108547152020037174224853515625"},
-                    "positionstitel": "Vadders Preisstaffel",
+                    "marktpartnername": "Mein MP",
+                    "einzelpreis": {"bezugswert": "KWH", "status": "ENDGUELTIG", "wert": "3.5", "einheit": "EUR"},
+                    "menge": {"wert": "3.410000000000000142108547152020037174224853515625", "einheit": "MWH"},
+                    "zeitmenge": {"wert": "3.410000000000000142108547152020037174224853515625", "einheit": "MWH"},
                     "marktpartnercode": "986543210123",
                     "bis": "2014-05-01T00:00:00+00:00",
+                    "positionstitel": "Vadders Preisstaffel",
                     "von": "2013-05-01T00:00:00+00:00",
-                    "einzelpreis": {"einheit": "EUR", "status": "ENDGUELTIG", "wert": "3.5", "bezugswert": "KWH"},
                     "betragKostenposition": {"wert": "12.5", "waehrung": "EUR"},
                     "gebietcodeEic": "not an eic code but validation will follow in ticket 146",
                     "linkPreisblatt": "http://foo.bar/",
diff --git a/tests/test_kostenposition.py b/tests/test_kostenposition.py
new file mode 100644
index 000000000..bddefbd61
--- /dev/null
+++ b/tests/test_kostenposition.py
@@ -0,0 +1,116 @@
+from datetime import datetime, timezone
+from decimal import Decimal
+
+import pytest  # type:ignore[import]
+
+from bo4e.com.betrag import Betrag
+from bo4e.com.kostenposition import Kostenposition, KostenpositionSchema
+from bo4e.com.preis import Preis
+from bo4e.enum.mengeneinheit import Mengeneinheit
+from bo4e.enum.preisstatus import Preisstatus
+from bo4e.enum.waehrungscode import Waehrungscode
+from bo4e.enum.waehrungseinheit import Waehrungseinheit
+from tests.serialization_helper import assert_serialization_roundtrip  # type:ignore[import]
+from tests.test_menge import example_menge  # type:ignore[import]
+from tests.test_sigmoidparameter import example_sigmoidparameter  # type:ignore[import]
+
+
+class TestKostenposition:
+    @pytest.mark.parametrize(
+        "kostenposition, expected_json_dict",
+        [
+            pytest.param(
+                Kostenposition(
+                    positionstitel="Mudders Preisstaffel",
+                    artikelbezeichnung="Dei Mudder ihr Preisstaffel",
+                    einzelpreis=Preis(
+                        wert=Decimal(3.50),
+                        einheit=Waehrungseinheit.EUR,
+                        bezugswert=Mengeneinheit.KWH,
+                        status=Preisstatus.ENDGUELTIG,
+                    ),
+                    betrag_kostenposition=Betrag(
+                        waehrung=Waehrungscode.EUR,
+                        wert=Decimal(12.5),
+                    ),
+                ),
+                {
+                    "positionstitel": "Mudders Preisstaffel",
+                    "einzelpreis": {"status": "ENDGUELTIG", "einheit": "EUR", "wert": "3.5", "bezugswert": "KWH"},
+                    "bis": None,
+                    "von": None,
+                    "menge": None,
+                    "zeitmenge": None,
+                    "artikelbezeichnung": "Dei Mudder ihr Preisstaffel",
+                    "artikeldetail": None,
+                    "betragKostenposition": {"waehrung": "EUR", "wert": "12.5"},
+                },
+                id="only required attributes",
+            ),
+            pytest.param(
+                Kostenposition(
+                    positionstitel="Vadders Kostenposition",
+                    von=datetime(2013, 5, 1, tzinfo=timezone.utc),
+                    bis=datetime(2014, 5, 1, tzinfo=timezone.utc),
+                    artikelbezeichnung="Deim Vadder sei Kostenposition",
+                    zeitmenge=example_menge,
+                    einzelpreis=Preis(
+                        wert=Decimal(3.50),
+                        einheit=Waehrungseinheit.EUR,
+                        bezugswert=Mengeneinheit.KWH,
+                        status=Preisstatus.ENDGUELTIG,
+                    ),
+                    betrag_kostenposition=Betrag(
+                        waehrung=Waehrungscode.EUR,
+                        wert=Decimal(12.5),
+                    ),
+                    menge=example_menge,
+                    artikeldetail="foo",
+                ),
+                {
+                    "artikelbezeichnung": "Deim Vadder sei Kostenposition",
+                    "positionstitel": "Vadders Kostenposition",
+                    "menge": {"wert": "3.410000000000000142108547152020037174224853515625", "einheit": "MWH"},
+                    "artikeldetail": "foo",
+                    "einzelpreis": {"bezugswert": "KWH", "status": "ENDGUELTIG", "wert": "3.5", "einheit": "EUR"},
+                    "von": "2013-05-01T00:00:00+00:00",
+                    "zeitmenge": {"wert": "3.410000000000000142108547152020037174224853515625", "einheit": "MWH"},
+                    "bis": "2014-05-01T00:00:00+00:00",
+                    "betragKostenposition": {"wert": "12.5", "waehrung": "EUR"},
+                },
+                id="required and optional attributes",
+            ),
+        ],
+    )
+    def test_serialization_roundtrip(self, kostenposition: Kostenposition, expected_json_dict: dict):
+        """
+        Test de-/serialisation of Kostenposition.
+        """
+        assert_serialization_roundtrip(kostenposition, KostenpositionSchema(), expected_json_dict)
+
+    def test_missing_required_attribute(self):
+        with pytest.raises(TypeError) as excinfo:
+            _ = Kostenposition()
+
+        assert "missing 4 required" in str(excinfo.value)
+
+    def test_von_bis_validation_attribute(self):
+        with pytest.raises(ValueError) as excinfo:
+            _ = Kostenposition(
+                positionstitel="Mudders Preisstaffel",
+                artikelbezeichnung="Dei Mudder ihr Preisstaffel",
+                einzelpreis=Preis(
+                    wert=Decimal(3.50),
+                    einheit=Waehrungseinheit.EUR,
+                    bezugswert=Mengeneinheit.KWH,
+                    status=Preisstatus.ENDGUELTIG,
+                ),
+                betrag_kostenposition=Betrag(
+                    waehrung=Waehrungscode.EUR,
+                    wert=Decimal(12.5),
+                ),
+                von=datetime(2014, 5, 1, tzinfo=timezone.utc),
+                bis=datetime(2013, 5, 1, tzinfo=timezone.utc),
+            )
+
+        assert "has to be later than the start" in str(excinfo.value)