1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
//! GPKE Strom answer Fristen — the table itself lives in
//! [`mako_fristen::antwort`], and what stays here is the check that it
//! agrees with these workflows.
//!
//! The table is data: a PID, a window shape and a Fundstelle. Beside the
//! workflows it would sit above `mako-engine`, so no crate could hold all four
//! families and each service would aggregate them itself.
//!
//! What belongs beside a workflow is the cross-check: every trigger is a PID
//! this crate spawns from, and the answer PIDs match `response_pid_for`'s own
//! derivation. Those tests are below.
pub use mako_fristen::antwort::{AntwortObligation, FristShape, GPKE as ANTWORT_OBLIGATIONS};
use mako_fristen::antwort;
use time::OffsetDateTime;
/// The answer obligation for an inbound GPKE Strom PID, if the Festlegung
/// states one.
#[must_use]
pub fn antwort_obligation(trigger_pid: u32) -> Option<&'static AntwortObligation> {
antwort::antwort_obligation(trigger_pid).filter(|o| o.family == antwort::Family::Gpke)
}
/// The instant by which the answer to `trigger_pid` must have been sent.
///
/// `received` is the arrival instant of the inbound message (the ÜT). Returns
/// `None` for a PID not in [`ANTWORT_OBLIGATIONS`] — treat that as *unknown*,
/// never as *unbounded*.
#[must_use]
pub fn antwort_deadline(trigger_pid: u32, received: OffsetDateTime) -> Option<OffsetDateTime> {
antwort_obligation(trigger_pid).map(|o| {
o.frist
.due_at(received, mako_fristen::HolidayCalendar::BdewMaKo)
})
}
#[cfg(test)]
mod tests {
use super::*;
use time::{Date, Month, Time};
fn utc(y: i32, m: Month, d: u8, h: u8) -> OffsetDateTime {
OffsetDateTime::new_utc(
Date::from_calendar_date(y, m, d).expect("valid date"),
Time::from_hms(h, 0, 0).expect("valid time"),
)
}
/// Every listed trigger must be an inbound PID of a registered workflow —
/// `makod` only emits `process.initiated` for messages it spawns from, so a
/// table entry keyed on an answer PID would describe a clock that never
/// starts.
///
/// This is the check the table cannot make about itself, which is why it
/// stayed here when the data moved to `mako-fristen`.
#[test]
fn every_trigger_is_an_inbound_pid() {
let inbound: std::collections::BTreeSet<u32> = crate::UTILMD_ANFRAGE_PIDS
.iter()
.copied()
.chain(crate::LF_ABMELDUNG_PIDS.iter().copied())
.chain(crate::BEENDIGUNG_ZUORDNUNG_PIDS.iter().copied())
.chain(crate::kuendigung::KUENDIGUNG_PIDS.iter().copied())
.chain(crate::eog::EOG_PIDS.iter().copied())
.chain(crate::neuanlage::NEUANLAGE_PIDS.iter().copied())
.chain(
crate::ankuendigung_zuordnung_lf::ANKUENDIGUNG_ZUORDNUNG_PIDS
.iter()
.copied(),
)
.chain(
crate::abrechnungsdaten::ABRECHNUNGSDATEN_PIDS
.iter()
.copied(),
)
.chain(std::iter::once(
crate::anfrage_bestellung::ANFRAGE_PID.as_u32(),
))
.chain(crate::sperrung::SPERRUNG_PIDS.iter().copied())
.chain(crate::sperrung::ORDCHG_STORNIERUNG_PIDS.iter().copied())
.chain(
crate::stammdatenaenderung::STAMMDATEN_PAIRS
.iter()
.map(|(aenderung, _, _)| *aenderung),
)
.collect();
// REQOTE 35004 „Anfrage einer Konfiguration" states its window in GPKE
// Teil 3, so it belongs to the GPKE *family*, but the workflow that
// spawns from it is `wim-preisanfrage` in `mako-wim` — this crate has
// no REQOTE workflow to list. The Family says which Festlegung states
// the number, not which crate owns the process.
const ROUTED_ELSEWHERE: &[u32] = &[35_004];
for o in ANTWORT_OBLIGATIONS {
if ROUTED_ELSEWHERE.contains(&o.trigger_pid) {
continue;
}
assert!(
inbound.contains(&o.trigger_pid),
"{} ({}) is not an inbound PID of any registered workflow",
o.trigger_pid,
o.name
);
}
}
/// A trigger must never be an **answer** PID: an answer discharges a Frist,
/// it does not start one, and `makod` never spawns a process from it.
#[test]
fn no_trigger_is_an_answer_pid() {
let answers: std::collections::BTreeSet<u32> = ANTWORT_OBLIGATIONS
.iter()
.flat_map(|o| [o.antwort_pids.0, o.antwort_pids.1])
.collect();
for o in ANTWORT_OBLIGATIONS {
assert!(
!answers.contains(&o.trigger_pid),
"{} ({}) is an answer PID of another obligation",
o.trigger_pid,
o.name
);
}
}
/// The answer PIDs must agree with the workflow's own derivation, which is
/// what actually goes on the wire.
#[test]
fn answer_pids_match_the_workflow_derivation() {
for o in ANTWORT_OBLIGATIONS {
let Some(accept) = crate::wechselprozesse::response_pid_for(o.trigger_pid, true) else {
continue; // not a supplier-change PID
};
let reject = crate::wechselprozesse::response_pid_for(o.trigger_pid, false)
.expect("a PID with a positive answer has a negative one");
assert_eq!(
(accept.as_u32(), reject.as_u32()),
o.antwort_pids,
"answer PIDs for {} disagree with response_pid_for",
o.trigger_pid
);
}
}
/// The GPKE view is the GPKE table and nothing else: a Gas or WiM PID must
/// not resolve through this module.
#[test]
fn the_gpke_view_excludes_the_other_families() {
for foreign in [44_001_u32, 55_039, 44_042, 35_001] {
assert!(
antwort_obligation(foreign).is_none(),
"PID {foreign} is not GPKE"
);
}
}
/// A Friday Anmeldung is answerable on Monday morning, not on Saturday
/// afternoon.
#[test]
fn a_friday_anmeldung_is_due_monday_1100_berlin() {
let due = antwort_deadline(55_001, utc(2025, Month::January, 10, 13)).expect("known PID");
assert_eq!(
due.date(),
Date::from_calendar_date(2025, Month::January, 13).expect("valid date")
);
assert_eq!(due.to_offset(time::UtcOffset::UTC).hour(), 10);
}
/// The Abmeldung window (06:00) is strictly tighter than the Anmeldung one
/// (11:00) for the same arrival — sizing both alike loses five hours on the
/// one that matters.
#[test]
fn the_abmeldung_window_is_tighter_than_the_anmeldung_window() {
let received = utc(2025, Month::January, 13, 8);
assert!(
antwort_deadline(55_004, received) < antwort_deadline(55_001, received),
"55004 is due 06:00, 55001 is due 11:00 of the same Werktag"
);
}
/// An unlisted PID is unknown, not unbounded.
#[test]
fn an_unlisted_pid_has_no_deadline() {
assert!(antwort_deadline(55_699, OffsetDateTime::now_utc()).is_none());
}
}