#![allow(clippy::doc_markdown)]
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use time::Date;
use uuid::Uuid;
use std::future::Future;
use crate::{
domain::{Lokationstyp, MaloId, MarktpartnerId, MeloId, ProcessStatus, Sparte},
error::MdmError,
};
fn unix_epoch() -> time::OffsetDateTime {
time::OffsetDateTime::UNIX_EPOCH
}
mod date_iso {
use serde::{Deserialize, Deserializer, Serializer};
use time::Date;
use time::macros::format_description;
#[expect(clippy::trivially_copy_pass_by_ref)]
pub fn serialize<S: Serializer>(date: &Date, s: S) -> Result<S::Ok, S::Error> {
let fmt = format_description!("[year]-[month]-[day]");
s.serialize_str(&date.format(fmt).map_err(serde::ser::Error::custom)?)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Date, D::Error> {
let raw = String::deserialize(d)?;
let fmt = format_description!("[year]-[month]-[day]");
Date::parse(&raw, fmt).map_err(serde::de::Error::custom)
}
pub mod opt {
use serde::{Deserialize, Deserializer, Serializer};
use time::Date;
use time::macros::format_description;
#[expect(clippy::trivially_copy_pass_by_ref, clippy::ref_option)]
pub fn serialize<S: Serializer>(date: &Option<Date>, s: S) -> Result<S::Ok, S::Error> {
match date {
Some(d) => {
let fmt = format_description!("[year]-[month]-[day]");
s.serialize_some(&d.format(fmt).map_err(serde::ser::Error::custom)?)
}
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Date>, D::Error> {
let raw: Option<String> = Option::deserialize(d)?;
match raw {
Some(s) => {
let fmt = format_description!("[year]-[month]-[day]");
Date::parse(&s, fmt)
.map(Some)
.map_err(serde::de::Error::custom)
}
None => Ok(None),
}
}
}
}
pub type MaloPayload = serde_json::Value;
pub type MeloPayload = serde_json::Value;
fn default_bo4e_version() -> String {
crate::bo4e::schema_version()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rollenzuordnung {
pub zuordnungstyp: String,
pub rollencodenummer: String,
#[serde(with = "date_iso")]
pub valid_from: Date,
#[serde(default, with = "date_iso::opt")]
pub valid_to: Option<Date>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaloRecord {
pub malo_id: MaloId,
pub sparte: Sparte,
pub netzebene: Option<String>,
pub bilanzierungsgebiet: Option<String>,
pub gasqualitaet: Option<String>,
pub energierichtung: Option<String>,
pub bilanzierungsmethode: Option<String>,
pub regelzone: Option<String>,
pub fallgruppe: Option<String>,
#[serde(default)]
pub lokationsbuendel_objektcode: Option<String>,
#[serde(default)]
pub fernsteuerbar: Option<bool>,
#[serde(default)]
pub abwicklungsmodell: Option<String>,
pub version: i64,
pub data: MaloPayload,
pub rollenzuordnung: Vec<Rollenzuordnung>,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
#[serde(default = "default_bo4e_version")]
pub bo4e_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeloRecord {
pub melo_id: MeloId,
pub malo_id: Option<MaloId>,
pub netzebene_messung: Option<String>,
pub regelzone: Option<String>,
pub standorteigenschaften: Option<serde_json::Value>,
#[serde(default)]
pub lokationsbuendel_objektcode: Option<String>,
pub version: i64,
pub data: MeloPayload,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
#[serde(default = "default_bo4e_version")]
pub bo4e_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Subscription {
pub subscriber_id: String,
pub webhook_url: String,
pub webhook_secret: Option<String>,
pub roles: Vec<String>,
pub event_types: Vec<String>,
pub sparten: Vec<String>,
pub active: bool,
pub version: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartnerRecord {
pub mp_id: MarktpartnerId,
pub display_name: Option<String>,
pub marktrolle: Option<rubo4e::current::Marktrolle>,
pub sparte: Option<Sparte>,
pub rollencodetyp: Option<rubo4e::current::Rollencodetyp>,
pub makoadresse: Vec<String>,
#[serde(default)]
pub geschaeftspartner: serde_json::Value,
#[serde(default)]
pub version: i64,
#[serde(default = "unix_epoch", with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
impl PartnerRecord {
#[must_use]
pub fn to_marktteilnehmer(&self) -> rubo4e::current::Marktteilnehmer {
let geschaeftspartner = self.display_name.as_ref().map(|name| {
Box::new(rubo4e::current::Geschaeftspartner {
organisationsname: Some(name.clone()),
..Default::default()
})
});
rubo4e::current::Marktteilnehmer {
rollencodenummer: Some(self.mp_id.clone()),
marktrolle: self.marktrolle,
rollencodetyp: self.rollencodetyp,
sparte: self.sparte.map(|s| match s {
Sparte::Strom => rubo4e::current::Sparte::Strom,
Sparte::Gas => rubo4e::current::Sparte::Gas,
}),
makoadresse: (!self.makoadresse.is_empty()).then(|| self.makoadresse.clone()),
geschaeftspartner,
..Default::default()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CorrelationEntry {
pub process_id: Uuid,
pub workflow_name: Option<String>,
pub pid: Option<i32>,
pub malo_id: Option<MaloId>,
pub melo_id: Option<MeloId>,
pub contract_id: Option<String>,
pub erp_contract_id: Option<String>,
pub erp_order_id: Option<String>,
pub edifact_conv_id: Option<Uuid>,
pub marktrolle: Option<String>,
pub format_version: Option<String>,
pub status: ProcessStatus,
#[serde(with = "time::serde::rfc3339")]
pub initiated_at: time::OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
pub completed_at: Option<time::OffsetDateTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageResult<T> {
pub items: Vec<T>,
pub total: u64,
pub page: u32,
pub size: u32,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct MaloFilter {
pub sparte: Option<Sparte>,
pub zuordnungstyp: Option<String>,
pub rollencodenummer: Option<String>,
pub fallgruppe: Option<String>,
pub bilanzierungsmethode: Option<String>,
pub regelzone: Option<String>,
pub page: u32,
pub size: u32,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct CorrelationFilter {
pub erp_order_id: Option<String>,
pub malo_id: Option<MaloId>,
pub status: Option<ProcessStatus>,
}
#[allow(async_fn_in_trait)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
pub struct MaloTypedFields {
pub malo_id: String,
pub netzebene: Option<String>,
pub bilanzierungsgebiet: Option<String>,
pub gasqualitaet: Option<String>,
pub energierichtung: Option<String>,
pub bilanzierungsmethode: Option<String>,
pub fallgruppe: Option<String>,
pub regelzone: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MaloStammdatenPatch {
pub netzebene: Option<String>,
pub bilanzierungsgebiet: Option<String>,
pub gasqualitaet: Option<String>,
pub energierichtung: Option<String>,
pub bilanzierungsmethode: Option<String>,
pub regelzone: Option<String>,
pub fallgruppe: Option<String>,
pub fernsteuerbar: Option<bool>,
pub abwicklungsmodell: Option<String>,
}
impl MaloStammdatenPatch {
#[must_use]
pub fn is_empty(&self) -> bool {
self.netzebene.is_none()
&& self.bilanzierungsgebiet.is_none()
&& self.gasqualitaet.is_none()
&& self.energierichtung.is_none()
&& self.bilanzierungsmethode.is_none()
&& self.regelzone.is_none()
&& self.fallgruppe.is_none()
&& self.fernsteuerbar.is_none()
&& self.abwicklungsmodell.is_none()
}
}
#[allow(async_fn_in_trait)]
pub trait MaloRepository: Send + Sync {
async fn upsert(
&self,
malo_id: &MaloId,
sparte: Sparte,
data: &rubo4e::current::Marktlokation,
rollenzuordnung: Vec<Rollenzuordnung>,
if_match: Option<i64>,
bo4e_version: &str,
) -> Result<i64, MdmError>;
async fn patch_typenmerkmal(
&self,
malo_id: &MaloId,
bilanzierungsmethode: Option<&str>,
fallgruppe: Option<&str>,
) -> Result<(), MdmError>;
async fn patch_stammdaten(
&self,
malo_id: &MaloId,
patch: &MaloStammdatenPatch,
) -> Result<bool, MdmError>;
async fn find(&self, malo_id: &MaloId, at: Date) -> Result<Option<MaloRecord>, MdmError>;
async fn list(&self, filter: MaloFilter, at: Date) -> Result<PageResult<MaloRecord>, MdmError>;
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MeloStammdatenPatch {
#[serde(rename = "netzebene")]
pub netzebene_messung: Option<String>,
pub regelzone: Option<String>,
}
impl MeloStammdatenPatch {
#[must_use]
pub fn is_empty(&self) -> bool {
self.netzebene_messung.is_none() && self.regelzone.is_none()
}
}
#[allow(async_fn_in_trait)]
pub trait MeloRepository: Send + Sync {
async fn upsert(
&self,
melo_id: &MeloId,
malo_id: Option<&MaloId>,
data: &rubo4e::current::Messlokation,
if_match: Option<i64>,
bo4e_version: &str,
) -> Result<i64, MdmError>;
async fn find(&self, melo_id: &MeloId) -> Result<Option<MeloRecord>, MdmError>;
async fn patch_stammdaten(
&self,
melo_id: &MeloId,
patch: &MeloStammdatenPatch,
) -> Result<bool, MdmError>;
}
pub trait SubscriptionRepository: Send + Sync {
fn upsert(&self, sub: Subscription) -> impl Future<Output = Result<i64, MdmError>> + Send;
fn find(
&self,
subscriber_id: &str,
) -> impl Future<Output = Result<Option<Subscription>, MdmError>> + Send;
fn deactivate(
&self,
subscriber_id: &str,
) -> impl Future<Output = Result<bool, MdmError>> + Send;
fn list_active(&self) -> impl Future<Output = Result<Vec<Subscription>, MdmError>> + Send;
fn list_matching(
&self,
event_type: &str,
role: &str,
sparte: Option<&str>,
) -> impl Future<Output = Result<Vec<Subscription>, MdmError>> + Send;
}
#[allow(async_fn_in_trait)]
pub trait CorrelationIndex: Send + Sync {
async fn insert(&self, entry: CorrelationEntry) -> Result<(), MdmError>;
async fn update_status(
&self,
process_id: Uuid,
status: ProcessStatus,
completed_at: Option<time::OffsetDateTime>,
) -> Result<(), MdmError>;
async fn update_edifact_conv_id(&self, process_id: Uuid, conv_id: Uuid)
-> Result<(), MdmError>;
async fn find_by_erp_order_id(
&self,
erp_order_id: &str,
) -> Result<Option<CorrelationEntry>, MdmError>;
async fn find_by_process_id(
&self,
process_id: Uuid,
) -> Result<Option<CorrelationEntry>, MdmError>;
async fn list(&self, filter: CorrelationFilter) -> Result<Vec<CorrelationEntry>, MdmError>;
}
#[allow(async_fn_in_trait)]
pub trait PartnerRepository: Send + Sync {
async fn upsert(&self, partner: PartnerRecord) -> Result<i64, MdmError>;
async fn find(&self, id: &MarktpartnerId) -> Result<Option<PartnerRecord>, MdmError>;
async fn list(&self) -> Result<Vec<PartnerRecord>, MdmError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PreisblattSource {
Api,
Mako,
}
impl std::fmt::Display for PreisblattSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PreisblattSource::Api => f.write_str("api"),
PreisblattSource::Mako => f.write_str("mako"),
}
}
}
impl std::str::FromStr for PreisblattSource {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"api" => Ok(PreisblattSource::Api),
"mako" => Ok(PreisblattSource::Mako),
other => Err(format!("unknown PreisblattSource: {other:?}")),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PreisblattRecord {
pub nb_mp_id: String,
pub data: serde_json::Value,
#[serde(default = "default_bo4e_version")]
pub bo4e_version: String,
pub source: PreisblattSource,
#[serde(with = "time::serde::rfc3339")]
pub created_at: time::OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[allow(async_fn_in_trait)]
pub trait PreisblattRepository: Send + Sync {
async fn upsert(
&self,
nb_mp_id: &str,
data: serde_json::Value,
bo4e_version: &str,
source: PreisblattSource,
) -> Result<(), MdmError>;
async fn find_for_date(
&self,
nb_mp_id: &str,
billing_date: &str,
) -> Result<Option<PreisblattRecord>, MdmError>;
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PreisblattMessungRecord {
pub msb_mp_id: String,
pub data: serde_json::Value,
#[serde(default = "default_bo4e_version")]
pub bo4e_version: String,
pub source: PreisblattSource,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub auf_abschlaege: Vec<serde_json::Value>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: time::OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[allow(async_fn_in_trait)]
pub trait PreisblattMessungRepository: Send + Sync {
async fn upsert_messung(
&self,
msb_mp_id: &str,
data: serde_json::Value,
bo4e_version: &str,
source: PreisblattSource,
) -> Result<(), MdmError>;
async fn find_messung_for_date(
&self,
msb_mp_id: &str,
billing_date: &str,
) -> Result<Option<PreisblattMessungRecord>, MdmError>;
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PreisblattKaRecord {
pub nb_mp_id: String,
pub sparte: String,
pub kundengruppe_ka: Option<String>,
pub data: serde_json::Value,
#[serde(default = "default_bo4e_version")]
pub bo4e_version: String,
pub source: PreisblattSource,
#[serde(with = "time::serde::rfc3339")]
pub created_at: time::OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[allow(async_fn_in_trait)]
pub trait PreisblattKaRepository: Send + Sync {
async fn upsert_ka(
&self,
nb_mp_id: &str,
sparte: &str,
kundengruppe_ka: Option<&str>,
data: serde_json::Value,
bo4e_version: &str,
source: PreisblattSource,
) -> Result<(), MdmError>;
async fn find_ka_for_date(
&self,
nb_mp_id: &str,
sparte: &str,
kundengruppe_ka: Option<&str>,
billing_date: &str,
) -> Result<Option<PreisblattKaRecord>, MdmError>;
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PreisblattDienstleistungRecord {
pub msb_mp_id: String,
pub data: serde_json::Value,
#[serde(default = "default_bo4e_version")]
pub bo4e_version: String,
pub source: PreisblattSource,
#[serde(with = "time::serde::rfc3339")]
pub created_at: time::OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[allow(async_fn_in_trait)]
pub trait PreisblattDienstleistungRepository: Send + Sync {
async fn upsert_dienstleistung(
&self,
msb_mp_id: &str,
data: serde_json::Value,
bo4e_version: &str,
source: PreisblattSource,
) -> Result<(), MdmError>;
async fn find_dienstleistung_for_date(
&self,
msb_mp_id: &str,
billing_date: &str,
) -> Result<Option<PreisblattDienstleistungRecord>, MdmError>;
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PreisblattHardwareRecord {
pub msb_mp_id: String,
pub data: serde_json::Value,
#[serde(default = "default_bo4e_version")]
pub bo4e_version: String,
pub source: PreisblattSource,
#[serde(with = "time::serde::rfc3339")]
pub created_at: time::OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[allow(async_fn_in_trait)]
pub trait PreisblattHardwareRepository: Send + Sync {
async fn upsert_hardware(
&self,
msb_mp_id: &str,
data: serde_json::Value,
bo4e_version: &str,
source: PreisblattSource,
) -> Result<(), MdmError>;
async fn find_hardware_for_date(
&self,
msb_mp_id: &str,
billing_date: &str,
) -> Result<Option<PreisblattHardwareRecord>, MdmError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PriCatDispatchState {
Pending,
Queued,
Done,
Error,
}
impl std::fmt::Display for PriCatDispatchState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pending => write!(f, "pending"),
Self::Queued => write!(f, "queued"),
Self::Done => write!(f, "done"),
Self::Error => write!(f, "error"),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PriCatVersion {
pub id: uuid::Uuid,
pub nb_mp_id: String,
pub tenant: String,
pub valid_from: time::Date,
pub valid_to: Option<time::Date>,
pub data: serde_json::Value,
#[serde(default = "default_bo4e_version")]
pub bo4e_version: String,
pub source: PreisblattSource,
pub dispatch_state: PriCatDispatchState,
pub dispatch_error: Option<String>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: time::OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PriCatDispatchEntry {
pub id: uuid::Uuid,
pub pricat_version_id: uuid::Uuid,
pub nb_mp_id: String,
pub lf_mp_id: String,
pub tenant: String,
pub process_id: Option<uuid::Uuid>,
#[serde(with = "time::serde::rfc3339")]
pub dispatched_at: time::OffsetDateTime,
pub outcome: String,
pub error_detail: Option<String>,
}
#[allow(async_fn_in_trait)]
pub trait PriCatRepository: Send + Sync {
#[allow(clippy::too_many_arguments)]
async fn upsert_version(
&self,
nb_mp_id: &str,
tenant: &str,
valid_from: time::Date,
valid_to: Option<time::Date>,
data: serde_json::Value,
bo4e_version: &str,
source: PreisblattSource,
) -> Result<uuid::Uuid, MdmError>;
async fn list_versions(
&self,
nb_mp_id: &str,
tenant: &str,
) -> Result<Vec<PriCatVersion>, MdmError>;
async fn find_latest(
&self,
nb_mp_id: &str,
tenant: &str,
) -> Result<Option<PriCatVersion>, MdmError>;
async fn list_pending(&self, tenant: &str) -> Result<Vec<PriCatVersion>, MdmError>;
async fn mark_queued(&self, id: uuid::Uuid) -> Result<(), MdmError>;
async fn mark_done(&self, id: uuid::Uuid) -> Result<(), MdmError>;
async fn mark_error(&self, id: uuid::Uuid, error: &str) -> Result<(), MdmError>;
async fn log_dispatch(&self, entry: PriCatDispatchEntry) -> Result<(), MdmError>;
async fn dispatch_log(
&self,
pricat_version_id: uuid::Uuid,
) -> Result<Vec<PriCatDispatchEntry>, MdmError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum BillingSchedule {
#[default]
Monthly,
Quarterly,
Annually,
}
impl std::fmt::Display for BillingSchedule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Monthly => write!(f, "MONTHLY"),
Self::Quarterly => write!(f, "QUARTERLY"),
Self::Annually => write!(f, "ANNUALLY"),
}
}
}
impl std::str::FromStr for BillingSchedule {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_uppercase().as_str() {
"MONTHLY" => Ok(Self::Monthly),
"QUARTERLY" => Ok(Self::Quarterly),
"ANNUALLY" => Ok(Self::Annually),
other => Err(format!("unknown BillingSchedule '{other}'")),
}
}
}
impl BillingSchedule {
#[must_use]
pub fn from_str_or_default(s: &str) -> Self {
s.parse().unwrap_or_default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NbContractView {
pub contract_id: String,
pub malo_id: String,
pub nb_mp_id: String,
pub netznutzer_mp_id: String,
#[serde(default)]
pub netznutzer_typ: NetznutzerTyp,
pub netzebene: String,
pub bilanzierungsmethode: String,
}
impl NbContractView {
#[must_use]
pub const fn is_selbstzahler(&self) -> bool {
self.netznutzer_typ.is_selbstzahler()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum NetznutzerTyp {
#[default]
Lieferant,
Letztverbraucher,
}
impl NetznutzerTyp {
#[must_use]
pub const fn as_db_str(self) -> &'static str {
match self {
Self::Lieferant => "LIEFERANT",
Self::Letztverbraucher => "LETZTVERBRAUCHER",
}
}
#[must_use]
pub fn from_db_str(s: &str) -> Option<Self> {
match s {
"LIEFERANT" => Some(Self::Lieferant),
"LETZTVERBRAUCHER" => Some(Self::Letztverbraucher),
_ => None,
}
}
#[must_use]
pub const fn is_selbstzahler(self) -> bool {
matches!(self, Self::Letztverbraucher)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NbContractRecord {
pub contract_id: String,
pub malo_id: crate::domain::MaloId,
pub nb_mp_id: String,
pub sparte: crate::domain::Sparte,
pub netzebene: String,
pub bilanzierungsmethode: String,
pub billing_schedule: BillingSchedule,
pub netznutzer_mp_id: String,
#[serde(default)]
pub netznutzer_typ: NetznutzerTyp,
#[serde(with = "date_iso")]
pub valid_from: time::Date,
#[serde(with = "date_iso::opt")]
pub valid_to: Option<time::Date>,
#[serde(default)]
pub data: serde_json::Value,
#[serde(default)]
pub vertragsart: Option<String>,
#[serde(default)]
pub vertragsstatus: Option<String>,
pub tenant: String,
pub version: i64,
}
#[allow(async_fn_in_trait)]
pub trait NbContractRepository: Send + Sync {
#[must_use]
async fn upsert(&self, rec: NbContractRecord) -> Result<i64, MdmError>;
#[must_use]
async fn find(
&self,
contract_id: &str,
tenant: &str,
) -> Result<Option<NbContractRecord>, MdmError>;
#[must_use]
async fn find_active(
&self,
malo_id: &str,
date: time::Date,
tenant: &str,
) -> Result<Option<NbContractRecord>, MdmError>;
#[must_use]
async fn list_by_nb(
&self,
nb_mp_id: &str,
tenant: &str,
) -> Result<Vec<NbContractRecord>, MdmError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum LieferStatus {
Beliefert,
Unbeliefert,
Grundversorgung,
Ersatzversorgung,
Ruhend,
Stillgelegt,
}
impl std::fmt::Display for LieferStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Beliefert => write!(f, "Beliefert"),
Self::Unbeliefert => write!(f, "Unbeliefert"),
Self::Grundversorgung => write!(f, "Grundversorgung"),
Self::Ersatzversorgung => write!(f, "Ersatzversorgung"),
Self::Ruhend => write!(f, "Ruhend"),
Self::Stillgelegt => write!(f, "Stillgelegt"),
}
}
}
impl std::str::FromStr for LieferStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"Beliefert" => Ok(Self::Beliefert),
"Unbeliefert" => Ok(Self::Unbeliefert),
"Grundversorgung" => Ok(Self::Grundversorgung),
"Ersatzversorgung" => Ok(Self::Ersatzversorgung),
"Ruhend" => Ok(Self::Ruhend),
"Stillgelegt" => Ok(Self::Stillgelegt),
other => Err(format!("unknown LieferStatus '{other}'")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum ZuordnungsStatus {
Angekuendigt,
Aktiv,
}
impl ZuordnungsStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Angekuendigt => "Angekuendigt",
Self::Aktiv => "Aktiv",
}
}
}
impl std::str::FromStr for ZuordnungsStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"Angekuendigt" => Ok(Self::Angekuendigt),
"Aktiv" => Ok(Self::Aktiv),
other => Err(format!("unknown ZuordnungsStatus '{other}'")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LfZuordnung {
pub lf_mp_id: String,
pub prozent: Decimal,
pub tranche_id: Option<String>,
pub status: ZuordnungsStatus,
#[serde(default, with = "date_iso::opt")]
pub zuordnungsbeginn: Option<Date>,
#[serde(default, with = "date_iso::opt")]
pub zuordnungsende: Option<Date>,
pub process_id: Option<Uuid>,
}
impl LfZuordnung {
#[must_use]
pub fn ganz(lf_mp_id: impl Into<String>, status: ZuordnungsStatus) -> Self {
Self {
lf_mp_id: lf_mp_id.into(),
prozent: Decimal::ONE_HUNDRED,
tranche_id: None,
status,
zuordnungsbeginn: None,
zuordnungsende: None,
process_id: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersorgungsStatusRecord {
pub malo_id: MaloId,
pub lieferstatus: LieferStatus,
#[serde(default)]
pub zuordnungen: Vec<LfZuordnung>,
#[serde(default, with = "date_iso::opt")]
pub lieferende: Option<Date>,
pub msb_mp_id: Option<String>,
pub nb_mp_id: String,
#[serde(default, with = "date_iso::opt")]
pub eog_seit: Option<Date>,
pub last_process_id: Option<Uuid>,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
#[serde(skip_serializing, default)]
pub tenant: String,
pub version: i64,
}
impl VersorgungsStatusRecord {
pub fn aktive(&self) -> impl Iterator<Item = &LfZuordnung> {
self.zuordnungen
.iter()
.filter(|z| z.status == ZuordnungsStatus::Aktiv)
}
pub fn angekuendigte(&self) -> impl Iterator<Item = &LfZuordnung> {
self.zuordnungen
.iter()
.filter(|z| z.status == ZuordnungsStatus::Angekuendigt)
}
#[must_use]
pub fn lf_mp_id(&self) -> Option<&str> {
let mut aktive = self.aktive();
let first = aktive.next()?;
aktive.next().is_none().then_some(first.lf_mp_id.as_str())
}
#[must_use]
pub fn lf_mp_id_next(&self) -> Option<&str> {
let mut pending = self.angekuendigte();
let first = pending.next()?;
pending.next().is_none().then_some(first.lf_mp_id.as_str())
}
#[must_use]
pub fn lieferbeginn(&self) -> Option<Date> {
let mut aktive = self.aktive();
let first = aktive.next()?;
aktive.next().is_none().then_some(first.zuordnungsbeginn)?
}
#[must_use]
pub fn lf_next_lieferbeginn(&self) -> Option<Date> {
let mut pending = self.angekuendigte();
let first = pending.next()?;
pending.next().is_none().then_some(first.zuordnungsbeginn)?
}
#[must_use]
pub fn ist_tranchiert(&self) -> bool {
self.zuordnungen
.iter()
.any(|z| z.tranche_id.is_some() || z.prozent < Decimal::ONE_HUNDRED)
}
#[must_use]
pub fn andere_anmeldung_in_bearbeitung(&self, lf_mp_id: &str) -> Option<&LfZuordnung> {
self.angekuendigte().find(|z| z.lf_mp_id != lf_mp_id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersorgungsStatusHistoryRecord {
pub id: i64,
pub malo_id: MaloId,
pub tenant: String,
pub lieferstatus: LieferStatus,
#[serde(default)]
pub zuordnungen: Vec<LfZuordnung>,
#[serde(default, with = "date_iso::opt")]
pub lieferende: Option<Date>,
pub msb_mp_id: Option<String>,
pub nb_mp_id: String,
pub last_process_id: Option<Uuid>,
pub version: i64,
#[serde(with = "time::serde::rfc3339")]
pub valid_from: time::OffsetDateTime,
}
#[allow(async_fn_in_trait)]
pub trait VersorgungsStatusRepository: Send + Sync {
#[must_use]
async fn upsert(
&self,
rec: VersorgungsStatusRecord,
if_version: Option<i64>,
) -> Result<i64, MdmError>;
#[must_use]
async fn find(
&self,
malo_id: &MaloId,
tenant: &str,
) -> Result<Option<VersorgungsStatusRecord>, MdmError>;
#[must_use]
async fn find_at(
&self,
malo_id: &MaloId,
tenant: &str,
at: Date,
) -> Result<Option<VersorgungsStatusRecord>, MdmError>;
#[must_use]
async fn find_history(
&self,
malo_id: &MaloId,
tenant: &str,
page: u32,
size: u32,
) -> Result<PageResult<VersorgungsStatusHistoryRecord>, MdmError>;
#[must_use]
async fn list_by_tenant(
&self,
tenant: &str,
page: u32,
size: u32,
) -> Result<PageResult<VersorgungsStatusRecord>, MdmError>;
#[must_use]
#[allow(clippy::too_many_arguments)] async fn announce_lf_next(
&self,
malo_id: &MaloId,
tenant: &str,
lf_mp_id_next: &str,
lf_next_lieferbeginn: Option<Date>,
prozent: Decimal,
tranche_id: Option<&str>,
nb_mp_id: &str,
process_id: Option<Uuid>,
) -> Result<(), MdmError>;
#[must_use]
async fn confirm_supply(
&self,
malo_id: &MaloId,
tenant: &str,
lf_mp_id: Option<&str>,
process_id: Option<Uuid>,
) -> Result<(), MdmError>;
#[must_use]
async fn end_supply(
&self,
malo_id: &MaloId,
tenant: &str,
lf_mp_id: Option<&str>,
nb_mp_id: &str,
process_id: Option<Uuid>,
) -> Result<(), MdmError>;
async fn clear_lf_next(
&self,
malo_id: &MaloId,
tenant: &str,
lf_mp_id: Option<&str>,
process_id: Option<Uuid>,
) -> Result<(), MdmError>;
#[must_use]
#[allow(clippy::too_many_arguments)] async fn begin_eog_supply(
&self,
malo_id: &MaloId,
tenant: &str,
gv_mp_id: &str,
nb_mp_id: &str,
eog_status: LieferStatus,
eog_seit: Option<Date>,
process_id: Option<Uuid>,
) -> Result<(), MdmError>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GrundversorgerRecord {
pub nb_mp_id: String,
pub sparte: Sparte,
pub gv_mp_id: String,
#[serde(default, with = "date_iso::opt")]
pub festgestellt_am: Option<Date>,
#[serde(default)]
pub default_bilanzkreis: Option<String>,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
#[serde(skip_serializing, default)]
pub tenant: String,
}
#[allow(async_fn_in_trait)]
pub trait GrundversorgerRepository: Send + Sync {
async fn find(
&self,
tenant: &str,
nb_mp_id: &str,
sparte: Sparte,
) -> Result<Option<GrundversorgerRecord>, MdmError>;
async fn upsert(&self, record: &GrundversorgerRecord) -> Result<(), MdmError>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeloMsbZuordnung {
pub melo_id: String,
pub msb_mp_id: String,
#[serde(with = "date_iso")]
pub valid_from: Date,
#[serde(default, with = "date_iso::opt")]
pub valid_to: Option<Date>,
#[serde(skip_serializing, default)]
pub tenant: String,
}
#[allow(async_fn_in_trait)]
pub trait MeloMsbRepository: Send + Sync {
async fn assign_msb(
&self,
tenant: &str,
melo_id: &str,
msb_mp_id: &str,
valid_from: Date,
) -> Result<(), MdmError>;
async fn find_msb_at(
&self,
tenant: &str,
melo_id: &str,
at: Date,
) -> Result<Option<String>, MdmError>;
async fn history(&self, tenant: &str, melo_id: &str)
-> Result<Vec<MeloMsbZuordnung>, MdmError>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BilanzierungRecord {
pub malo_id: String,
#[serde(with = "time::serde::rfc3339")]
pub bilanzierungsbeginn: time::OffsetDateTime,
#[serde(default, with = "time::serde::rfc3339::option")]
pub bilanzierungsende: Option<time::OffsetDateTime>,
#[serde(default)]
pub bilanzkreis: Option<String>,
#[serde(default)]
pub aggregationsverantwortung: Option<String>,
#[serde(default)]
pub abwicklungsmodell: Option<String>,
#[serde(default)]
pub aggregationszustaendigkeit: Option<String>,
#[serde(default)]
pub prognosegrundlage: Option<String>,
#[serde(default)]
pub fallgruppenzuordnung: Option<String>,
pub data: serde_json::Value,
#[serde(default = "default_bo4e_version")]
pub bo4e_version: String,
#[serde(skip_serializing, default)]
pub tenant: String,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum BilanzierungRecordError {
#[error(
"bilanzierungsbeginn is required — it is the temporal key \
(tenant, malo_id, bilanzierungsbeginn)"
)]
NoBeginn,
#[error("bilanzierungsende {ende} is not after bilanzierungsbeginn {beginn}")]
EndeBeforeBeginn {
beginn: time::OffsetDateTime,
ende: time::OffsetDateTime,
},
#[error(transparent)]
Serialise(#[from] crate::bo4e::Bo4eSerialiseError),
}
impl BilanzierungRecord {
pub fn from_bo4e(
tenant: &str,
malo_id: &str,
bo: &crate::bo4e::Bo4e<rubo4e::current::Bilanzierung>,
) -> Result<Self, BilanzierungRecordError> {
use rubo4e::convenience::Aggregationszustaendigkeit;
let beginn = bo
.bilanzierungsbeginn
.ok_or(BilanzierungRecordError::NoBeginn)?;
let ende = bo.bilanzierungsende;
if let Some(ende) = ende
&& ende <= beginn
{
return Err(BilanzierungRecordError::EndeBeforeBeginn { beginn, ende });
}
let zustaendigkeit = match bo.aggregationszustaendigkeit() {
Aggregationszustaendigkeit::Uebertragungsnetzbetreiber => "UEBERTRAGUNGSNETZBETREIBER",
Aggregationszustaendigkeit::Verteilnetzbetreiber => "VERTEILNETZBETREIBER",
Aggregationszustaendigkeit::Ruhend => "RUHEND",
_ => "UNBEKANNT",
};
Ok(Self {
malo_id: malo_id.to_owned(),
bilanzierungsbeginn: beginn,
bilanzierungsende: ende,
bilanzkreis: bo.bilanzkreis.as_ref().map(ToString::to_string),
aggregationsverantwortung: bo.aggregationsverantwortung.map(|v| v.as_wire().to_owned()),
abwicklungsmodell: bo.abwicklungsmodell.map(|v| v.as_wire().to_owned()),
aggregationszustaendigkeit: Some(zustaendigkeit.to_owned()),
prognosegrundlage: bo.prognosegrundlage.map(|v| v.as_wire().to_owned()),
fallgruppenzuordnung: bo.fallgruppenzuordnung.map(|v| v.as_wire().to_owned()),
data: bo.canonical_json()?,
bo4e_version: crate::bo4e::schema_version(),
tenant: tenant.to_owned(),
updated_at: time::OffsetDateTime::now_utc(),
})
}
}
#[allow(async_fn_in_trait)]
pub trait BilanzierungRepository: Send + Sync {
async fn upsert(&self, record: &BilanzierungRecord) -> Result<(), MdmError>;
async fn find_at(
&self,
tenant: &str,
malo_id: &str,
at: time::OffsetDateTime,
) -> Result<Option<BilanzierungRecord>, MdmError>;
async fn history(
&self,
tenant: &str,
malo_id: &str,
) -> Result<Vec<BilanzierungRecord>, MdmError>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NeLoRecord {
pub nelo_id: String,
pub tenant: String,
pub name: Option<String>,
pub sparte: Sparte,
pub netzebene: Option<String>,
pub nb_mp_id: String,
pub steuerkanal: Option<bool>,
pub eigenschaft_msb_lokation: Option<String>,
pub grundzustaendiger_msb_codenr: Option<String>,
pub data: serde_json::Value,
pub version: i64,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NeloStammdatenPatch {
pub netzebene: Option<String>,
pub steuerkanal: Option<bool>,
}
impl NeloStammdatenPatch {
#[must_use]
pub fn is_empty(&self) -> bool {
self.netzebene.is_none() && self.steuerkanal.is_none()
}
}
#[allow(async_fn_in_trait)]
pub trait NeLoRepository: Send + Sync {
#[must_use]
async fn upsert(&self, rec: NeLoRecord, if_match: Option<i64>) -> Result<i64, MdmError>;
#[must_use]
async fn find(&self, nelo_id: &str, tenant: &str) -> Result<Option<NeLoRecord>, MdmError>;
async fn patch_stammdaten(
&self,
nelo_id: &str,
tenant: &str,
patch: &NeloStammdatenPatch,
) -> Result<bool, MdmError>;
#[must_use]
async fn list_by_nb(
&self,
nb_mp_id: &str,
tenant: &str,
page: u32,
size: u32,
) -> Result<PageResult<NeLoRecord>, MdmError>;
#[must_use]
async fn list_by_tenant(
&self,
tenant: &str,
page: u32,
size: u32,
) -> Result<PageResult<NeLoRecord>, MdmError>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrancheRecord {
pub tranche_id: String,
pub tenant: String,
pub malo_id: Option<String>,
pub bilanzierungsgebiet: Option<String>,
pub netzebene: Option<String>,
pub energierichtung: Option<String>,
pub data: serde_json::Value,
pub version: i64,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TrancheStammdatenPatch {
pub bilanzierungsgebiet: Option<String>,
pub netzebene: Option<String>,
pub energierichtung: Option<String>,
}
impl TrancheStammdatenPatch {
#[must_use]
pub fn is_empty(&self) -> bool {
self.bilanzierungsgebiet.is_none()
&& self.netzebene.is_none()
&& self.energierichtung.is_none()
}
}
#[allow(async_fn_in_trait)]
pub trait TrancheRepository: Send + Sync {
async fn upsert(&self, rec: TrancheRecord, if_match: Option<i64>) -> Result<i64, MdmError>;
async fn find(&self, tranche_id: &str, tenant: &str)
-> Result<Option<TrancheRecord>, MdmError>;
async fn list_by_malo(
&self,
malo_id: &str,
tenant: &str,
page: u32,
size: u32,
) -> Result<PageResult<TrancheRecord>, MdmError>;
async fn patch_stammdaten(
&self,
tranche_id: &str,
tenant: &str,
patch: &TrancheStammdatenPatch,
) -> Result<bool, MdmError>;
}
#[derive(Clone)]
pub struct AppState<Ma, Me, Su, Ci, Pa>
where
Ma: MaloRepository + Clone,
Me: MeloRepository + Clone,
Su: SubscriptionRepository + Clone,
Ci: CorrelationIndex + Clone,
Pa: PartnerRepository + Clone,
{
pub malo_repo: Ma,
pub melo_repo: Me,
pub subscription_repo: Su,
pub correlation_index: Ci,
pub partner_repo: Pa,
#[cfg(feature = "makod-client")]
pub makod_client: std::sync::Arc<crate::makod_client::MakodClient>,
pub notify: std::sync::Arc<tokio::sync::Notify>,
pub tenant: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaloGridRecord {
pub malo_id: MaloId,
pub nb_mp_id: String,
pub bilanzierungsgebiet: Option<String>,
pub netzgebiet: Option<String>,
pub sparte: Sparte,
pub source: String,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
#[serde(default)]
pub tenant: String,
}
#[allow(async_fn_in_trait)]
pub trait MaloGridRepository: Send + Sync {
#[must_use]
async fn upsert(&self, rec: MaloGridRecord) -> Result<(), MdmError>;
#[must_use]
async fn find(
&self,
malo_id: &MaloId,
tenant: &str,
) -> Result<Option<MaloGridRecord>, MdmError>;
#[must_use]
async fn list_by_nb(
&self,
nb_mp_id: &str,
tenant: &str,
) -> Result<Vec<MaloGridRecord>, MdmError>;
#[must_use]
async fn delete(&self, malo_id: &MaloId, tenant: &str) -> Result<(), MdmError>;
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SteuerbareRessourceRecord {
pub sr_id: String,
pub tenant: String,
pub malo_id: Option<String>,
pub melo_id: Option<String>,
pub data: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub konfigurationsprodukte: Option<serde_json::Value>,
#[serde(default = "default_bo4e_version")]
pub bo4e_version: String,
pub version: i64,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SteuerbareRessourceStammdatenPatch {
pub konfigurationsprodukte: Option<serde_json::Value>,
}
impl SteuerbareRessourceStammdatenPatch {
#[must_use]
pub fn is_empty(&self) -> bool {
self.konfigurationsprodukte.is_none()
}
}
#[allow(async_fn_in_trait)]
pub trait SteuerbareRessourceRepository: Send + Sync {
#[allow(clippy::too_many_arguments)]
async fn upsert_sr(
&self,
sr_id: &str,
tenant: &str,
malo_id: Option<&str>,
melo_id: Option<&str>,
data: serde_json::Value,
bo4e_version: &str,
konfigurationsprodukte: Option<serde_json::Value>,
) -> Result<(), MdmError>;
async fn find_sr(
&self,
sr_id: &str,
tenant: &str,
) -> Result<Option<SteuerbareRessourceRecord>, MdmError>;
async fn list_sr_by_malo(
&self,
malo_id: &str,
tenant: &str,
) -> Result<Vec<SteuerbareRessourceRecord>, MdmError>;
async fn replace_sr_konfigurationsprodukte(
&self,
sr_id: &str,
tenant: &str,
konfigurationsprodukte: serde_json::Value,
) -> Result<bool, MdmError>;
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TechnischeRessourceRecord {
pub tr_id: String,
pub tenant: String,
pub malo_id: Option<String>,
pub melo_id: Option<String>,
pub nutzung: Option<String>,
pub verbrauchsart: Option<String>,
pub ist_fernschaltbar: Option<bool>,
pub data: serde_json::Value,
#[serde(default = "default_bo4e_version")]
pub bo4e_version: String,
pub version: i64,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TechnischeRessourceStammdatenPatch {
pub nutzung: Option<String>,
pub verbrauchsart: Option<String>,
pub ist_fernschaltbar: Option<bool>,
}
impl TechnischeRessourceStammdatenPatch {
#[must_use]
pub fn is_empty(&self) -> bool {
self.nutzung.is_none() && self.verbrauchsart.is_none() && self.ist_fernschaltbar.is_none()
}
}
#[allow(async_fn_in_trait)]
pub trait TechnischeRessourceRepository: Send + Sync {
#[allow(clippy::too_many_arguments)]
async fn upsert_tr(
&self,
tr_id: &str,
tenant: &str,
malo_id: Option<&str>,
melo_id: Option<&str>,
nutzung: Option<&str>,
verbrauchsart: Option<&str>,
ist_fernschaltbar: Option<bool>,
data: serde_json::Value,
bo4e_version: &str,
) -> Result<(), MdmError>;
async fn find_tr(
&self,
tr_id: &str,
tenant: &str,
) -> Result<Option<TechnischeRessourceRecord>, MdmError>;
async fn list_tr_by_malo(
&self,
malo_id: &str,
tenant: &str,
) -> Result<Vec<TechnischeRessourceRecord>, MdmError>;
async fn list_tr_by_melo(
&self,
melo_id: &str,
tenant: &str,
) -> Result<Vec<TechnischeRessourceRecord>, MdmError>;
async fn patch_stammdaten(
&self,
tr_id: &str,
tenant: &str,
patch: &TechnischeRessourceStammdatenPatch,
) -> Result<bool, MdmError>;
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LokationszuordnungEdge {
pub id: uuid::Uuid,
pub tenant: String,
pub von_id: String,
pub von_typ: Lokationstyp,
pub nach_id: String,
pub nach_typ: Lokationstyp,
pub valid_from: Option<time::Date>,
pub valid_to: Option<time::Date>,
#[serde(default)]
pub lokationsbuendelcode: Option<String>,
pub data: serde_json::Value,
#[serde(default)]
pub depth: i32,
}
#[allow(async_fn_in_trait)]
pub trait LokationszuordnungRepository: Send + Sync {
#[allow(clippy::too_many_arguments)]
async fn upsert_edge(
&self,
tenant: &str,
von_id: &str,
von_typ: Lokationstyp,
nach_id: &str,
nach_typ: Lokationstyp,
valid_from: Option<time::Date>,
valid_to: Option<time::Date>,
data: serde_json::Value,
) -> Result<uuid::Uuid, MdmError>;
async fn find_graph(
&self,
tenant: &str,
root_id: &str,
at_date: Option<time::Date>,
) -> Result<Vec<LokationszuordnungEdge>, MdmError>;
async fn list_edges_from(
&self,
tenant: &str,
von_id: &str,
at_date: Option<time::Date>,
) -> Result<Vec<LokationszuordnungEdge>, MdmError>;
async fn delete_edge(
&self,
tenant: &str,
von_id: &str,
nach_id: &str,
) -> Result<bool, MdmError>;
async fn load_buendel(
&self,
tenant: &str,
malo_id: &str,
at_date: Option<time::Date>,
) -> Result<Lokationsbuendel, MdmError> {
let edges = self.find_graph(tenant, malo_id, at_date).await?;
Ok(Lokationsbuendel::from_graph(malo_id, &edges))
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum BuendelError {
#[error(
"Lokationsbündel for MaLo {malo_id} has no Messlokation \
(a consuming MaLo must bundle at least one MeLo)"
)]
NoMesslokation { malo_id: String },
#[error(
"Lokationsbündel for MaLo {malo_id} spans divergent MSB assignments {msbs:?} \
(all MeLos of a MaLo must share one Messstellenbetreiber)"
)]
DivergentMsb { malo_id: String, msbs: Vec<String> },
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Buendelbefund {
#[error("the bundle declares no lokationsbuendelcode")]
StrukturcodeFehlt,
#[error("lokationsbuendelcode {code} is not a valid BDEW code: {grund}")]
StrukturcodeUngueltig {
code: String,
grund: String,
},
#[error("lokationsbuendelcode {code} is not a published Lokationsbündelstruktur")]
StrukturUnbekannt {
code: String,
},
#[error("the structure describes no {objekttyp}, but the bundle holds {ist}")]
ObjekttypNichtVorgesehen {
objekttyp: String,
ist: usize,
},
#[error(
"the structure needs at least {min} Marktlokationen; the location graph keeps only \
the root, so this cannot be checked here — audit the BO4E Lokationszuordnung instead"
)]
MarktlokationenNichtPruefbar {
min: u32,
},
#[error(
"the structure permits {} {objekttyp}, the bundle holds {ist}",
match max { Some(m) if *m == *min => min.to_string(),
Some(m) => format!("{min}-{m}"),
None => format!("≥{min}") }
)]
Kardinalitaet {
objekttyp: String,
ist: usize,
min: u32,
max: Option<u32>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Buendelstrukturaudit {
pub struktur: Option<&'static rubo4e::lokationsbuendel::Lokationsbuendelstruktur>,
pub befunde: Vec<Buendelbefund>,
}
impl Buendelstrukturaudit {
#[must_use]
pub fn is_conformant(&self) -> bool {
self.befunde.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Lokationsbuendel {
pub malo_id: String,
pub lokationsbuendelcode: Option<String>,
pub messlokationen: Vec<String>,
pub netzlokationen: Vec<String>,
pub steuerbare_ressourcen: Vec<String>,
pub technische_ressourcen: Vec<String>,
}
impl Lokationsbuendel {
#[must_use]
pub fn from_graph(malo_id: &str, edges: &[LokationszuordnungEdge]) -> Self {
use std::collections::BTreeSet;
let (mut melo, mut nelo, mut sr, mut tr) = (
BTreeSet::new(),
BTreeSet::new(),
BTreeSet::new(),
BTreeSet::new(),
);
let mut lokationsbuendelcode: Option<String> = None;
for e in edges {
if lokationsbuendelcode.is_none() {
lokationsbuendelcode.clone_from(&e.lokationsbuendelcode);
}
for (id, typ) in [(&e.von_id, e.von_typ), (&e.nach_id, e.nach_typ)] {
if id == malo_id {
continue;
}
match typ {
Lokationstyp::Melo => melo.insert(id.clone()),
Lokationstyp::Nelo => nelo.insert(id.clone()),
Lokationstyp::Sr => sr.insert(id.clone()),
Lokationstyp::Tr => tr.insert(id.clone()),
Lokationstyp::Malo => false,
};
}
}
Self {
malo_id: malo_id.to_owned(),
lokationsbuendelcode,
messlokationen: melo.into_iter().collect(),
netzlokationen: nelo.into_iter().collect(),
steuerbare_ressourcen: sr.into_iter().collect(),
technische_ressourcen: tr.into_iter().collect(),
}
}
#[must_use]
pub fn audit_struktur(&self) -> Buendelstrukturaudit {
use rubo4e::lokationsbuendel::{Lokationsbuendelstruktur, Objekttyp};
let mut befunde = Vec::new();
let Some(raw) = self.lokationsbuendelcode.as_deref() else {
return Buendelstrukturaudit {
struktur: None,
befunde: vec![Buendelbefund::StrukturcodeFehlt],
};
};
let code = match rubo4e::identifiers::Lokationsbuendelcode::new(raw) {
Ok(c) => c,
Err(e) => {
return Buendelstrukturaudit {
struktur: None,
befunde: vec![Buendelbefund::StrukturcodeUngueltig {
code: raw.to_owned(),
grund: e.to_string(),
}],
};
}
};
let Some(struktur) = Lokationsbuendelstruktur::from_code(&code) else {
return Buendelstrukturaudit {
struktur: None,
befunde: vec![Buendelbefund::StrukturUnbekannt {
code: raw.to_owned(),
}],
};
};
let malo_min: u32 = struktur
.objekte_of(Objekttyp::Marktlokation)
.map(|o| o.min)
.sum();
if malo_min > 1 {
befunde.push(Buendelbefund::MarktlokationenNichtPruefbar { min: malo_min });
}
for (typ, ist) in [
(Objekttyp::Messlokation, self.messlokationen.len()),
(Objekttyp::Netzlokation, self.netzlokationen.len()),
(
Objekttyp::TechnischeRessource,
self.technische_ressourcen.len(),
),
] {
let rows: Vec<_> = struktur.objekte_of(typ).collect();
if rows.is_empty() {
if ist > 0 {
befunde.push(Buendelbefund::ObjekttypNichtVorgesehen {
objekttyp: typ.to_string(),
ist,
});
}
continue;
}
let min: u32 = rows.iter().map(|o| o.min).sum();
let max: Option<u32> = rows.iter().try_fold(0_u32, |acc, o| o.max.map(|m| acc + m));
let ist_u32 = u32::try_from(ist).unwrap_or(u32::MAX);
if ist_u32 < min || max.is_some_and(|m| ist_u32 > m) {
befunde.push(Buendelbefund::Kardinalitaet {
objekttyp: typ.to_string(),
ist,
min,
max,
});
}
}
Buendelstrukturaudit {
struktur: Some(struktur),
befunde,
}
}
pub fn validate(&self) -> Result<(), BuendelError> {
if self.messlokationen.is_empty() {
return Err(BuendelError::NoMesslokation {
malo_id: self.malo_id.clone(),
});
}
Ok(())
}
pub fn validate_msb_consistency(
&self,
msb_by_melo: &std::collections::HashMap<String, Option<String>>,
) -> Result<(), BuendelError> {
use std::collections::BTreeSet;
let msbs: BTreeSet<String> = self
.messlokationen
.iter()
.filter_map(|m| msb_by_melo.get(m).and_then(Clone::clone))
.collect();
if msbs.len() > 1 {
return Err(BuendelError::DivergentMsb {
malo_id: self.malo_id.clone(),
msbs: msbs.into_iter().collect(),
});
}
Ok(())
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ZaehlerRecord {
pub zaehler_id: String,
pub tenant: String,
pub melo_id: String,
pub zaehler_typ: Option<String>,
pub eichung_bis: Option<time::Date>,
pub data: serde_json::Value,
#[serde(default = "default_bo4e_version")]
pub bo4e_version: String,
pub version: i64,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Konfigurationsparameter {
FirmwareVersion,
HardwareRevision,
Kommunikation,
FernUpdateFaehig,
ClsFaehig,
SmgwTlsCertFingerprint,
SmgwCertAblaufdatum,
ClsKanalId,
GwaCodenummer,
Hersteller,
Inbetriebnahmedatum,
LetzteWartung,
NaechsteWartung,
AusleseProtokoll,
MsbVertragsnummer,
Sonstiges,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GeraetKonfiguration {
pub parameter: Konfigurationsparameter,
pub wert: String,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
pub notiz: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GeraetRecord {
pub geraet_id: String,
pub tenant: String,
pub zaehler_id: String,
pub geraet_typ: Option<String>,
pub data: serde_json::Value,
pub konfigurationen: Vec<GeraetKonfiguration>,
#[serde(default = "default_bo4e_version")]
pub bo4e_version: String,
pub version: i64,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[allow(async_fn_in_trait)]
pub trait DeviceRepository: Send + Sync {
#[allow(clippy::too_many_arguments)]
async fn upsert_zaehler(
&self,
zaehler_id: &str,
tenant: &str,
melo_id: &str,
data: &rubo4e::current::Zaehler,
bo4e_version: &str,
) -> Result<(), MdmError>;
async fn list_zaehler_by_melo(
&self,
melo_id: &str,
tenant: &str,
) -> Result<Vec<ZaehlerRecord>, MdmError>;
async fn find_zaehler(
&self,
zaehler_id: &str,
tenant: &str,
) -> Result<Option<ZaehlerRecord>, MdmError>;
async fn upsert_geraet(
&self,
geraet_id: &str,
tenant: &str,
zaehler_id: &str,
data: &rubo4e::current::Geraet,
bo4e_version: &str,
) -> Result<(), MdmError>;
async fn list_geraete_by_zaehler(
&self,
zaehler_id: &str,
tenant: &str,
) -> Result<Vec<GeraetRecord>, MdmError>;
async fn find_geraet(
&self,
geraet_id: &str,
tenant: &str,
) -> Result<Option<GeraetRecord>, MdmError>;
async fn upsert_geraet_konfigurationen(
&self,
geraet_id: &str,
tenant: &str,
konfigurationen: Vec<GeraetKonfiguration>,
) -> Result<bool, MdmError>;
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ZaehlzeitRegisterRecord {
pub id: uuid::Uuid,
pub zaehler_id: String,
pub tenant: String,
pub bezeichnung: String,
pub zaehlerauspraegung: String,
pub obis_kennzahl: Option<String>,
#[serde(default = "default_kwh")]
pub einheit: String,
pub valid_from: time::Date,
pub valid_to: Option<time::Date>,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
fn default_kwh() -> String {
"KWH".to_owned()
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ZaehlzeitSaisonRecord {
pub id: uuid::Uuid,
pub register_id: uuid::Uuid,
pub saison: String,
pub wochentage: Vec<i16>,
#[serde(with = "wall_clock")]
pub zeit_von: time::Time,
#[serde(with = "wall_clock")]
pub zeit_bis: time::Time,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
pub mod wall_clock {
use serde::{Deserialize as _, Deserializer, Serializer, de::Error as _};
pub fn serialize<S: Serializer>(t: &time::Time, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&format!(
"{:02}:{:02}:{:02}",
t.hour(),
t.minute(),
t.second()
))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<time::Time, D::Error> {
let raw = String::deserialize(d)?;
let mut parts = raw.trim().split(':');
let mut next = |what: &str| -> Result<u8, D::Error> {
parts
.next()
.ok_or_else(|| D::Error::custom(format!("{raw:?}: missing {what}")))?
.parse()
.map_err(|e| D::Error::custom(format!("{raw:?}: {what}: {e}")))
};
let h = next("hour")?;
let m = next("minute")?;
let sec = match parts.next() {
Some(s) => s
.parse()
.map_err(|e| D::Error::custom(format!("{raw:?}: second: {e}")))?,
None => 0,
};
if parts.next().is_some() {
return Err(D::Error::custom(format!(
"{raw:?}: too many `:`-separated parts"
)));
}
time::Time::from_hms(h, m, sec).map_err(|e| D::Error::custom(format!("{raw:?}: {e}")))
}
#[cfg(test)]
mod tests {
use super::super::ZaehlzeitSaisonRecord;
#[test]
fn a_window_round_trips_as_hh_mm_ss_not_a_component_array() {
let json = serde_json::json!({
"id": "00000000-0000-0000-0000-000000000001",
"register_id": "00000000-0000-0000-0000-000000000002",
"saison": "WINTER",
"wochentage": [1, 2, 3, 4, 5],
"zeit_von": "07:00",
"zeit_bis": "22:00:00",
"updated_at": "2026-01-01T00:00:00Z",
});
let rec: ZaehlzeitSaisonRecord =
serde_json::from_value(json).expect("HH:MM and HH:MM:SS both parse");
assert_eq!(rec.zeit_von, time::macros::time!(07:00));
assert_eq!(rec.zeit_bis, time::macros::time!(22:00));
let out = serde_json::to_value(&rec).expect("serialise");
assert_eq!(out["zeit_von"], "07:00:00");
assert!(
out["zeit_bis"].is_string(),
"a window boundary must stay a string, not become a component array: {out}"
);
}
#[test]
fn a_nonsense_time_is_refused_rather_than_defaulted() {
for bad in ["25:00", "07", "07:00:00:00", "seven"] {
let json = serde_json::json!({
"id": "00000000-0000-0000-0000-000000000001",
"register_id": "00000000-0000-0000-0000-000000000002",
"saison": "WINTER",
"wochentage": [1],
"zeit_von": bad,
"zeit_bis": "22:00",
"updated_at": "2026-01-01T00:00:00Z",
});
assert!(
serde_json::from_value::<ZaehlzeitSaisonRecord>(json).is_err(),
"{bad:?} must not parse as a window boundary"
);
}
}
}
}
#[allow(async_fn_in_trait)]
pub trait ZaehlzeitRepository: Send + Sync {
async fn upsert_register(&self, rec: &ZaehlzeitRegisterRecord) -> Result<(), MdmError>;
async fn list_registers_by_zaehler(
&self,
zaehler_id: &str,
tenant: &str,
) -> Result<Vec<ZaehlzeitRegisterRecord>, MdmError>;
async fn upsert_saison(&self, rec: &ZaehlzeitSaisonRecord) -> Result<(), MdmError>;
async fn list_saisons_by_register(
&self,
register_id: uuid::Uuid,
tenant: &str,
) -> Result<Vec<ZaehlzeitSaisonRecord>, MdmError>;
async fn resolve_tariff_zone(
&self,
zaehler_id: &str,
tenant: &str,
local_datetime: time::PrimitiveDateTime,
) -> Result<Option<String>, MdmError>;
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MmmaPreisGasRecord {
pub price_month: time::Date,
pub marktgebiet: String,
pub mehr_ct_kwh: rust_decimal::Decimal,
pub minder_ct_kwh: rust_decimal::Decimal,
pub source: String,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[allow(async_fn_in_trait)]
pub trait MmmaPreisGasRepository: Send + Sync {
async fn upsert_gas(
&self,
price_month: time::Date,
marktgebiet: &str,
mehr_ct_kwh: rust_decimal::Decimal,
minder_ct_kwh: rust_decimal::Decimal,
source: &str,
) -> Result<(), MdmError>;
async fn find_gas(
&self,
price_month: time::Date,
marktgebiet: &str,
) -> Result<Option<MmmaPreisGasRecord>, MdmError>;
async fn list_gas(&self, limit: i64) -> Result<Vec<MmmaPreisGasRecord>, MdmError>;
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MmmPreisStromRecord {
pub price_month: time::Date,
pub mehr_ct_kwh: rust_decimal::Decimal,
pub minder_ct_kwh: rust_decimal::Decimal,
pub source: String,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[allow(async_fn_in_trait)]
pub trait MmmPreisStromRepository: Send + Sync {
async fn upsert_strom(
&self,
price_month: time::Date,
mehr_ct_kwh: rust_decimal::Decimal,
minder_ct_kwh: rust_decimal::Decimal,
source: &str,
) -> Result<(), MdmError>;
async fn find_strom(
&self,
price_month: time::Date,
) -> Result<Option<MmmPreisStromRecord>, MdmError>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NbEnergiemixRecord {
pub nb_mp_id: String,
pub gueltig_fuer: i16,
pub energiemix: serde_json::Value,
pub eeg_einspeisung_kwh: Option<i64>,
pub gesamtentnahme_kwh: Option<i64>,
#[serde(with = "time::serde::rfc3339::option", default)]
pub updated_at: Option<time::OffsetDateTime>,
}
#[allow(async_fn_in_trait)]
pub trait NbEnergiemixRepository: Send + Sync {
async fn upsert_energiemix(
&self,
tenant: &str,
nb_mp_id: &str,
gueltig_fuer: i16,
energiemix: serde_json::Value,
eeg_einspeisung_kwh: Option<i64>,
gesamtentnahme_kwh: Option<i64>,
) -> Result<(), MdmError>;
async fn find_energiemix(
&self,
tenant: &str,
nb_mp_id: &str,
year: Option<i16>,
) -> Result<Option<NbEnergiemixRecord>, MdmError>;
async fn list_energiemix_years(
&self,
tenant: &str,
nb_mp_id: &str,
) -> Result<Vec<i16>, MdmError>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EinwilligungRecord {
#[serde(default)]
pub id: Uuid,
#[serde(default)]
pub tenant: String,
pub anschlussnutzer_ref: String,
pub esa_mp_id: String,
pub location_ids: Vec<String>,
#[serde(default = "default_scope")]
pub scope: String,
#[serde(default = "unix_epoch", with = "time::serde::rfc3339")]
pub granted_at: time::OffsetDateTime,
#[serde(with = "date_iso")]
pub valid_from: Date,
#[serde(default, with = "date_iso::opt")]
pub valid_to: Option<Date>,
#[serde(default, with = "time::serde::rfc3339::option")]
pub revoked_at: Option<time::OffsetDateTime>,
#[serde(default)]
pub evidence_uri: Option<String>,
#[serde(default)]
pub evidence_hash: Option<String>,
}
fn default_scope() -> String {
"werte".to_owned()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EsaFrameworkAgreement {
#[serde(default)]
pub tenant: String,
pub msb_mp_id: String,
pub esa_mp_id: String,
#[serde(default, with = "time::serde::rfc3339::option")]
pub signed_at: Option<time::OffsetDateTime>,
#[serde(default)]
pub edi_agreement: bool,
#[serde(default = "default_cert_state")]
pub cert_state: String,
}
fn default_cert_state() -> String {
"pending".to_owned()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EsaMessproduktAngebot {
#[serde(default)]
pub tenant: String,
pub msb_mp_id: String,
pub messprodukt: String,
#[serde(default = "crate::repository::default_true")]
pub als_abo: bool,
#[serde(default = "crate::repository::default_true")]
pub als_einmalig: bool,
#[serde(default)]
pub valid_from: Option<time::Date>,
#[serde(default)]
pub valid_to: Option<time::Date>,
}
#[must_use]
pub const fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EsaMessproduktPreis {
#[serde(default)]
pub tenant: String,
pub esa_mp_id: String,
pub msb_mp_id: String,
pub lokations_id: String,
pub messprodukt: String,
pub artikel_id: String,
pub preistyp: String,
pub betrag: rust_decimal::Decimal,
pub einheit: String,
#[serde(default = "default_waehrung")]
pub waehrung: String,
#[serde(default)]
pub bestellung_ref: Option<String>,
#[serde(default)]
pub valid_from: Option<time::Date>,
#[serde(default)]
pub valid_to: Option<time::Date>,
}
fn default_waehrung() -> String {
"EUR".to_owned()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConsentPerspective {
#[default]
MsbInbound,
EsaOutbound,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConsentCode {
Active,
SelfAssertion,
NoConsent,
Revoked,
FrameworkRejected,
}
impl ConsentCode {
#[must_use]
pub const fn allowed(self) -> bool {
matches!(self, Self::Active | Self::SelfAssertion)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConsentDecision {
pub allowed: bool,
pub code: ConsentCode,
pub reason: String,
}
impl ConsentDecision {
#[must_use]
pub fn from_code(code: ConsentCode) -> Self {
let reason = match code {
ConsentCode::Active => "aktive Einwilligung liegt vor",
ConsentCode::SelfAssertion => {
"keine Einwilligung erfasst — Zusicherung des ESA gilt (keine Formprüfung)"
}
ConsentCode::NoConsent => {
"keine Einwilligung erfasst — der ESA hat keine Rechtsgrundlage (GDPR Art. 7)"
}
ConsentCode::Revoked => {
"Einwilligung wurde widerrufen (GDPR Art. 7 Abs. 3) — keine Belieferung"
}
ConsentCode::FrameworkRejected => {
"Rahmenvertrag/EDI-Vereinbarung nicht etabliert (Vorbedingung UC 4.1.1)"
}
};
Self {
allowed: code.allowed(),
code,
reason: reason.to_owned(),
}
}
}
#[allow(async_fn_in_trait)]
pub trait EinwilligungRepository: Send + Sync {
async fn grant(&self, rec: EinwilligungRecord) -> Result<Uuid, MdmError>;
async fn get(&self, tenant: &str, id: Uuid) -> Result<Option<EinwilligungRecord>, MdmError>;
async fn list_for_esa(
&self,
tenant: &str,
esa_mp_id: &str,
) -> Result<Vec<EinwilligungRecord>, MdmError>;
async fn revoke(&self, tenant: &str, id: Uuid) -> Result<Option<EinwilligungRecord>, MdmError>;
async fn revoke_expired(
&self,
now: time::Date,
tenant: &str,
) -> Result<Vec<EinwilligungRecord>, MdmError>;
async fn upsert_framework(&self, rec: EsaFrameworkAgreement) -> Result<(), MdmError>;
async fn get_framework(
&self,
tenant: &str,
msb_mp_id: &str,
esa_mp_id: &str,
) -> Result<Option<EsaFrameworkAgreement>, MdmError>;
async fn upsert_esa_preise(&self, preise: &[EsaMessproduktPreis]) -> Result<(), MdmError>;
async fn esa_messprodukt_angebot(
&self,
tenant: &str,
msb_mp_id: &str,
messprodukt: &str,
at: time::Date,
) -> Result<Option<EsaMessproduktAngebot>, MdmError>;
async fn upsert_esa_messprodukt_katalog(
&self,
eintraege: &[EsaMessproduktAngebot],
) -> Result<(), MdmError>;
async fn esa_messprodukt_of_bestellung(
&self,
tenant: &str,
bestellung_ref: &str,
) -> Result<Option<String>, MdmError>;
async fn esa_preise_at(
&self,
tenant: &str,
esa_mp_id: &str,
msb_mp_id: &str,
at: time::Date,
) -> Result<Vec<EsaMessproduktPreis>, MdmError>;
async fn consent_check(
&self,
tenant: &str,
esa_mp_id: &str,
msb_mp_id: &str,
location_id: &str,
perspective: ConsentPerspective,
) -> Result<ConsentDecision, MdmError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NetzzugangAntragTyp {
Zaehlpunktanordnung,
Verrechnungskonzept,
EnergySharingVereinbarung,
}
impl NetzzugangAntragTyp {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Zaehlpunktanordnung => "zaehlpunktanordnung",
Self::Verrechnungskonzept => "verrechnungskonzept",
Self::EnergySharingVereinbarung => "energysharing_vereinbarung",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NetzzugangAktion {
Bestellung,
Aenderung,
Abbestellung,
Registrierung,
}
impl NetzzugangAktion {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Bestellung => "bestellung",
Self::Aenderung => "aenderung",
Self::Abbestellung => "abbestellung",
Self::Registrierung => "registrierung",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NetzzugangStatus {
Erfasst,
Uebermittelt,
Bestaetigt,
Abgelehnt,
Fehlgeschlagen,
}
impl NetzzugangStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Erfasst => "erfasst",
Self::Uebermittelt => "uebermittelt",
Self::Bestaetigt => "bestaetigt",
Self::Abgelehnt => "abgelehnt",
Self::Fehlgeschlagen => "fehlgeschlagen",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetzzugangAntrag {
#[serde(default)]
pub id: Uuid,
#[serde(default)]
pub tenant: String,
pub antrag_typ: NetzzugangAntragTyp,
pub aktion: NetzzugangAktion,
pub netzanschluss_id: String,
pub nb_mp_id: String,
pub antragsteller_ref: String,
#[serde(default = "default_netzzugang_status")]
pub status: NetzzugangStatus,
#[serde(default)]
pub payload: serde_json::Value,
#[serde(default)]
pub platform_ref: Option<String>,
#[serde(default = "unix_epoch", with = "time::serde::rfc3339")]
pub created_at: time::OffsetDateTime,
#[serde(default, with = "time::serde::rfc3339::option")]
pub submitted_at: Option<time::OffsetDateTime>,
}
fn default_netzzugang_status() -> NetzzugangStatus {
NetzzugangStatus::Erfasst
}
#[cfg(test)]
mod partner_record_tests {
use super::*;
fn sample_partner() -> PartnerRecord {
PartnerRecord {
mp_id: "9900357000004".parse().expect("valid MP-ID"),
display_name: Some("Stadtwerke Musterstadt Netz GmbH".to_owned()),
marktrolle: Some(rubo4e::current::Marktrolle::Nb),
sparte: Some(Sparte::Strom),
rollencodetyp: Some(rubo4e::current::Rollencodetyp::Bdew),
makoadresse: vec!["https://as4.musterstadt.example/msh".to_owned()],
geschaeftspartner: serde_json::json!({}),
version: 1,
updated_at: time::OffsetDateTime::UNIX_EPOCH,
}
}
#[test]
fn typed_enums_stay_string_compatible() {
let p = sample_partner();
let json = serde_json::to_value(&p).expect("serialise");
assert_eq!(json["marktrolle"], "NB");
assert_eq!(json["rollencodetyp"], "BDEW");
let round: PartnerRecord = serde_json::from_value(json).expect("deserialise");
assert_eq!(round.marktrolle, Some(rubo4e::current::Marktrolle::Nb));
assert_eq!(
round.rollencodetyp,
Some(rubo4e::current::Rollencodetyp::Bdew)
);
assert_eq!(rubo4e::current::Marktrolle::Nb.to_string(), "NB");
assert_eq!(rubo4e::current::Rollencodetyp::Gln.to_string(), "GLN");
assert_eq!(
rubo4e::current::Marktrolle::from_wire("LF"),
Ok(rubo4e::current::Marktrolle::Lf)
);
}
#[test]
fn to_marktteilnehmer_maps_all_fields() {
let p = sample_partner();
let mt = p.to_marktteilnehmer();
assert_eq!(
mt.rollencodenummer.as_ref().map(ToString::to_string),
Some("9900357000004".to_owned())
);
assert_eq!(mt.marktrolle, Some(rubo4e::current::Marktrolle::Nb));
assert_eq!(mt.rollencodetyp, Some(rubo4e::current::Rollencodetyp::Bdew));
assert_eq!(mt.sparte, Some(rubo4e::current::Sparte::Strom));
assert_eq!(
mt.makoadresse,
Some(vec!["https://as4.musterstadt.example/msh".to_owned()])
);
assert_eq!(
mt.geschaeftspartner
.as_ref()
.and_then(|g| g.organisationsname.clone()),
Some("Stadtwerke Musterstadt Netz GmbH".to_owned())
);
assert_eq!(mt.typ, Some(rubo4e::current::BoTyp::Marktteilnehmer));
let mut bare = sample_partner();
bare.makoadresse.clear();
bare.display_name = None;
let mt = bare.to_marktteilnehmer();
assert_eq!(mt.makoadresse, None);
assert!(mt.geschaeftspartner.is_none());
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MabisZpRecord {
pub bilanzierungsgebiet: String,
pub mabis_zp_id: String,
pub source: String,
pub tenant: String,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[allow(async_fn_in_trait)]
pub trait MabisZpRepository: Send + Sync {
#[must_use]
async fn upsert(&self, rec: MabisZpRecord) -> Result<(), MdmError>;
#[must_use]
async fn find(
&self,
bilanzierungsgebiet: &str,
tenant: &str,
) -> Result<Option<MabisZpRecord>, MdmError>;
#[must_use]
async fn list(&self, tenant: &str) -> Result<Vec<MabisZpRecord>, MdmError>;
}
#[cfg(test)]
mod lokationsbuendel_tests {
use std::collections::HashMap;
use super::*;
fn edge(
von: &str,
von_typ: Lokationstyp,
nach: &str,
nach_typ: Lokationstyp,
) -> LokationszuordnungEdge {
LokationszuordnungEdge {
id: uuid::Uuid::nil(),
tenant: "t".to_owned(),
von_id: von.to_owned(),
von_typ,
nach_id: nach.to_owned(),
nach_typ,
valid_from: None,
valid_to: None,
lokationsbuendelcode: Some("9992000000125".to_owned()),
data: serde_json::json!({}),
depth: 0,
}
}
#[test]
fn edge_typ_is_bo4e_lokationstyp() {
let e = edge("MALO1", Lokationstyp::Malo, "MELO1", Lokationstyp::Melo);
let json = serde_json::to_value(&e).expect("serialise");
assert_eq!(json["von_typ"], "MALO");
assert_eq!(json["nach_typ"], "MELO");
assert_eq!(<&'static str>::from(Lokationstyp::Nelo), "NELO");
assert_eq!("SR".parse(), Ok(Lokationstyp::Sr));
}
const UNPUBLISHED_BUT_WELL_FORMED: &str = "9992000009002";
fn buendel_with(code: Option<&str>, melos: usize) -> Lokationsbuendel {
Lokationsbuendel {
malo_id: "MALO1".to_owned(),
lokationsbuendelcode: code.map(ToOwned::to_owned),
messlokationen: (1..=melos).map(|i| format!("MELO{i}")).collect(),
netzlokationen: vec![],
steuerbare_ressourcen: vec![],
technische_ressourcen: vec![],
}
}
#[test]
fn a_conformant_bundle_names_its_published_structure() {
let audit = buendel_with(Some("9992000000026"), 1).audit_struktur();
assert!(audit.is_conformant(), "{:?}", audit.befunde);
let s = audit.struktur.expect("a published structure");
assert_eq!(s.bezeichnung, "Verbrauch mit einer Messlokation (Standard)");
assert_eq!(s.max_ebene(), 1);
}
#[test]
fn a_second_messlokation_breaks_the_standard_structure() {
let audit = buendel_with(Some("9992000000026"), 2).audit_struktur();
assert_eq!(audit.befunde.len(), 1, "{:?}", audit.befunde);
let msg = audit.befunde[0].to_string();
assert!(
msg.contains("permits 1") && msg.contains("holds 2"),
"the finding must state both numbers: {msg}"
);
}
#[test]
fn an_object_the_structure_does_not_describe_is_its_own_finding() {
let audit = buendel_with(Some("9992000000018"), 1).audit_struktur();
assert!(matches!(
audit.befunde.as_slice(),
[Buendelbefund::ObjekttypNichtVorgesehen { ist: 1, .. }]
));
}
#[test]
fn a_structure_needing_two_malos_is_reported_as_undecidable() {
let audit = buendel_with(Some("9992000000125"), 2).audit_struktur();
assert!(
matches!(
audit.befunde.as_slice(),
[Buendelbefund::MarktlokationenNichtPruefbar { min: 2 }]
),
"{:?}",
audit.befunde
);
assert!(
audit.befunde[0].to_string().contains("audit the BO4E"),
"the finding must name what *can* decide it: {}",
audit.befunde[0]
);
}
#[test]
fn a_single_malo_structure_says_nothing_about_marktlokationen() {
let audit = buendel_with(Some("9992000000026"), 1).audit_struktur();
assert!(audit.is_conformant(), "{:?}", audit.befunde);
}
#[test]
fn a_bad_check_digit_is_refused_before_the_lookup() {
let audit = buendel_with(Some("9992000000019"), 1).audit_struktur();
assert!(audit.struktur.is_none());
assert!(matches!(
audit.befunde.as_slice(),
[Buendelbefund::StrukturcodeUngueltig { .. }]
));
}
#[test]
fn a_well_formed_unpublished_code_says_so() {
let audit = buendel_with(Some(UNPUBLISHED_BUT_WELL_FORMED), 1).audit_struktur();
assert!(audit.struktur.is_none());
assert!(
matches!(
audit.befunde.as_slice(),
[Buendelbefund::StrukturUnbekannt { .. }]
),
"{:?}",
audit.befunde
);
}
#[test]
fn a_bundle_with_no_code_reports_the_absence() {
let audit = buendel_with(None, 1).audit_struktur();
assert_eq!(audit.befunde, vec![Buendelbefund::StrukturcodeFehlt]);
}
#[test]
fn from_graph_projects_nodes_by_type() {
let edges = vec![
edge("MALO1", Lokationstyp::Malo, "MELO1", Lokationstyp::Melo),
edge("MALO1", Lokationstyp::Malo, "MELO2", Lokationstyp::Melo),
edge("MELO1", Lokationstyp::Melo, "NELO1", Lokationstyp::Nelo),
edge("MELO1", Lokationstyp::Melo, "SR1", Lokationstyp::Sr),
edge("SR1", Lokationstyp::Sr, "TR1", Lokationstyp::Tr),
edge("MALO1", Lokationstyp::Malo, "MELO1", Lokationstyp::Melo),
];
let b = Lokationsbuendel::from_graph("MALO1", &edges);
assert_eq!(b.malo_id, "MALO1");
assert_eq!(b.lokationsbuendelcode.as_deref(), Some("9992000000125"));
assert_eq!(b.messlokationen, vec!["MELO1", "MELO2"]);
assert_eq!(b.netzlokationen, vec!["NELO1"]);
assert_eq!(b.steuerbare_ressourcen, vec!["SR1"]);
assert_eq!(b.technische_ressourcen, vec!["TR1"]);
b.validate().expect("bundle with a MeLo is valid");
}
#[test]
fn validate_requires_at_least_one_melo() {
let edges = vec![edge(
"MALO1",
Lokationstyp::Malo,
"NELO1",
Lokationstyp::Nelo,
)];
let b = Lokationsbuendel::from_graph("MALO1", &edges);
assert!(b.messlokationen.is_empty());
assert!(matches!(
b.validate(),
Err(BuendelError::NoMesslokation { .. })
));
}
#[test]
fn validate_msb_consistency_flags_divergent_msb() {
let edges = vec![
edge("MALO1", Lokationstyp::Malo, "MELO1", Lokationstyp::Melo),
edge("MALO1", Lokationstyp::Malo, "MELO2", Lokationstyp::Melo),
];
let b = Lokationsbuendel::from_graph("MALO1", &edges);
let mut consistent = HashMap::new();
consistent.insert("MELO1".to_owned(), Some("MSB_A".to_owned()));
consistent.insert("MELO2".to_owned(), None);
b.validate_msb_consistency(&consistent)
.expect("single MSB is consistent");
let mut divergent = HashMap::new();
divergent.insert("MELO1".to_owned(), Some("MSB_A".to_owned()));
divergent.insert("MELO2".to_owned(), Some("MSB_B".to_owned()));
assert!(matches!(
b.validate_msb_consistency(&divergent),
Err(BuendelError::DivergentMsb { .. })
));
}
}
#[cfg(test)]
mod wire_format_guard {
#[test]
fn the_default_time_format_is_not_rfc_3339() {
let t = time::OffsetDateTime::from_unix_timestamp(1_767_225_600).expect("valid instant");
let raw = serde_json::to_string(&t).expect("serialise");
assert_eq!(raw, "\"2026-01-01 00:00:00.0 +00:00:00\"");
}
#[test]
fn every_offsetdatetime_field_declares_the_rfc_3339_format() {
let src = include_str!("repository.rs");
let lines: Vec<&str> = src.lines().collect();
let mut offenders = Vec::new();
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if !trimmed.starts_with("pub ") || !trimmed.contains("time::OffsetDateTime") {
continue;
}
let annotated = lines[..i]
.iter()
.rev()
.take_while(|l| {
let t = l.trim();
t.starts_with('#') || t.starts_with("///") || t.starts_with("//")
})
.any(|l| l.contains("time::serde::rfc3339"));
if !annotated {
offenders.push(format!("line {}: {trimmed}", i + 1));
}
}
assert!(
offenders.is_empty(),
"these OffsetDateTime fields would serialise in `time`'s own non-RFC-3339 \
format; add #[serde(with = \"time::serde::rfc3339\")] (or `::option`):\n {}",
offenders.join("\n ")
);
}
}
#[cfg(test)]
mod netznutzer_typ_tests {
use super::NetznutzerTyp;
#[test]
fn the_db_token_round_trips() {
for t in [NetznutzerTyp::Lieferant, NetznutzerTyp::Letztverbraucher] {
assert_eq!(NetznutzerTyp::from_db_str(t.as_db_str()), Some(t));
}
}
#[test]
fn an_unknown_token_is_refused() {
assert_eq!(NetznutzerTyp::from_db_str("GROSSKUNDE"), None);
assert_eq!(NetznutzerTyp::default(), NetznutzerTyp::Lieferant);
assert!(!NetznutzerTyp::default().is_selbstzahler());
assert!(NetznutzerTyp::Letztverbraucher.is_selbstzahler());
}
#[test]
fn the_json_form_is_the_db_token() {
let json = serde_json::to_string(&NetznutzerTyp::Letztverbraucher).unwrap();
assert_eq!(json, "\"LETZTVERBRAUCHER\"");
let back: NetznutzerTyp = serde_json::from_str(&json).unwrap();
assert_eq!(back, NetznutzerTyp::Letztverbraucher);
}
}
#[cfg(test)]
mod bilanzierung_record_tests {
use super::{BilanzierungRecord, BilanzierungRecordError};
use crate::bo4e::Bo4e;
use rubo4e::current::{Abwicklungsmodell, Aggregationsverantwortung, Bilanzierung};
use time::macros::datetime;
fn bo(b: Bilanzierung) -> Bo4e<Bilanzierung> {
Bo4e::from_built(b)
}
fn beginn() -> Bilanzierung {
Bilanzierung {
bilanzierungsbeginn: Some(datetime!(2026-01-01 00:00 UTC)),
..Default::default()
}
}
#[test]
fn modell_2_with_no_holder_is_ruhend_not_unknown() {
let rec = BilanzierungRecord::from_bo4e(
"t",
"51238696012",
&bo(Bilanzierung {
abwicklungsmodell: Some(Abwicklungsmodell::Modell2),
..beginn()
}),
)
.expect("a record");
assert_eq!(rec.aggregationsverantwortung, None);
assert_eq!(rec.abwicklungsmodell.as_deref(), Some("MODELL_2"));
assert_eq!(rec.aggregationszustaendigkeit.as_deref(), Some("RUHEND"));
}
#[test]
fn an_absent_holder_alone_is_unbekannt() {
let rec = BilanzierungRecord::from_bo4e("t", "51238696012", &bo(beginn())).expect("record");
assert_eq!(rec.aggregationszustaendigkeit.as_deref(), Some("UNBEKANNT"));
}
#[test]
fn a_named_holder_wins_over_the_model() {
let rec = BilanzierungRecord::from_bo4e(
"t",
"51238696012",
&bo(Bilanzierung {
abwicklungsmodell: Some(Abwicklungsmodell::Modell2),
aggregationsverantwortung: Some(Aggregationsverantwortung::Vnb),
..beginn()
}),
)
.expect("a record");
assert_eq!(rec.aggregationsverantwortung.as_deref(), Some("VNB"));
assert_eq!(
rec.aggregationszustaendigkeit.as_deref(),
Some("VERTEILNETZBETREIBER")
);
}
#[test]
fn a_missing_beginn_is_refused_by_name() {
let err = BilanzierungRecord::from_bo4e("t", "51238696012", &bo(Bilanzierung::default()))
.expect_err("no temporal key");
assert_eq!(err, BilanzierungRecordError::NoBeginn);
}
#[test]
fn an_end_before_the_start_is_refused() {
let err = BilanzierungRecord::from_bo4e(
"t",
"51238696012",
&bo(Bilanzierung {
bilanzierungsende: Some(datetime!(2025-01-01 00:00 UTC)),
..beginn()
}),
)
.expect_err("an empty interval");
assert!(matches!(
err,
BilanzierungRecordError::EndeBeforeBeginn { .. }
));
}
#[test]
fn the_stored_document_is_canonical_and_the_stamp_is_ours() {
let rec = BilanzierungRecord::from_bo4e("t", "51238696012", &bo(beginn())).expect("record");
assert_eq!(rec.data["_typ"], "BILANZIERUNG");
assert_eq!(rec.bo4e_version, crate::bo4e::schema_version());
}
}