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

Issue:
Implement COM Fremdkostenposition
https://www.bo4e.de/dokumentation/komponenten/com-fremdkostenposition
requires 
- #145 
- #144 
- The validation will have short comings due to #126 (how to compare Betrag and Preis if one of them uses Waehrungscode and the other uses Waehrungseinheit)
- von and bis should be inclusive / exklusive
- for EIC see #147 


Discussion / hints:

Fix patch:
diff --git a/docs/api/bo4e.com.rst b/docs/api/bo4e.com.rst
index 33b329894..ff24ac4b4 100644
--- a/docs/api/bo4e.com.rst
+++ b/docs/api/bo4e.com.rst
@@ -100,6 +100,14 @@ bo4e.com.externereferenz module
    :undoc-members:
    :show-inheritance:
 
+bo4e.com.fremdkostenposition module
+-----------------------------------
+
+.. automodule:: bo4e.com.fremdkostenposition
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
 bo4e.com.geokoordinaten module
 ------------------------------
 
@@ -260,6 +268,14 @@ bo4e.com.steuerbetrag module
    :undoc-members:
    :show-inheritance:
 
+bo4e.com.tagesvektor module
+---------------------------
+
+.. automodule:: bo4e.com.tagesvektor
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
 bo4e.com.tarifeinschraenkung module
 -----------------------------------
 
@@ -284,6 +300,14 @@ bo4e.com.unterschrift module
    :undoc-members:
    :show-inheritance:
 
+bo4e.com.verbrauch module
+-------------------------
+
+.. automodule:: bo4e.com.verbrauch
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
 bo4e.com.vertragskonditionen module
 -----------------------------------
 
diff --git a/src/bo4e/com/fremdkostenposition.py b/src/bo4e/com/fremdkostenposition.py
new file mode 100644
index 000000000..e4a0101a1
--- /dev/null
+++ b/src/bo4e/com/fremdkostenposition.py
@@ -0,0 +1,115 @@
+"""
+Contains Fremdkostenposition and corresponding marshmallow schema for de-/serialization
+"""
+from datetime import datetime
+from typing import Optional
+
+import attr
+from marshmallow import fields, post_load
+
+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
+
+
+# pylint: disable=too-few-public-methods, too-many-instance-attributes
+@attr.s(auto_attribs=True, kw_only=True)
+class Fremdkostenposition(COM):
+    """
+    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))
+    )
+
+    #: 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))
+    )
+
+    #: Die Codenummer (z.B. BDEW-Codenummer) des Marktpartners, der die Preise festlegt / die Kosten in Rechnung stellt
+    marktpartnercode: Optional[str] = attr.ib(
+        default=None, validator=attr.validators.optional(attr.validators.instance_of(str))
+    )
+
+    #: EIC-Code des Regel- oder Marktgebietes eingetragen. Z.B. '10YDE-EON------1' für die Regelzone TenneT
+    gebietcode_eic: Optional[str] = attr.ib(
+        default=None, validator=attr.validators.optional(attr.validators.instance_of(str))
+    )
+    # todo: see issue https://github.com/Hochfrequenz/BO4E-python/issues/147 for EIC validation
+
+    #: Link zum veröffentlichten Preisblatt
+    link_preisblatt: Optional[str] = attr.ib(
+        default=None, validator=attr.validators.optional(attr.validators.instance_of(str))
+    )
+
+
+class FremdkostenpositionSchema(COMSchema):
+    """
+    Schema for de-/serialization of 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)
+    marktpartnername = fields.Str(allow_none=True)
+    marktpartnercode = fields.Str(allow_none=True)
+    gebietcode_eic = fields.Str(allow_none=True)
+    link_preisblatt = fields.Str(allow_none=True)
+
+    # pylint: disable=no-self-use, unused-argument
+    @post_load
+    def deserialize(self, data, **kwargs) -> Fremdkostenposition:
+        """Deserialize JSON to Fremdkostenposition object"""
+        return Fremdkostenposition(**data)
diff --git a/src/bo4e/com/tagesvektor.py b/src/bo4e/com/tagesvektor.py
index 3270bfc61..4e7e8b8cd 100644
--- a/src/bo4e/com/tagesvektor.py
+++ b/src/bo4e/com/tagesvektor.py
@@ -10,7 +10,6 @@
 from bo4e.com.com import COM, COMSchema
 from bo4e.com.zeitreihenwertkompakt import Zeitreihenwertkompakt, ZeitreihenwertkompaktSchema
 
-
 # pylint: disable=too-few-public-methods
 from bo4e.validators import check_list_length_at_least_one
 
diff --git a/src/bo4e/com/verbrauch.py b/src/bo4e/com/verbrauch.py
index 91cb85206..c12ea2f92 100644
--- a/src/bo4e/com/verbrauch.py
+++ b/src/bo4e/com/verbrauch.py
@@ -12,7 +12,7 @@
 from bo4e.com.com import COM, COMSchema
 from bo4e.enum.mengeneinheit import Mengeneinheit
 from bo4e.enum.wertermittlungsverfahren import Wertermittlungsverfahren
-from bo4e.validators import obis_validator, check_bis_is_later_than_von
+from bo4e.validators import check_bis_is_later_than_von, obis_validator
 
 
 # pylint: disable=too-few-public-methods
diff --git a/src/bo4e/validators.py b/src/bo4e/validators.py
index 844f93552..c26e7384c 100644
--- a/src/bo4e/validators.py
+++ b/src/bo4e/validators.py
@@ -2,7 +2,7 @@
 Contains validators for BO s and COM s classes.
 """
 from datetime import datetime
-from typing import Protocol, Optional
+from typing import Optional, Protocol
 
 import attr.validators
 


Test patch:
diff --git a/tests/test_fremdkostenposition.py b/tests/test_fremdkostenposition.py
new file mode 100644
index 000000000..fabf099eb
--- /dev/null
+++ b/tests/test_fremdkostenposition.py
@@ -0,0 +1,108 @@
+from datetime import datetime, timezone
+from decimal import Decimal
+
+import pytest  # type:ignore[import]
+
+from bo4e.com.betrag import Betrag
+from bo4e.com.fremdkostenposition import Fremdkostenposition, FremdkostenpositionSchema
+from bo4e.com.preis import Preis
+from bo4e.com.preisstaffel import Preisstaffel
+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 TestFremdkostenposition:
+    @pytest.mark.parametrize(
+        "fremdkostenposition, expected_json_dict",
+        [
+            pytest.param(
+                Fremdkostenposition(
+                    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),
+                    ),
+                ),
+                {
+                    "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,
+                    "menge": None,
+                    "marktpartnername": None,
+                    "zeitmenge": None,
+                    "gebietcodeEic": None,
+                    "betragKostenposition": {"wert": "12.5", "waehrung": "EUR"},
+                    "linkPreisblatt": None,
+                },
+                id="only required attributes",
+            ),
+            pytest.param(
+                Fremdkostenposition(
+                    positionstitel="Vadders Preisstaffel",
+                    von=datetime(2013, 5, 1, tzinfo=timezone.utc),
+                    bis=datetime(2014, 5, 1, tzinfo=timezone.utc),
+                    artikelbezeichnung="Deim Vadder sei Preisstaffel",
+                    link_preisblatt="http://foo.bar/",
+                    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",
+                    marktpartnercode="986543210123",
+                    marktpartnername="Mein MP",
+                    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",
+                    "marktpartnercode": "986543210123",
+                    "bis": "2014-05-01T00:00:00+00:00",
+                    "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/",
+                },
+                id="required and optional attributes",
+            ),
+        ],
+    )
+    def test_serialization_roundtrip(self, fremdkostenposition: Fremdkostenposition, expected_json_dict: dict):
+        """
+        Test de-/serialisation of Fremdkostenposition.
+        """
+        assert_serialization_roundtrip(fremdkostenposition, FremdkostenpositionSchema(), expected_json_dict)
+
+    def test_missing_required_attribute(self):
+        with pytest.raises(TypeError) as excinfo:
+            _ = Fremdkostenposition()
+
+        assert "missing 4 required" in str(excinfo.value)