use rust_decimal::Decimal;
use time::{Date, OffsetDateTime};
use crate::reading::{Anomaly, LastgangConfig, MeterReading, consumption_between};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum MeterStatus {
#[default]
Active,
Removed,
Pending,
Deployed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum MeterLifecycleEventType {
Installed,
Replaced,
Removed,
Updated,
Recalibrated,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MeterLifecycleEvent {
pub event_id: String,
pub meter_serial: String,
pub melo_id: String,
pub event_type: MeterLifecycleEventType,
pub occurred_at: OffsetDateTime,
pub reading: Option<Decimal>,
pub obis_code: Option<crate::obis::ObisCode>,
pub reason: Option<String>,
pub triggered_by_pid: Option<u32>,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MeterExchangeEvent {
pub exchange_id: String,
pub melo_id: String,
pub old_meter_serial: String,
pub old_final_reading: Decimal,
pub new_meter_serial: String,
pub new_first_reading: Decimal,
pub exchange_at: OffsetDateTime,
pub exchange_date: Date,
pub triggered_by_pid: Option<u32>,
pub insrpt_process_id: Option<String>,
pub performed_by: Option<String>,
}
impl MeterExchangeEvent {
#[must_use]
pub fn old_final(&self) -> MeterReading {
MeterReading::measured(self.exchange_at, self.old_final_reading)
}
#[must_use]
pub fn new_first(&self) -> MeterReading {
MeterReading::measured(self.exchange_at, self.new_first_reading)
}
pub fn consumption_old_meter(
&self,
period_start: &MeterReading,
config: &LastgangConfig,
) -> Result<Decimal, Anomaly> {
consumption_between(period_start, &self.old_final(), config)
}
pub fn consumption_new_meter(
&self,
period_end: &MeterReading,
config: &LastgangConfig,
) -> Result<Decimal, Anomaly> {
consumption_between(&self.new_first(), period_end, config)
}
pub fn total_consumption(
&self,
period_start: &MeterReading,
period_end: &MeterReading,
config: &LastgangConfig,
) -> Result<Decimal, Anomaly> {
Ok(self.consumption_old_meter(period_start, config)?
+ self.consumption_new_meter(period_end, config)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
use time::macros::{date, datetime};
fn make_exchange() -> MeterExchangeEvent {
MeterExchangeEvent {
exchange_id: "EX-001".to_owned(),
melo_id: "DE000123456789".to_owned(),
old_meter_serial: "OLD-1234".to_owned(),
old_final_reading: dec!(12500),
new_meter_serial: "NEW-5678".to_owned(),
new_first_reading: dec!(0), exchange_at: datetime!(2026-06-15 8:00 UTC),
exchange_date: date!(2026 - 06 - 15),
triggered_by_pid: Some(23003),
insrpt_process_id: None,
performed_by: Some("MSB-9900357000004".to_owned()),
}
}
fn start(value: Decimal) -> MeterReading {
MeterReading::measured(datetime!(2026-06-01 0:00 UTC), value)
}
fn end(value: Decimal) -> MeterReading {
MeterReading::measured(datetime!(2026-06-30 0:00 UTC), value)
}
#[test]
fn consumption_split_across_exchange() {
let ex = make_exchange();
let cfg = LastgangConfig::default();
assert_eq!(
ex.consumption_old_meter(&start(dec!(12000)), &cfg),
Ok(dec!(500))
);
assert_eq!(
ex.consumption_new_meter(&end(dec!(800)), &cfg),
Ok(dec!(800))
);
assert_eq!(
ex.total_consumption(&start(dec!(12000)), &end(dec!(800)), &cfg),
Ok(dec!(1300))
);
}
#[test]
fn a_wrapped_old_register_is_reconstructed_not_zeroed() {
use crate::reading::AnomalyKind;
let mut ex = make_exchange();
ex.old_final_reading = dec!(300); let period_start = start(dec!(999_500));
let blind = ex.consumption_old_meter(&period_start, &LastgangConfig::default());
assert_eq!(
blind.unwrap_err().kind,
AnomalyKind::BackwardsWithoutRegisterWidth
);
let cfg = LastgangConfig::default().with_register_digits(6);
assert_eq!(ex.consumption_old_meter(&period_start, &cfg), Ok(dec!(800)));
}
#[test]
fn a_backwards_reading_is_reported_rather_than_zeroed() {
let ex = make_exchange();
let err = ex
.consumption_old_meter(&start(dec!(13000)), &LastgangConfig::default())
.unwrap_err();
assert!(err.to_string().contains("register decreased"), "{err}");
}
#[test]
fn the_total_fails_when_either_side_does() {
let ex = make_exchange();
let cfg = LastgangConfig::default();
assert!(
ex.total_consumption(&start(dec!(13000)), &end(dec!(800)), &cfg)
.is_err()
);
assert!(
ex.total_consumption(&start(dec!(12000)), &end(dec!(-5)), &cfg)
.is_err()
);
}
}