use rubo4e::current::Marktlokation;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MaloShadowColumns {
pub netzebene: Option<&'static str>,
pub bilanzierungsgebiet: Option<String>,
pub gasqualitaet: Option<&'static str>,
pub energierichtung: Option<&'static str>,
pub bilanzierungsmethode: Option<&'static str>,
pub regelzone: Option<String>,
pub lokationsbuendel_objektcode: Option<String>,
}
impl MaloShadowColumns {
pub fn from_marktlokation(malo: &Marktlokation) -> Result<Self, ObjektcodeError> {
Ok(Self {
netzebene: malo.netzebene.map(|v| v.as_wire()),
bilanzierungsgebiet: malo.bilanzierungsgebiet.clone(),
gasqualitaet: malo.gasqualitaet.map(|v| v.as_wire()),
energierichtung: malo.energierichtung.map(|v| v.as_wire()),
bilanzierungsmethode: malo.bilanzierungsmethode.map(|v| v.as_wire()),
regelzone: malo.regelzone.as_ref().map(ToString::to_string),
lokationsbuendel_objektcode: checked_objektcode(
malo.lokationsbuendel_objektcode.as_ref(),
rubo4e::lokationsbuendel::Objekttyp::Marktlokation,
)?,
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MeloShadowColumns {
pub netzebene_messung: Option<&'static str>,
pub lokationsbuendel_objektcode: Option<String>,
pub regelzone: Option<String>,
pub standorteigenschaften: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ObjektcodeError {
#[error("lokationsbuendelObjektcode {code} is not a valid BDEW code: {grund}")]
Ungueltig {
code: String,
grund: String,
},
#[error("lokationsbuendelObjektcode {code} is not a published object code")]
Unbekannt {
code: String,
},
#[error("lokationsbuendelObjektcode {code} stands for a {ist}, but it is carried by a {soll}")]
FalscherObjekttyp {
code: String,
ist: &'static str,
soll: &'static str,
},
}
fn checked_objektcode(
code: Option<&String>,
soll: rubo4e::lokationsbuendel::Objekttyp,
) -> Result<Option<String>, ObjektcodeError> {
use rubo4e::identifiers::LokationsbuendelObjektcode;
use rubo4e::lokationsbuendel::Objektrolle;
let Some(code) = code else { return Ok(None) };
let typed = LokationsbuendelObjektcode::new(code).map_err(|e| ObjektcodeError::Ungueltig {
code: code.clone(),
grund: e.to_string(),
})?;
let rolle = Objektrolle::from_code(&typed)
.ok_or_else(|| ObjektcodeError::Unbekannt { code: code.clone() })?;
if rolle.objekttyp != soll {
return Err(ObjektcodeError::FalscherObjekttyp {
code: code.clone(),
ist: rolle.objekttyp.abbreviation(),
soll: soll.abbreviation(),
});
}
Ok(Some(code.clone()))
}
#[derive(Debug, thiserror::Error)]
#[error("`standorteigenschaften` is not a valid BO4E Standorteigenschaften: {0}")]
pub struct StandorteigenschaftenError(String);
#[derive(Debug, thiserror::Error)]
pub enum MeloColumnsError {
#[error(transparent)]
Standorteigenschaften(#[from] StandorteigenschaftenError),
#[error(transparent)]
Objektcode(#[from] ObjektcodeError),
}
impl MeloShadowColumns {
pub fn from_messlokation(
melo: &rubo4e::current::Messlokation,
) -> Result<Self, MeloColumnsError> {
use rubo4e::current::Standorteigenschaften;
use rubo4e::json::Bo4eExtensionData as _;
let (regelzone, standorteigenschaften) =
match melo.extension_data().get("standorteigenschaften") {
None => (None, None),
Some(raw) => {
let typed: Standorteigenschaften = super::gate::decode(raw.clone())
.map_err(|e| StandorteigenschaftenError(e.to_string()))?;
let eic = typed
.eigenschaften_strom
.as_ref()
.and_then(|v| v.first())
.and_then(|s| s.regelzone_eic.as_ref())
.map(ToString::to_string);
let json = serde_json::to_value(&typed)
.map_err(|e| StandorteigenschaftenError(e.to_string()))?;
(eic, Some(json))
}
};
Ok(Self {
netzebene_messung: melo.netzebene_messung.map(|v| v.as_wire()),
lokationsbuendel_objektcode: checked_objektcode(
melo.lokationsbuendel_objektcode.as_ref(),
rubo4e::lokationsbuendel::Objekttyp::Messlokation,
)?,
regelzone,
standorteigenschaften,
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ZaehlerShadowColumns {
pub zaehler_typ: Option<&'static str>,
pub eichung_bis: Option<time::Date>,
}
impl ZaehlerShadowColumns {
#[must_use]
pub fn from_zaehler(z: &rubo4e::current::Zaehler) -> Self {
Self {
zaehler_typ: z.zaehlertyp.map(|v| v.as_wire()),
eichung_bis: z.eichung_bis.map(time::OffsetDateTime::date),
}
}
}
#[must_use]
pub fn geraet_typ(g: &rubo4e::current::Geraet) -> Option<&'static str> {
g.geraetetyp.map(|v| v.as_wire())
}
pub const MAKO_PREISTYP_ATTRIBUT: &str = "mako:preistyp";
#[must_use]
pub fn position_preistyp(position: &serde_json::Value) -> &str {
if let Some(pt) = position.get("preistyp").and_then(|v| v.as_str())
&& !pt.is_empty()
{
return pt;
}
position
.get("zusatzAttribute")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.find(|a| a.get("name").and_then(|v| v.as_str()) == Some(MAKO_PREISTYP_ATTRIBUT))
.and_then(|a| a.get("wert"))
.and_then(|v| v.as_str())
.unwrap_or("")
}
#[must_use]
pub fn is_bo4e_preistyp(value: &str) -> bool {
rubo4e::current::Preistyp::from_wire(value).is_ok()
}
#[must_use]
pub fn malo_enum_check_lists() -> Vec<(&'static str, Vec<&'static str>)> {
use rubo4e::current::{
Abwicklungsmodell, Bilanzierungsmethode, Energierichtung, Fallgruppenzuordnung,
Gasqualitaet, Netzebene,
};
fn wires<T: rubo4e::Bo4eEnum + 'static>() -> Vec<&'static str> {
T::VARIANTS.iter().map(rubo4e::Bo4eEnum::as_wire).collect()
}
vec![
("netzebene", wires::<Netzebene>()),
("gasqualitaet", wires::<Gasqualitaet>()),
("energierichtung", wires::<Energierichtung>()),
("bilanzierungsmethode", wires::<Bilanzierungsmethode>()),
("fallgruppe", wires::<Fallgruppenzuordnung>()),
("abwicklungsmodell", wires::<Abwicklungsmodell>()),
]
}
#[must_use]
pub fn melo_enum_check_lists() -> Vec<(&'static str, Vec<&'static str>)> {
vec![("netzebene_messung", netzebene_wires())]
}
#[must_use]
pub fn nelo_enum_check_lists() -> Vec<(&'static str, Vec<&'static str>)> {
vec![("netzebene", netzebene_wires())]
}
#[must_use]
pub fn partner_enum_check_lists() -> Vec<(&'static str, Vec<&'static str>)> {
use rubo4e::current::{Marktrolle, Rollencodetyp};
vec![
(
"marktrolle",
Marktrolle::VARIANTS
.iter()
.map(rubo4e::Bo4eEnum::as_wire)
.collect(),
),
(
"rollencodetyp",
Rollencodetyp::VARIANTS
.iter()
.map(rubo4e::Bo4eEnum::as_wire)
.collect(),
),
]
}
fn netzebene_wires() -> Vec<&'static str> {
rubo4e::current::Netzebene::VARIANTS
.iter()
.map(rubo4e::Bo4eEnum::as_wire)
.collect()
}
#[cfg(test)]
mod tests {
use super::MaloShadowColumns;
use rubo4e::current::{
Bilanzierungsmethode, Energierichtung, Gasqualitaet, Marktlokation, Netzebene,
};
#[test]
fn columns_are_bo4e_wire_values() {
let malo = Marktlokation {
netzebene: Some(Netzebene::MspNspUmsp),
gasqualitaet: Some(Gasqualitaet::LGas),
energierichtung: Some(Energierichtung::Einsp),
bilanzierungsmethode: Some(Bilanzierungsmethode::Rlm),
..Default::default()
};
let cols = MaloShadowColumns::from_marktlokation(&malo)
.expect("a catalogued Marktlokation object code, or none");
assert_eq!(cols.netzebene, Some("MSP_NSP_UMSP"));
assert_eq!(cols.gasqualitaet, Some("L_GAS"));
assert_eq!(cols.energierichtung, Some("EINSP"));
assert_eq!(cols.bilanzierungsmethode, Some("RLM"));
}
#[test]
fn every_variant_round_trips_through_from_wire() {
for &v in Netzebene::VARIANTS {
let malo = Marktlokation {
netzebene: Some(v),
..Default::default()
};
let wire = MaloShadowColumns::from_marktlokation(&malo)
.expect("no object code")
.netzebene
.expect("set");
assert_eq!(Netzebene::from_wire(wire), Ok(v));
}
for &v in Energierichtung::VARIANTS {
let malo = Marktlokation {
energierichtung: Some(v),
..Default::default()
};
let wire = MaloShadowColumns::from_marktlokation(&malo)
.expect("no object code")
.energierichtung
.expect("set");
assert_eq!(Energierichtung::from_wire(wire), Ok(v));
}
}
#[test]
fn an_empty_marktlokation_yields_no_columns() {
assert_eq!(
MaloShadowColumns::from_marktlokation(&Marktlokation::default())
.expect("no object code"),
MaloShadowColumns::default()
);
}
}
#[cfg(test)]
mod standorteigenschaften_tests {
use super::MeloShadowColumns;
use rubo4e::current::Messlokation;
fn melo_with(standorteigenschaften: &serde_json::Value) -> Messlokation {
serde_json::from_value(serde_json::json!({
"messlokationsId": "DE0123456789012345678901234567890",
"standorteigenschaften": standorteigenschaften,
}))
.expect("a Messlokation with an extension field")
}
#[test]
fn the_regelzone_column_holds_the_eic_and_not_the_name() {
let cols = MeloShadowColumns::from_messlokation(&melo_with(&serde_json::json!({
"eigenschaftenStrom": [{
"regelzone": "TenneT TSO GmbH",
"regelzoneEic": "10YDE-EON------1",
}],
})))
.expect("a well-formed Standorteigenschaften");
assert_eq!(cols.regelzone.as_deref(), Some("10YDE-EON------1"));
}
#[test]
fn a_name_without_a_code_yields_no_regelzone() {
let cols = MeloShadowColumns::from_messlokation(&melo_with(&serde_json::json!({
"eigenschaftenStrom": [{ "regelzone": "TenneT TSO GmbH" }],
})))
.expect("a well-formed Standorteigenschaften");
assert_eq!(cols.regelzone, None);
}
#[test]
fn the_stored_extension_carries_its_discriminant() {
let cols = MeloShadowColumns::from_messlokation(&melo_with(&serde_json::json!({
"eigenschaftenStrom": [{ "regelzoneEic": "10YDE-EON------1" }],
})))
.expect("a well-formed Standorteigenschaften");
let stored = cols.standorteigenschaften.expect("the extension is stored");
assert_eq!(stored["_typ"], "STANDORTEIGENSCHAFTEN");
}
#[test]
fn an_out_of_schema_value_is_refused() {
let err = MeloShadowColumns::from_messlokation(&melo_with(&serde_json::json!({
"eigenschaftenGas": [{ "netzebene": "NIEDERDRUCKK" }],
})))
.expect_err("NIEDERDRUCKK is not a Netzebene");
assert!(err.to_string().contains("Standorteigenschaften"), "{err}");
}
#[test]
fn no_extension_yields_no_columns() {
let cols = MeloShadowColumns::from_messlokation(&Messlokation::default())
.expect("absence is not a failure");
assert_eq!(cols.regelzone, None);
assert_eq!(cols.standorteigenschaften, None);
}
}
#[cfg(test)]
mod check_constraint_drift {
use std::collections::BTreeSet;
fn check_list(ddl: &str, column: &str) -> BTreeSet<String> {
let needle = format!("CHECK ({column} IN (");
let start = ddl
.find(&needle)
.unwrap_or_else(|| panic!("no CHECK constraint on `{column}` in the malo DDL"))
+ needle.len();
let end = start
+ ddl[start..]
.find("))")
.expect("unterminated CHECK constraint");
ddl[start..end]
.split(',')
.map(|v| v.trim().trim_matches('\'').to_owned())
.filter(|v| !v.is_empty())
.collect()
}
fn table_ddl<'a>(sql: &'a str, table: &str) -> &'a str {
let head = format!("CREATE TABLE {table} (");
let start = sql
.find(&head)
.unwrap_or_else(|| panic!("no `{table}` table in the migration"));
let end = start
+ sql[start..]
.find("\n);")
.unwrap_or_else(|| panic!("unterminated `{table}` table"));
&sql[start..end]
}
#[test]
fn bo4e_check_constraints_match_the_schema() {
let sql = include_str!("../../../../services/marktd/migrations/0001_initial.sql");
for (table, lists) in [
("malo", super::malo_enum_check_lists()),
("melo", super::melo_enum_check_lists()),
("nelo", super::nelo_enum_check_lists()),
("partners", super::partner_enum_check_lists()),
] {
let ddl = table_ddl(sql, table);
for (column, variants) in lists {
let expected: BTreeSet<String> = variants.iter().map(|v| (*v).to_owned()).collect();
let actual = check_list(ddl, column);
assert_eq!(
actual,
expected,
"{table}.{column}: the CHECK list has drifted from the BO4E schema. \
Missing: {:?}. Unknown to BO4E: {:?}.",
expected.difference(&actual).collect::<Vec<_>>(),
actual.difference(&expected).collect::<Vec<_>>(),
);
}
}
}
}
#[cfg(test)]
mod preistyp_tests {
use super::{MAKO_PREISTYP_ATTRIBUT, is_bo4e_preistyp, position_preistyp};
#[test]
fn a_bo4e_preistyp_is_read_from_the_bo4e_field() {
let pos = serde_json::json!({ "preistyp": "GRUNDPREIS" });
assert_eq!(position_preistyp(&pos), "GRUNDPREIS");
assert!(is_bo4e_preistyp("GRUNDPREIS"));
}
#[test]
fn a_mako_preistyp_is_read_from_the_zusatz_attribut() {
let pos = serde_json::json!({
"zusatzAttribute": [{ "name": MAKO_PREISTYP_ATTRIBUT, "wert": "EEG_MARKTPRAEMIE" }]
});
assert_eq!(position_preistyp(&pos), "EEG_MARKTPRAEMIE");
assert!(!is_bo4e_preistyp("EEG_MARKTPRAEMIE"));
}
#[test]
fn unknown_is_not_a_bo4e_preistyp() {
assert!(!is_bo4e_preistyp("UNKNOWN"));
assert!(!is_bo4e_preistyp(""));
}
#[test]
fn a_position_with_neither_reads_empty() {
assert_eq!(position_preistyp(&serde_json::json!({})), "");
assert_eq!(
position_preistyp(&serde_json::json!({ "zusatzAttribute": [] })),
""
);
}
}
#[cfg(test)]
mod objektcode_tests {
use super::{MaloShadowColumns, MeloShadowColumns, ObjektcodeError};
use rubo4e::current::{Marktlokation, Messlokation};
const MALO_CODE: &str = "9992000001016";
const MELO_CODE: &str = "9992000001032";
fn malo(code: &str) -> Result<MaloShadowColumns, ObjektcodeError> {
MaloShadowColumns::from_marktlokation(&Marktlokation {
lokationsbuendel_objektcode: Some(code.to_owned()),
..Default::default()
})
}
#[test]
fn a_correct_object_code_is_shadowed() {
assert_eq!(
malo(MALO_CODE)
.expect("catalogued")
.lokationsbuendel_objektcode,
Some(MALO_CODE.to_owned())
);
let melo = MeloShadowColumns::from_messlokation(&Messlokation {
lokationsbuendel_objektcode: Some(MELO_CODE.to_owned()),
..Default::default()
})
.expect("catalogued");
assert_eq!(melo.lokationsbuendel_objektcode, Some(MELO_CODE.to_owned()));
}
#[test]
fn a_messlokation_code_on_a_marktlokation_is_refused() {
let err = malo(MELO_CODE).expect_err("a MeLo code is not a MaLo code");
assert_eq!(
err,
ObjektcodeError::FalscherObjekttyp {
code: MELO_CODE.to_owned(),
ist: "MeLo",
soll: "MaLo",
}
);
}
#[test]
fn a_bad_check_digit_is_refused() {
assert!(matches!(
malo("9992000001017").expect_err("bad check digit"),
ObjektcodeError::Ungueltig { .. }
));
}
#[test]
fn a_well_formed_unpublished_code_is_refused_by_name() {
assert!(matches!(
malo("9992000009002").expect_err("not published"),
ObjektcodeError::Unbekannt { .. }
));
}
#[test]
fn an_absent_object_code_passes() {
assert_eq!(
MaloShadowColumns::from_marktlokation(&Marktlokation::default())
.expect("absent is fine")
.lokationsbuendel_objektcode,
None
);
}
}