Skip to main content

mako_emob/
allocation.rs

1//! **The conservation invariant** — Anlage 6 §IV.1, executable.
2//!
3//! ```text
4//! NGZ(t, richtung) = Σ zugeordnete Marktlokationen + Deltamenge
5//! ```
6//!
7//! exactly, for every quarter hour, every direction and every version. The
8//! Netzgangzeitreihe is what the VNB measured at the Übergabestelle; the parts
9//! are what the LPB claims for each supplier's Bilanzkreis; the Deltamenge is
10//! the remainder, which Anlage 6 §IV.2 books to a Bilanzkreis the LPB names, at
11//! its own cost.
12//!
13//! # The Deltamenge is a quantity, not a rounding error
14//!
15//! It has a Bilanzkreis, it settles in money, and it is the LPB's exposure —
16//! an unmetered draw, a session whose CDR has not arrived, the six-decimal cut
17//! of a proportional split all land in it. Hence a field on
18//! [`QuarterHourAllocation`] and a returned [`ConservationProof`].
19//!
20//! # Two shapes of the same call
21//!
22//! | Case | What happens | Delta |
23//! |---|---|---|
24//! | claims **under** the NGZ | every claim is met in full | `NGZ − Σ claims` |
25//! | claims **over** the NGZ | every claim is cut back in proportion | zero up to the six-decimal cut, [`Ueberdeckung`] recorded |
26//!
27//! Both come out of one `metering::allocation::allocate` with each part capped
28//! at its own claim. Over-claim is real — generation behind the Netzanschluss
29//! feeds the charge points — and neither Anlage 6 nor the AWH resolves it;
30//! proportional cut-back is the default, recorded on the row because routine
31//! over-claim is a metering fault, not a rounding one.
32//!
33//! # Directions never net
34//!
35//! Bezug and Einspeisung settle as separate series: netting them inside a
36//! quarter hour would let a V2G discharge cancel a neighbour's draw, and both
37//! would leave their suppliers' Bilanzkreise. [`Richtung`] is part of the key
38//! and each direction carries its own non-negative pool.
39
40use rust_decimal::Decimal;
41use serde::{Deserialize, Serialize};
42use time::OffsetDateTime;
43
44use metering::allocation::{AllocationBasis, AllocationPart, allocate};
45
46use crate::error::EmobError;
47use crate::ids::VirtualMaloId;
48use crate::session::Viertelstunde;
49
50pub use mako_mabis::Datenstatus;
51
52/// Which way the energy flowed across the Übergabestelle.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
54#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
55pub enum Richtung {
56    /// Energy drawn from the VNB's grid — charging.
57    Bezug,
58    /// Energy fed back into it — V2G discharge, or local generation exported.
59    ///
60    /// No published Zeitreihentyp covers an Einspeisungs-BK-SZR eMob, so a
61    /// deployment holds these rows until its BIKO names one. Modelling the
62    /// direction is nevertheless not optional: without it the two flows net.
63    Einspeisung,
64}
65
66/// What a virtual Marktlokation is for.
67///
68/// The last two exist so that energy nobody recognised still reaches a real
69/// supplier's Bilanzkreis instead of the Deltamenge. Anlage 6 §IV.1 obliges the
70/// LPB to assign the whole BG; letting station losses and unknown tokens fall
71/// through to the Delta-BK would satisfy the arithmetic while defeating the
72/// obligation.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
74#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
75pub enum MaloKind {
76    /// One vehicle or one driver's contract.
77    Vehicle,
78    /// A device that is not a vehicle — a stationary battery, a heat pump
79    /// behind the same Übergabestelle.
80    Device,
81    /// A household or Kundenanlage under the BK6-24-267 access path.
82    Household,
83    /// The station's own consumption: standby, lighting, cooling, cable losses.
84    ///
85    /// A real Marktlokation with a real supplier — usually the operator's own.
86    Betriebsstrom,
87    /// Energy drawn on a token no registry recognised.
88    ///
89    /// Also a real Marktlokation with a real supplier: the „Residualstrom"
90    /// contract every operating model in the market carries. Not the Delta.
91    Residual,
92}
93
94/// One claim on a quarter hour: this virtual Marktlokation drew this much.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct Anspruch {
97    /// Whose claim.
98    pub malo: VirtualMaloId,
99    /// What it is for.
100    pub kind: MaloKind,
101    /// Claimed energy in kWh. Must not be negative.
102    pub kwh: Decimal,
103}
104
105/// One virtual Marktlokation's settled share of a quarter hour.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct Zuordnung {
108    /// Whose share.
109    pub malo: VirtualMaloId,
110    /// What it is for.
111    pub kind: MaloKind,
112    /// What was claimed.
113    pub anspruch_kwh: Decimal,
114    /// What was actually assigned — at most the claim.
115    pub kwh: Decimal,
116}
117
118impl Zuordnung {
119    /// `true` when the claim was cut back because the quarter hour was
120    /// over-claimed.
121    #[must_use]
122    pub fn gekuerzt(&self) -> bool {
123        self.kwh < self.anspruch_kwh
124    }
125}
126
127/// Recorded when the claims exceeded what the Netzgangzeitreihe delivered.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct Ueberdeckung {
130    /// What the claims added up to.
131    pub anspruch_kwh: Decimal,
132    /// What the NGZ delivered.
133    pub ngz_kwh: Decimal,
134}
135
136impl Ueberdeckung {
137    /// How much more was claimed than arrived.
138    #[must_use]
139    pub fn ueberhang_kwh(&self) -> Decimal {
140        self.anspruch_kwh - self.ngz_kwh
141    }
142}
143
144/// Proof that Anlage 6 §IV.1 holds for one quarter hour.
145///
146/// Returned rather than asserted, so a caller can file it beside the allocation
147/// and an auditor can re-check it without re-running the engine.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct ConservationProof {
150    /// The measured Netzgangzeitreihe value.
151    pub ngz_kwh: Decimal,
152    /// What the parts add up to.
153    pub zugeordnet_kwh: Decimal,
154    /// The Deltamenge.
155    pub delta_kwh: Decimal,
156}
157
158impl ConservationProof {
159    /// `true` when `zugeordnet + delta == ngz` exactly.
160    #[must_use]
161    pub fn haelt(&self) -> bool {
162        self.zugeordnet_kwh + self.delta_kwh == self.ngz_kwh
163    }
164}
165
166/// One quarter hour of one Übergabestelle, allocated.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct QuarterHourAllocation {
169    /// The quarter hour.
170    pub slot: Viertelstunde,
171    /// Which direction this row settles.
172    pub richtung: Richtung,
173    /// The Netzgangzeitreihe value the VNB measured.
174    pub ngz_kwh: Decimal,
175    /// Every virtual Marktlokation's share, in input order.
176    pub zuordnungen: Vec<Zuordnung>,
177    /// The Deltamenge — what no Marktlokation claimed.
178    pub delta_kwh: Decimal,
179    /// Present only when the quarter hour was over-claimed.
180    pub ueberdeckung: Option<Ueberdeckung>,
181    /// The conservation identity, ready to file.
182    pub proof: ConservationProof,
183}
184
185impl QuarterHourAllocation {
186    /// Allocate one quarter hour.
187    ///
188    /// # Errors
189    ///
190    /// [`EmobError::Allocation`] when `ngz_kwh` or any claim is negative — a
191    /// reverse flow is [`Richtung::Einspeisung`], not a negative Bezug;
192    /// [`EmobError::DoppelterAnspruch`] when one virtual Marktlokation claims
193    /// the same quarter hour twice; and [`EmobError::ErhaltungVerletzt`] if the
194    /// identity somehow fails, which is a bug in this crate rather than a
195    /// caller condition.
196    pub fn allocate(
197        slot: Viertelstunde,
198        richtung: Richtung,
199        ngz_kwh: Decimal,
200        ansprueche: &[Anspruch],
201    ) -> Result<Self, EmobError> {
202        if ngz_kwh < Decimal::ZERO {
203            return Err(EmobError::Allocation(format!(
204                "the Netzgangzeitreihe value {ngz_kwh} is negative; settle a reverse flow as \
205                 Richtung::Einspeisung"
206            )));
207        }
208        if let Some(bad) = ansprueche.iter().find(|a| a.kwh < Decimal::ZERO) {
209            return Err(EmobError::Allocation(format!(
210                "virtual Marktlokation {} claims negative energy {}",
211                bad.malo, bad.kwh
212            )));
213        }
214        // One row per Marktlokation, because one Marktlokation is one
215        // Bilanzkreis-Zuordnung. Summing two claims silently would be the
216        // friendlier default and the wrong one: the reason a MaLo appears
217        // twice (two sessions, or the same CDR ingested twice) is knowable
218        // upstream and not here.
219        let mut gesehen = std::collections::BTreeSet::new();
220        if let Some(dup) = ansprueche.iter().find(|a| !gesehen.insert(&a.malo)) {
221            return Err(EmobError::DoppelterAnspruch {
222                malo: dup.malo.to_string(),
223            });
224        }
225
226        let anspruch_sum: Decimal = ansprueche.iter().map(|a| a.kwh).sum();
227
228        let parts: Vec<AllocationPart> = ansprueche
229            .iter()
230            .map(|a| AllocationPart::new(a.malo.as_str(), a.kwh).capped_at(a.kwh))
231            .collect();
232
233        let row = allocate(ngz_kwh, parts, AllocationBasis::Proportional)?;
234
235        let zuordnungen: Vec<Zuordnung> = ansprueche
236            .iter()
237            .zip(row.parts.iter())
238            .map(|(a, p)| Zuordnung {
239                malo: a.malo.clone(),
240                kind: a.kind,
241                anspruch_kwh: a.kwh,
242                kwh: p.allocated,
243            })
244            .collect();
245
246        let zugeordnet_kwh: Decimal = zuordnungen.iter().map(|z| z.kwh).sum();
247        let delta_kwh = row.residual;
248
249        let proof = ConservationProof {
250            ngz_kwh,
251            zugeordnet_kwh,
252            delta_kwh,
253        };
254        if !proof.haelt() {
255            return Err(EmobError::ErhaltungVerletzt {
256                slot: slot.start().to_string(),
257                ngz: ngz_kwh,
258                summe: zugeordnet_kwh,
259                delta: delta_kwh,
260            });
261        }
262
263        let ueberdeckung = (anspruch_sum > ngz_kwh).then_some(Ueberdeckung {
264            anspruch_kwh: anspruch_sum,
265            ngz_kwh,
266        });
267
268        Ok(Self {
269            slot,
270            richtung,
271            ngz_kwh,
272            zuordnungen,
273            delta_kwh,
274            ueberdeckung,
275            proof,
276        })
277    }
278
279    /// The share that reached a given kind of Marktlokation.
280    #[must_use]
281    pub fn kwh_of_kind(&self, kind: MaloKind) -> Decimal {
282        self.zuordnungen
283            .iter()
284            .filter(|z| z.kind == kind)
285            .map(|z| z.kwh)
286            .sum()
287    }
288}
289
290/// One filing of an allocation, versioned the way MaBiS versions everything.
291///
292/// MaBiS Kap. 3.8.2 keys versions on the **Erstellungszeitpunkt** — a 17-char
293/// timestamp, never an integer — and pairs it with a [`Datenstatus`]. Both are
294/// reused from [`mako_mabis`] rather than restated, so a Modell-2 filing and an
295/// ordinary Summenzeitreihe filing cannot disagree about what „final" means.
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297pub struct AllocationVersion {
298    /// When this version was formed.
299    #[serde(with = "time::serde::rfc3339")]
300    pub erstellungszeitpunkt: OffsetDateTime,
301    /// Its MaBiS Datenstatus.
302    pub datenstatus: Datenstatus,
303    /// The quarter hours it covers.
304    pub rows: Vec<QuarterHourAllocation>,
305}
306
307impl AllocationVersion {
308    /// The total Deltamenge across every quarter hour and direction.
309    ///
310    /// The LPB's exposure for this version, in kWh.
311    #[must_use]
312    pub fn delta_kwh(&self) -> Decimal {
313        self.rows.iter().map(|r| r.delta_kwh).sum()
314    }
315
316    /// Every quarter hour whose claims exceeded the Netzgangzeitreihe.
317    pub fn ueberdeckungen(&self) -> impl Iterator<Item = &QuarterHourAllocation> {
318        self.rows.iter().filter(|r| r.ueberdeckung.is_some())
319    }
320
321    /// `true` when the conservation identity holds for every row.
322    #[must_use]
323    pub fn erhaltung_haelt(&self) -> bool {
324        self.rows.iter().all(|r| r.proof.haelt())
325    }
326
327    /// `true` when the Bilanzierungsmonat this version belongs to has settled.
328    ///
329    /// „Abgerechnete Daten" and „Abgerechnete Daten KBKA" have reached their
330    /// Abrechnungsstichtag. Delegated to [`Datenstatus::ist_abgerechnet`]
331    /// rather than restated: a second copy of the code list is a copy that can
332    /// drift from the one `mako-mabis` publishes.
333    #[must_use]
334    pub fn ist_final(&self) -> bool {
335        self.datenstatus.ist_abgerechnet()
336    }
337}
338
339/// Every version filed for one Bilanzierungsmonat, in filing order.
340///
341/// The invariants below are properties of the *sequence*, which no single
342/// [`AllocationVersion`] can state about itself — the same reason
343/// [`crate::bg::BgRegistry`] exists beside
344/// [`crate::bg::VirtualBalancingArea`].
345///
346/// | Invariant | Source |
347/// |---|---|
348/// | a version is immutable once filed; a correction is a **new** version | MaBiS Kap. 3.8.2 |
349/// | the Erstellungszeitpunkt strictly increases | MaBiS Kap. 3.8.2 — versions are *keyed* on it, so two filings sharing one are indistinguishable |
350/// | nothing follows an „abgerechnet" version | MaBiS Kap. 3.10 Tabelle 2 |
351/// | nothing is filed after the end of month M+7 | MaBiS Kap. 3.10 |
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353pub struct Versionsreihe {
354    /// The last calendar day of the Bilanzierungsmonat. Stored as the date
355    /// rather than as a [`mako_mabis::Bilanzierungsmonat`] because a series is
356    /// persisted and that type is a computed view over exactly this value.
357    monatsende: time::Date,
358    versionen: Vec<AllocationVersion>,
359}
360
361impl Versionsreihe {
362    /// An empty series for the Bilanzierungsmonat containing `tag`.
363    #[must_use]
364    pub fn fuer(tag: time::Date) -> Self {
365        Self {
366            monatsende: mako_mabis::Bilanzierungsmonat::enthaltend(tag).monatsende(),
367            versionen: Vec::new(),
368        }
369    }
370
371    /// The Bilanzierungsmonat this series settles.
372    #[must_use]
373    pub fn monat(&self) -> mako_mabis::Bilanzierungsmonat {
374        mako_mabis::Bilanzierungsmonat::new(self.monatsende)
375    }
376
377    /// The last day a correction may be filed — the end of month M+7
378    /// (MaBiS Kap. 3.10).
379    #[must_use]
380    pub fn korrekturfrist(&self) -> time::Date {
381        crate::fristen::korrekturfrist(self.monat())
382    }
383
384    /// Every version filed so far, oldest first.
385    pub fn iter(&self) -> impl Iterator<Item = &AllocationVersion> {
386        self.versionen.iter()
387    }
388
389    /// The version that would settle today — the most recently filed one.
390    #[must_use]
391    pub fn aktuell(&self) -> Option<&AllocationVersion> {
392        self.versionen.last()
393    }
394
395    /// File `version`, as of `eingang`.
396    ///
397    /// # Errors
398    ///
399    /// - [`EmobError::VersionIstFinal`] when the series has already settled.
400    /// - [`EmobError::KorrekturfristAbgelaufen`] when `eingang` is past the
401    ///   end of month M+7.
402    /// - [`EmobError::Allocation`] when the Erstellungszeitpunkt does not
403    ///   advance, or when the version's conservation identity does not hold —
404    ///   a version that fails Anlage 6 §IV.1 is not a version to file.
405    pub fn einreichen(
406        &mut self,
407        version: AllocationVersion,
408        eingang: time::Date,
409    ) -> Result<(), EmobError> {
410        if let Some(letzte) = self.versionen.last() {
411            if letzte.ist_final() {
412                return Err(EmobError::VersionIstFinal {
413                    erstellungszeitpunkt: letzte.erstellungszeitpunkt.to_string(),
414                });
415            }
416            if version.erstellungszeitpunkt <= letzte.erstellungszeitpunkt {
417                return Err(EmobError::Allocation(format!(
418                    "Erstellungszeitpunkt {} does not advance on the filed {}; MaBiS keys \
419                     versions on it",
420                    version.erstellungszeitpunkt, letzte.erstellungszeitpunkt
421                )));
422            }
423        }
424        let frist = self.korrekturfrist();
425        if eingang > frist {
426            return Err(EmobError::KorrekturfristAbgelaufen {
427                monat: format!(
428                    "{}-{:02}",
429                    self.monatsende.year(),
430                    u8::from(self.monatsende.month())
431                ),
432                frist,
433                eingang,
434            });
435        }
436        if !version.erhaltung_haelt() {
437            return Err(EmobError::Allocation(
438                "a version whose conservation identity fails cannot be filed (Anlage 6 §IV.1)"
439                    .to_owned(),
440            ));
441        }
442        self.versionen.push(version);
443        Ok(())
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use rust_decimal::dec;
451    use time::macros::datetime;
452
453    fn slot() -> Viertelstunde {
454        Viertelstunde::containing(datetime!(2026-11-03 08:00:00 UTC))
455    }
456
457    fn a(id: &str, kind: MaloKind, kwh: Decimal) -> Anspruch {
458        Anspruch {
459            malo: VirtualMaloId::new(id).unwrap(),
460            kind,
461            kwh,
462        }
463    }
464
465    #[test]
466    fn an_underclaimed_quarter_hour_puts_the_rest_in_the_delta() {
467        let r = QuarterHourAllocation::allocate(
468            slot(),
469            Richtung::Bezug,
470            dec!(12),
471            &[
472                a("veh-1", MaloKind::Vehicle, dec!(6)),
473                a("veh-2", MaloKind::Vehicle, dec!(3)),
474            ],
475        )
476        .unwrap();
477        assert_eq!(r.zuordnungen[0].kwh, dec!(6));
478        assert_eq!(r.zuordnungen[1].kwh, dec!(3));
479        assert_eq!(r.delta_kwh, dec!(3));
480        assert!(r.ueberdeckung.is_none());
481        assert!(r.proof.haelt());
482    }
483
484    #[test]
485    fn an_overclaimed_quarter_hour_cuts_back_proportionally_and_says_so() {
486        let r = QuarterHourAllocation::allocate(
487            slot(),
488            Richtung::Bezug,
489            dec!(10),
490            &[
491                a("veh-1", MaloKind::Vehicle, dec!(10)),
492                a("veh-2", MaloKind::Vehicle, dec!(10)),
493            ],
494        )
495        .unwrap();
496        assert_eq!(r.zuordnungen[0].kwh, dec!(5));
497        assert_eq!(r.zuordnungen[1].kwh, dec!(5));
498        assert_eq!(r.delta_kwh, Decimal::ZERO);
499        let u = r.ueberdeckung.expect("recorded, never silent");
500        assert_eq!(u.ueberhang_kwh(), dec!(10));
501        assert!(r.zuordnungen.iter().all(Zuordnung::gekuerzt));
502        assert!(r.proof.haelt());
503    }
504
505    /// Station losses and unknown tokens reach a supplier, not the Delta.
506    #[test]
507    fn betriebsstrom_and_residual_are_ordinary_claims() {
508        let r = QuarterHourAllocation::allocate(
509            slot(),
510            Richtung::Bezug,
511            dec!(10),
512            &[
513                a("veh-1", MaloKind::Vehicle, dec!(7)),
514                a("station-1", MaloKind::Betriebsstrom, dec!(1)),
515                a("residual", MaloKind::Residual, dec!(2)),
516            ],
517        )
518        .unwrap();
519        assert_eq!(r.delta_kwh, Decimal::ZERO);
520        assert_eq!(r.kwh_of_kind(MaloKind::Betriebsstrom), dec!(1));
521        assert_eq!(r.kwh_of_kind(MaloKind::Residual), dec!(2));
522        assert_eq!(r.kwh_of_kind(MaloKind::Vehicle), dec!(7));
523    }
524
525    /// The whole quarter hour becomes Delta when nobody claims it.
526    #[test]
527    fn no_claims_means_the_whole_slot_is_delta() {
528        let r = QuarterHourAllocation::allocate(slot(), Richtung::Bezug, dec!(4), &[]).unwrap();
529        assert_eq!(r.delta_kwh, dec!(4));
530        assert!(r.proof.haelt());
531    }
532
533    #[test]
534    fn a_zero_ngz_allocates_nothing() {
535        let r = QuarterHourAllocation::allocate(
536            slot(),
537            Richtung::Bezug,
538            Decimal::ZERO,
539            &[a("veh-1", MaloKind::Vehicle, dec!(5))],
540        )
541        .unwrap();
542        assert_eq!(r.zuordnungen[0].kwh, Decimal::ZERO);
543        assert_eq!(r.delta_kwh, Decimal::ZERO);
544        assert!(r.proof.haelt());
545    }
546
547    /// Thirds do not divide, and the identity still has to hold exactly.
548    #[test]
549    fn conservation_survives_a_non_terminating_share() {
550        let r = QuarterHourAllocation::allocate(
551            slot(),
552            Richtung::Bezug,
553            dec!(10),
554            &[
555                a("v1", MaloKind::Vehicle, dec!(10)),
556                a("v2", MaloKind::Vehicle, dec!(10)),
557                a("v3", MaloKind::Vehicle, dec!(10)),
558            ],
559        )
560        .unwrap();
561        assert!(r.proof.haelt());
562        let sum: Decimal = r.zuordnungen.iter().map(|z| z.kwh).sum();
563        assert_eq!(sum + r.delta_kwh, dec!(10));
564    }
565
566    /// One Marktlokation is one Bilanzkreis-Zuordnung, so two claims for it
567    /// are a caller bug rather than a sum.
568    #[test]
569    fn a_marktlokation_may_not_claim_the_same_slot_twice() {
570        let e = QuarterHourAllocation::allocate(
571            slot(),
572            Richtung::Bezug,
573            dec!(10),
574            &[
575                a("veh-1", MaloKind::Vehicle, dec!(4)),
576                a("veh-1", MaloKind::Vehicle, dec!(3)),
577            ],
578        )
579        .unwrap_err();
580        assert!(matches!(e, EmobError::DoppelterAnspruch { .. }), "{e:?}");
581    }
582
583    #[test]
584    fn negative_inputs_are_refused() {
585        assert!(QuarterHourAllocation::allocate(slot(), Richtung::Bezug, dec!(-1), &[]).is_err());
586        assert!(
587            QuarterHourAllocation::allocate(
588                slot(),
589                Richtung::Bezug,
590                dec!(1),
591                &[a("v1", MaloKind::Vehicle, dec!(-1))]
592            )
593            .is_err()
594        );
595    }
596
597    /// Directions are separate pools and never net against each other.
598    #[test]
599    fn the_two_directions_are_settled_apart() {
600        let bezug = QuarterHourAllocation::allocate(
601            slot(),
602            Richtung::Bezug,
603            dec!(10),
604            &[a("v1", MaloKind::Vehicle, dec!(10))],
605        )
606        .unwrap();
607        let einspeisung = QuarterHourAllocation::allocate(
608            slot(),
609            Richtung::Einspeisung,
610            dec!(4),
611            &[a("v1", MaloKind::Vehicle, dec!(4))],
612        )
613        .unwrap();
614        assert_eq!(bezug.zuordnungen[0].kwh, dec!(10));
615        assert_eq!(einspeisung.zuordnungen[0].kwh, dec!(4));
616        assert_ne!(bezug.richtung, einspeisung.richtung);
617    }
618
619    #[test]
620    fn a_version_totals_its_delta_and_flags_its_overclaims() {
621        let good = QuarterHourAllocation::allocate(
622            slot(),
623            Richtung::Bezug,
624            dec!(12),
625            &[a("v1", MaloKind::Vehicle, dec!(9))],
626        )
627        .unwrap();
628        let over = QuarterHourAllocation::allocate(
629            slot().next(),
630            Richtung::Bezug,
631            dec!(5),
632            &[a("v1", MaloKind::Vehicle, dec!(8))],
633        )
634        .unwrap();
635        let v = AllocationVersion {
636            erstellungszeitpunkt: datetime!(2026-11-04 09:00:00 UTC),
637            datenstatus: Datenstatus::Pruefdaten,
638            rows: vec![good, over],
639        };
640        assert_eq!(v.delta_kwh(), dec!(3));
641        assert_eq!(v.ueberdeckungen().count(), 1);
642        assert!(v.erhaltung_haelt());
643        assert!(!v.ist_final());
644    }
645
646    fn version(stamp: OffsetDateTime, status: Datenstatus) -> AllocationVersion {
647        AllocationVersion {
648            erstellungszeitpunkt: stamp,
649            datenstatus: status,
650            rows: vec![
651                QuarterHourAllocation::allocate(
652                    slot(),
653                    Richtung::Bezug,
654                    dec!(10),
655                    &[a("v1", MaloKind::Vehicle, dec!(6))],
656                )
657                .unwrap(),
658            ],
659        }
660    }
661
662    fn tag(y: i32, m: u8, d: u8) -> time::Date {
663        time::Date::from_calendar_date(y, time::Month::try_from(m).unwrap(), d).unwrap()
664    }
665
666    #[test]
667    fn a_series_takes_corrections_until_the_month_settles() {
668        let mut reihe = Versionsreihe::fuer(tag(2026, 11, 15));
669        assert_eq!(reihe.korrekturfrist(), tag(2027, 6, 30));
670
671        reihe
672            .einreichen(
673                version(datetime!(2026-12-05 09:00:00 UTC), Datenstatus::Pruefdaten),
674                tag(2026, 12, 5),
675            )
676            .unwrap();
677        reihe
678            .einreichen(
679                version(
680                    datetime!(2027-01-08 09:00:00 UTC),
681                    Datenstatus::Abrechnungsdaten,
682                ),
683                tag(2027, 1, 8),
684            )
685            .unwrap();
686        assert_eq!(reihe.iter().count(), 2);
687        assert_eq!(
688            reihe.aktuell().unwrap().datenstatus,
689            Datenstatus::Abrechnungsdaten
690        );
691    }
692
693    /// „Abgerechnet" closes the series; a later correction is not a filing.
694    #[test]
695    fn nothing_follows_an_abgerechnete_version() {
696        let mut reihe = Versionsreihe::fuer(tag(2026, 11, 15));
697        reihe
698            .einreichen(
699                version(
700                    datetime!(2027-01-08 09:00:00 UTC),
701                    Datenstatus::AbgerechneteDaten,
702                ),
703                tag(2027, 1, 8),
704            )
705            .unwrap();
706        let e = reihe
707            .einreichen(
708                version(datetime!(2027-02-08 09:00:00 UTC), Datenstatus::Pruefdaten),
709                tag(2027, 2, 8),
710            )
711            .unwrap_err();
712        assert!(matches!(e, EmobError::VersionIstFinal { .. }), "{e:?}");
713    }
714
715    #[test]
716    fn a_filing_past_month_seven_is_refused() {
717        let mut reihe = Versionsreihe::fuer(tag(2026, 11, 15));
718        let e = reihe
719            .einreichen(
720                version(datetime!(2027-07-01 09:00:00 UTC), Datenstatus::Pruefdaten),
721                tag(2027, 7, 1),
722            )
723            .unwrap_err();
724        match e {
725            EmobError::KorrekturfristAbgelaufen { monat, frist, .. } => {
726                assert_eq!(monat, "2026-11");
727                assert_eq!(frist, tag(2027, 6, 30));
728            }
729            other => panic!("{other:?}"),
730        }
731    }
732
733    /// MaBiS keys versions on the Erstellungszeitpunkt, so two filings may not
734    /// share one.
735    #[test]
736    fn the_erstellungszeitpunkt_has_to_advance() {
737        let mut reihe = Versionsreihe::fuer(tag(2026, 11, 15));
738        let stamp = datetime!(2026-12-05 09:00:00 UTC);
739        reihe
740            .einreichen(version(stamp, Datenstatus::Pruefdaten), tag(2026, 12, 5))
741            .unwrap();
742        assert!(
743            reihe
744                .einreichen(version(stamp, Datenstatus::Pruefdaten), tag(2026, 12, 5))
745                .is_err()
746        );
747    }
748
749    #[test]
750    fn settled_versions_are_final() {
751        for status in [
752            Datenstatus::AbgerechneteDaten,
753            Datenstatus::AbgerechneteDatenKbka,
754        ] {
755            let v = AllocationVersion {
756                erstellungszeitpunkt: datetime!(2026-11-04 09:00:00 UTC),
757                datenstatus: status,
758                rows: Vec::new(),
759            };
760            assert!(v.ist_final(), "{status:?}");
761        }
762        for status in [
763            Datenstatus::Pruefdaten,
764            Datenstatus::Abrechnungsdaten,
765            Datenstatus::AbrechnungsdatenKbka,
766        ] {
767            let v = AllocationVersion {
768                erstellungszeitpunkt: datetime!(2026-11-04 09:00:00 UTC),
769                datenstatus: status,
770                rows: Vec::new(),
771            };
772            assert!(!v.ist_final(), "{status:?}");
773        }
774    }
775}