use std::marker::PhantomData;
use edifact_rs::Writer;
use rubo4e::identifiers::ObisCode;
use crate::AgencyCode;
use crate::{Error, Pruefidentifikator, Release};
use super::{Set, Unset, bytes_to_segments, today_ccyymmdd};
const PIA_TYPE_OBIS: &str = "SRW";
pub const QTY_ENERGIE_SUMMIERT: &str = "79";
pub const QTY_WAHRER_WERT: &str = "220";
pub const QTY_ERSATZWERT: &str = "67";
pub const MSCONS_UNITS: [&str; 4] = ["KWH", "KWT", "D54", "MTS"];
#[must_use]
pub fn is_valid_mscons_unit(unit: &str) -> bool {
MSCONS_UNITS.contains(&unit)
}
#[derive(Debug, Clone)]
struct QuantitySpec {
qualifier: String,
value: String,
unit: String,
period: Option<(String, String)>,
}
#[derive(Debug, Clone)]
struct LeistungsperiodeSpec {
value: String,
format: String,
}
#[derive(Debug, Clone)]
struct LineItemSpec {
line_number: usize,
obis_code: Option<ObisCode>,
quantities: Vec<QuantitySpec>,
leistungsperiode: Option<LeistungsperiodeSpec>,
}
#[derive(Debug, Clone)]
struct MeteringPointSpec {
malo_id: String,
location_id: Option<String>,
balancing_period: Option<String>,
version: Option<String>,
line_items: Vec<LineItemSpec>,
}
#[derive(Debug, Clone)]
struct MsconsBuilderInner {
release: Release,
pruefidentifikator: Option<Pruefidentifikator>,
sender_id: Option<String>,
receiver_id: Option<String>,
sender_agency: AgencyCode,
receiver_agency: AgencyCode,
message_ref: String,
document_code: String,
document_number: String,
document_date: Option<String>,
header_references: Vec<(String, String)>,
metering_points: Vec<MeteringPointSpec>,
}
#[derive(Debug, Clone)]
#[must_use = "Builder must be consumed via .build() or .serialize()"]
pub struct MsconsBuilder<S = Unset, R = Unset> {
_ph: PhantomData<fn() -> (S, R)>,
inner: MsconsBuilderInner,
}
impl MsconsBuilder<Unset, Unset> {
pub fn new(release: Release) -> Self {
Self {
_ph: PhantomData,
inner: MsconsBuilderInner {
release,
pruefidentifikator: None,
sender_id: None,
receiver_id: None,
sender_agency: AgencyCode::Bdew,
receiver_agency: AgencyCode::Bdew,
message_ref: "1".to_owned(),
document_code: "7".to_owned(),
document_number: String::new(),
document_date: None,
header_references: Vec::new(),
metering_points: Vec::new(),
},
}
}
}
impl<S, R> MsconsBuilder<S, R> {
fn transition<S2, R2>(self) -> MsconsBuilder<S2, R2> {
MsconsBuilder {
_ph: PhantomData,
inner: self.inner,
}
}
pub fn sender(mut self, id: impl Into<String>) -> MsconsBuilder<Set, R> {
self.inner.sender_id = Some(id.into());
self.transition()
}
pub fn receiver(mut self, id: impl Into<String>) -> MsconsBuilder<S, Set> {
self.inner.receiver_id = Some(id.into());
self.transition()
}
pub fn sender_agency(mut self, agency: crate::AgencyCode) -> Self {
self.inner.sender_agency = agency;
self
}
pub fn receiver_agency(mut self, agency: crate::AgencyCode) -> Self {
self.inner.receiver_agency = agency;
self
}
pub fn pruefidentifikator(mut self, pid: Pruefidentifikator) -> Self {
self.inner.pruefidentifikator = Some(pid);
self
}
pub fn message_ref(mut self, reference: impl Into<String>) -> Self {
self.inner.message_ref = reference.into();
self
}
pub fn document_number(mut self, number: impl Into<String>) -> Self {
self.inner.document_number = number.into();
self
}
pub fn document_code(mut self, code: impl Into<String>) -> Self {
self.inner.document_code = code.into();
self
}
pub fn document_date(mut self, date: impl Into<String>) -> Self {
self.inner.document_date = Some(date.into());
self
}
pub fn header_reference(
mut self,
qualifier: impl Into<String>,
value: impl Into<String>,
) -> Self {
self.inner
.header_references
.push((qualifier.into(), value.into()));
self
}
pub fn metering_point(self, malo_id: impl Into<String>) -> MeteringPointBuilder<S, R> {
MeteringPointBuilder {
parent: self,
spec: MeteringPointSpec {
malo_id: malo_id.into(),
location_id: None,
balancing_period: None,
version: None,
line_items: Vec::new(),
},
current_item: None,
}
}
}
impl<S, R> MsconsBuilder<S, R> {
fn to_bytes(&self) -> Result<Vec<u8>, Error> {
let pid_str = self
.inner
.pruefidentifikator
.map(|p| format!("{:05}", p.as_u32()))
.unwrap_or_default();
let document_number = if self.inner.document_number.is_empty() {
pid_str.clone()
} else {
self.inner.document_number.clone()
};
let dtm_val = self
.inner
.document_date
.as_deref()
.map_or_else(today_ccyymmdd, str::to_owned);
let mut buf = Vec::new();
let mut w = Writer::new(&mut buf);
emit_comp!(
w,
"UNH",
[&self.inner.message_ref],
["MSCONS", "D", "04B", "UN", self.inner.release.as_str()]
);
emit_seg!(w, "BGM", &self.inner.document_code, &document_number, "9");
emit_comp!(w, "DTM", ["137", &dtm_val, "102"]);
if !pid_str.is_empty() {
emit_comp!(w, "RFF", ["Z13", &pid_str]);
}
for (qualifier, value) in &self.inner.header_references {
emit_comp!(w, "RFF", [qualifier, value]);
}
if let Some(id) = &self.inner.sender_id {
emit_comp!(
w,
"NAD",
["MS"],
[id, "", self.inner.sender_agency.as_str()]
);
}
if let Some(id) = &self.inner.receiver_id {
emit_comp!(
w,
"NAD",
["MR"],
[id, "", self.inner.receiver_agency.as_str()]
);
}
if !self.inner.metering_points.is_empty() {
emit_seg!(w, "UNS", "D");
for mp in &self.inner.metering_points {
emit_comp!(w, "NAD", ["DP"], [&mp.malo_id, "", "293"]);
if !mp.line_items.is_empty() {
let loc_id = mp.location_id.as_deref().unwrap_or(mp.malo_id.as_str());
emit_seg!(w, "LOC", "172", loc_id);
if let Some(period) = &mp.balancing_period {
emit_comp!(w, "DTM", ["492", period, "610"]);
}
if let Some(version) = &mp.version {
emit_comp!(w, "DTM", ["293", version, "304"]);
}
for item in &mp.line_items {
let ln = item.line_number.to_string();
emit_seg!(w, "LIN", &ln);
if let Some(obis) = &item.obis_code {
let pia_value = obis.to_pia_string();
emit_comp!(w, "PIA", ["5"], [&pia_value, PIA_TYPE_OBIS]);
}
for qty in &item.quantities {
emit_comp!(w, "QTY", [&qty.qualifier, &qty.value, &qty.unit]);
if let Some((start, end)) = &qty.period {
emit_comp!(w, "DTM", ["163", start, "303"]);
emit_comp!(w, "DTM", ["164", end, "303"]);
}
}
if let Some(lp) = &item.leistungsperiode {
emit_comp!(w, "DTM", ["306", &lp.value, &lp.format]);
}
}
}
}
}
w.finish_unt(&self.inner.message_ref)
.map_err(Error::Parse)?;
Ok(buf)
}
pub fn serialize(self) -> Result<Vec<u8>, Error> {
self.to_bytes()
}
}
impl MsconsBuilder<Set, Set> {
pub fn build(self) -> Result<crate::messages::mscons::MsconsMessage, Error> {
let pid = self
.inner
.pruefidentifikator
.map(super::super::pruefidentifikator::Pruefidentifikator::as_u32);
let message_ref = self.inner.message_ref.clone();
let assoc_code = self.inner.release.as_str().to_owned();
let segments = bytes_to_segments(&self.to_bytes()?)?;
Ok(crate::messages::mscons::MsconsMessage::from_parts(
segments,
message_ref.as_str(),
assoc_code.as_str(),
pid,
))
}
}
#[derive(Debug)]
#[must_use = "Sub-builder must be finalized with .done()"]
pub struct MeteringPointBuilder<S = Unset, R = Unset> {
parent: MsconsBuilder<S, R>,
spec: MeteringPointSpec,
current_item: Option<LineItemSpec>,
}
impl<S, R> MeteringPointBuilder<S, R> {
pub fn location_id(mut self, id: impl Into<String>) -> Self {
self.flush_item();
self.spec.location_id = Some(id.into());
self
}
pub fn balancing_period(mut self, period: impl Into<String>) -> Self {
self.spec.balancing_period = Some(period.into());
self
}
pub fn version(mut self, version: impl Into<String>) -> Self {
self.spec.version = Some(version.into());
self
}
pub fn obis(mut self, code: ObisCode) -> Self {
self.current_item
.get_or_insert_with(|| LineItemSpec {
line_number: self.spec.line_items.len() + 1,
obis_code: None,
quantities: Vec::new(),
leistungsperiode: None,
})
.obis_code = Some(code);
self
}
pub fn line_item(mut self, obis_code: ObisCode) -> Self {
self.flush_item();
let idx = self.spec.line_items.len() + 1;
self.current_item = Some(LineItemSpec {
line_number: idx,
obis_code: Some(obis_code),
quantities: Vec::new(),
leistungsperiode: None,
});
self
}
pub fn next_line_item(mut self) -> Self {
self.flush_item();
self
}
pub fn quantity(
mut self,
qualifier: impl Into<String>,
value: impl Into<String>,
unit: impl Into<String>,
) -> Self {
if self.current_item.is_none() {
let idx = self.spec.line_items.len() + 1;
self.current_item = Some(LineItemSpec {
line_number: idx,
obis_code: None,
quantities: Vec::new(),
leistungsperiode: None,
});
}
if let Some(item) = &mut self.current_item {
item.quantities.push(QuantitySpec {
qualifier: qualifier.into(),
value: value.into(),
unit: unit.into(),
period: None,
});
}
self
}
pub fn quantity_for_period(
mut self,
qualifier: impl Into<String>,
value: impl Into<String>,
unit: impl Into<String>,
start: impl Into<String>,
end: impl Into<String>,
) -> Self {
if self.current_item.is_none() {
let idx = self.spec.line_items.len() + 1;
self.current_item = Some(LineItemSpec {
line_number: idx,
obis_code: None,
quantities: Vec::new(),
leistungsperiode: None,
});
}
if let Some(item) = &mut self.current_item {
item.quantities.push(QuantitySpec {
qualifier: qualifier.into(),
value: value.into(),
unit: unit.into(),
period: Some((start.into(), end.into())),
});
}
self
}
pub fn leistungsperiode(mut self, value: impl Into<String>, format: impl Into<String>) -> Self {
if self.current_item.is_none() {
let idx = self.spec.line_items.len() + 1;
self.current_item = Some(LineItemSpec {
line_number: idx,
obis_code: None,
quantities: Vec::new(),
leistungsperiode: None,
});
}
if let Some(item) = &mut self.current_item {
item.leistungsperiode = Some(LeistungsperiodeSpec {
value: value.into(),
format: format.into(),
});
}
self
}
fn flush_item(&mut self) {
if let Some(item) = self.current_item.take() {
self.spec.line_items.push(item);
}
}
pub fn done(mut self) -> MsconsBuilder<S, R> {
self.flush_item();
self.parent.inner.metering_points.push(self.spec);
self.parent
}
}
#[cfg(test)]
mod summenzeitreihe_tests {
use super::*;
use crate::generated::releases;
#[test]
fn a_summenzeitreihe_carries_its_version_and_interval_bounds() {
let wire = MsconsBuilder::new(releases::mscons_fv20261001().clone())
.sender("9900357000004")
.receiver("9900077000006")
.pruefidentifikator(Pruefidentifikator::new(13003).expect("13003 is a valid PID"))
.message_ref("SZR0001")
.metering_point("11YAPG4CTRDNZ--A")
.balancing_period("202606")
.version("20260714050000+00")
.quantity_for_period(
QTY_ENERGIE_SUMMIERT,
"12.5",
"KWH",
"202606010000+00",
"202606010015+00",
)
.done()
.serialize()
.expect("serialize");
let wire = String::from_utf8(wire).expect("utf-8");
assert!(
wire.contains("DTM+492:202606:610"),
"Bilanzierungsmonat must be present: {wire}"
);
assert!(
wire.contains("DTM+293:20260714050000?+00:304"),
"Versionsangabe must be present — it is what marks a correction: {wire}"
);
assert!(
wire.contains("QTY+79:12.5:KWH"),
"quantity must be present: {wire}"
);
assert!(
wire.contains("DTM+163:202606010000?+00:303"),
"interval start must bound the quantity: {wire}"
);
assert!(
wire.contains("DTM+164:202606010015?+00:303"),
"interval end must bound the quantity: {wire}"
);
let qty_at = wire.find("QTY+79").expect("QTY present");
let dtm_at = wire.find("DTM+163").expect("DTM present");
assert!(dtm_at > qty_at, "period must follow its QTY: {wire}");
}
#[test]
fn a_bare_quantity_emits_no_period() {
let wire = MsconsBuilder::new(releases::mscons_fv20261001().clone())
.sender("9900357000004")
.receiver("9900077000006")
.message_ref("M1")
.metering_point("DE0001234567890")
.quantity("220", "42", "KWH")
.done()
.serialize()
.expect("serialize");
let wire = String::from_utf8(wire).expect("utf-8");
assert!(wire.contains("QTY+220:42:KWH"));
assert!(!wire.contains("DTM+163"), "no invented period: {wire}");
}
}