edi_energy/interchange.rs
1/// Process-layer types for EDIFACT interchange envelope handling.
2///
3/// An EDIFACT *interchange* (UNB…UNZ envelope) wraps one or more messages.
4/// Standard `parse_interchange()` discards the UNB metadata; the types here
5/// preserve it so downstream code can build acknowledgement messages, route by
6/// sender/receiver GLN, and cross-check control references.
7use crate::{AnyMessage, EdiEnergyMessage, EdiEnergyReport, Release};
8
9// ── InterchangeHeader ─────────────────────────────────────────────────────────
10
11/// Parsed UNB interchange envelope header fields.
12///
13/// All fields come from the UNB segment of the EDIFACT interchange.
14/// Use these for routing, acknowledgement generation, and audit logging.
15#[derive(Debug, Clone)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize))]
17#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
18pub struct InterchangeHeader {
19 /// Sender identification (UNB S002, DE 0004 — e.g. a 13-digit GLN).
20 pub sender_id: Box<str>,
21 /// Sender qualifier (UNB S002, DE 0007 — e.g. `"14"` for GS1 GLN).
22 pub sender_qualifier: Box<str>,
23 /// Recipient identification (UNB S003, DE 0010 — e.g. a 13-digit GLN).
24 pub receiver_id: Box<str>,
25 /// Recipient qualifier (UNB S003, DE 0007).
26 pub receiver_qualifier: Box<str>,
27 /// Preparation date+time from UNB S004 (DE 0017 + DE 0019).
28 ///
29 /// `None` when the UNB date/time fields are absent or malformed.
30 pub transmission_datetime: Option<time::OffsetDateTime>,
31 /// Interchange control reference (UNB DE 0020).
32 pub control_ref: Box<str>,
33 /// EDIFACT syntax identifier from UNB S001 (DE 0001 — e.g. `"UNOC"`).
34 pub syntax_id: Box<str>,
35 /// EDIFACT syntax version number from UNB S001 (DE 0002 — e.g. `3`).
36 pub syntax_version: u8,
37 /// Test indicator from UNB DE 0035.
38 ///
39 /// `true` when DE 0035 is `"1"`. Per Allgemeine Festlegungen V6.1d §3,
40 /// interchanges with the test flag **must not** be processed as production
41 /// messages. Reject at the ingest boundary and record a dead-letter entry.
42 pub test_indicator: bool,
43}
44
45impl InterchangeHeader {
46 /// Extract the transmission date component only.
47 ///
48 /// Returns `None` when [`transmission_datetime`][Self::transmission_datetime] is `None`.
49 #[must_use]
50 pub fn transmission_date(&self) -> Option<time::Date> {
51 self.transmission_datetime.map(time::OffsetDateTime::date)
52 }
53
54 /// Convert from the `edifact_rs::InterchangeEnvelope` produced by
55 /// [`edifact_rs::validate_envelope_owned`] into edi-energy's typed header.
56 ///
57 /// Used to attach envelope metadata to [`EdiEnergyReport`] so a single
58 /// report carries both the interchange routing data (sender/receiver/control
59 /// reference) and the validation findings.
60 #[cfg(any(
61 feature = "utilmd",
62 feature = "mscons",
63 feature = "aperak",
64 feature = "contrl",
65 feature = "invoic",
66 feature = "remadv",
67 feature = "orders",
68 feature = "iftsta",
69 feature = "insrpt",
70 feature = "reqote",
71 feature = "partin",
72 feature = "ordchg",
73 feature = "ordrsp",
74 feature = "quotes",
75 feature = "comdis",
76 feature = "pricat",
77 feature = "utilts",
78 ))]
79 #[must_use]
80 pub(crate) fn from_edifact_envelope(env: edifact_rs::InterchangeEnvelope) -> Self {
81 use time::{Date, Month, OffsetDateTime, Time, UtcOffset};
82
83 // Parse YYMMDD (6-digit) or YYYYMMDD (8-digit) date + HHMM or HHMMSS time.
84 let transmission_datetime = (|| -> Option<OffsetDateTime> {
85 let date_str = &env.date;
86 let time_str = env.time.as_deref().unwrap_or("");
87
88 let d: Date = match date_str.len() {
89 6 => {
90 let yy: i32 = date_str[0..2].parse().ok()?;
91 let mm: u8 = date_str[2..4].parse().ok()?;
92 let dd: u8 = date_str[4..6].parse().ok()?;
93 Date::from_calendar_date(2000 + yy, Month::try_from(mm).ok()?, dd).ok()?
94 }
95 8 => {
96 let yyyy: i32 = date_str[0..4].parse().ok()?;
97 let mm: u8 = date_str[4..6].parse().ok()?;
98 let dd: u8 = date_str[6..8].parse().ok()?;
99 Date::from_calendar_date(yyyy, Month::try_from(mm).ok()?, dd).ok()?
100 }
101 _ => return None,
102 };
103 let t: Time = match time_str.len() {
104 4 => {
105 let hh: u8 = time_str[0..2].parse().ok()?;
106 let mi: u8 = time_str[2..4].parse().ok()?;
107 Time::from_hms(hh, mi, 0).ok()?
108 }
109 6 => {
110 let hh: u8 = time_str[0..2].parse().ok()?;
111 let mi: u8 = time_str[2..4].parse().ok()?;
112 let ss: u8 = time_str[4..6].parse().ok()?;
113 Time::from_hms(hh, mi, ss).ok()?
114 }
115 // Time field may be absent; default to midnight
116 _ => Time::MIDNIGHT,
117 };
118 Some(OffsetDateTime::new_utc(d, t).to_offset(UtcOffset::UTC))
119 })();
120
121 let syntax_version: u8 = env.syntax_version.parse().unwrap_or(3);
122
123 Self {
124 sender_id: env.sender_id.into_boxed_str(),
125 sender_qualifier: env.sender_qualifier.into_boxed_str(),
126 receiver_id: env.recipient_id.into_boxed_str(),
127 receiver_qualifier: env.recipient_qualifier.into_boxed_str(),
128 transmission_datetime,
129 control_ref: env.control_ref.into_boxed_str(),
130 syntax_id: env.syntax_identifier.into_boxed_str(),
131 syntax_version,
132 test_indicator: env.test_indicator,
133 }
134 }
135}
136
137// ── ReceiptContext ─────────────────────────────────────────────────────────────
138
139/// Context needed to build an acknowledgement (APERAK / CONTRL) for a received
140/// message.
141///
142/// Produced by [`MessageEnvelope::receipt_context`]. Pass this to
143/// `AperakBuilder::for_receipt()` or `ContrlBuilder::for_interchange()` to
144/// construct the outgoing acknowledgement with the correct mirror fields.
145#[derive(Debug, Clone)]
146pub struct ReceiptContext<'m> {
147 /// GLN or ID of the original sender (becomes the recipient in the ACK).
148 pub original_sender: &'m str,
149 /// GLN or ID of the original receiver (becomes the sender in the ACK).
150 pub original_receiver: &'m str,
151 /// UNH message reference of the message being acknowledged.
152 pub message_ref: &'m str,
153 /// Wire release code of the message being acknowledged.
154 pub release: Release,
155 /// Transmission date of the original interchange, if available.
156 pub transmission_date: Option<time::Date>,
157}
158
159// ── MessageEnvelope ───────────────────────────────────────────────────────────
160
161/// A single parsed message together with its enclosing interchange envelope header.
162///
163/// When an interchange contains multiple messages, each yields a separate
164/// `MessageEnvelope` from `ParsedInterchange::messages`.
165#[derive(Debug)]
166pub struct MessageEnvelope {
167 /// The parsed message.
168 pub message: AnyMessage,
169 /// The interchange header from the enclosing UNB segment.
170 pub header: InterchangeHeader,
171 /// 0-based index of this message within the interchange.
172 pub message_index: usize,
173}
174
175impl MessageEnvelope {
176 /// Validate the message using the profile registry.
177 ///
178 /// Delegates to [`EdiEnergyMessage::validate`] for all known message types.
179 /// For [`AnyMessage::Unknown`] this returns `Ok(report)` where the report
180 /// contains a single `Warning` with rule ID `"UNKNOWN-MSG-TYPE"`, consistent
181 /// with the behaviour of `EdiEnergyMessage::validate_against` on unknown variants.
182 ///
183 /// **Rationale for returning `Ok` instead of `Err`:** an interchange may
184 /// legitimately contain message types that are not compiled into the current
185 /// binary (e.g. when only the `mscons` feature is enabled). Returning `Err`
186 /// would abort validation of the entire interchange on the first unknown message,
187 /// preventing valid messages later in the interchange from being validated.
188 /// Callers that want strict unknown-type rejection can check
189 /// `report.is_valid() && report.warnings().is_empty()` after the fact.
190 ///
191 /// # Errors
192 ///
193 /// Returns [`crate::Error::ProfileNotFound`] when no profile matches the message's
194 /// release code (known message type, unregistered release).
195 pub fn validate(&self) -> Result<EdiEnergyReport, crate::Error> {
196 // Delegate to the EdiEnergyMessage trait impl for ALL variants, including
197 // Unknown. The Unknown impl returns Ok(report) with a warning, which is
198 // exactly the consistent behaviour we want here (resolves.
199 EdiEnergyMessage::validate(&self.message)
200 }
201
202 /// Return `true` when the wire release code in this message is normatively
203 /// acceptable on `date` (considering the grace window configured on `registry`).
204 ///
205 /// Pass the registry from the owning [`crate::Platform`] rather than calling
206 /// this via the global singleton. Using an explicit registry is required for
207 /// test isolation and multi-tenant deployments.
208 ///
209 /// For convenience in simple single-registry programs, call
210 /// [`MessageEnvelope::is_wire_code_acceptable_on_global`] instead.
211 #[must_use]
212 pub fn is_wire_code_acceptable_on(
213 &self,
214 date: time::Date,
215 registry: &crate::registry::ReleaseRegistry,
216 ) -> bool {
217 let Some(mt) = self.message.try_message_type() else {
218 return false;
219 };
220 let Ok(release) = EdiEnergyMessage::detect_release(&self.message) else {
221 return false;
222 };
223 registry.is_acceptable_on(mt, release, date)
224 }
225
226 /// Convenience wrapper that uses the process-global registry.
227 ///
228 /// Prefer [`MessageEnvelope::is_wire_code_acceptable_on`] with an explicit
229 /// registry when working with a [`crate::Platform`] instance.
230 #[must_use]
231 pub fn is_wire_code_acceptable_on_global(&self, date: time::Date) -> bool {
232 self.is_wire_code_acceptable_on(date, crate::registry::ReleaseRegistry::global())
233 }
234
235 /// Extract the sender's party identifier from a 13-digit numeric sender ID
236 /// (BDEW code, agency `293`, or GS1 GLN, agency `9`), or return `None`.
237 ///
238 /// Returns `None` for 16-char EIC codes — check [`InterchangeHeader::sender_id`]
239 /// directly when EIC senders are expected.
240 #[must_use]
241 pub fn sender_party_id(&self) -> Option<&str> {
242 extract_13digit_party_id(&self.header.sender_id)
243 }
244
245 /// Extract the receiver's party identifier from a 13-digit numeric receiver ID,
246 /// or return `None`.
247 #[must_use]
248 pub fn receiver_party_id(&self) -> Option<&str> {
249 extract_13digit_party_id(&self.header.receiver_id)
250 }
251
252 /// The transmission date from the interchange header, if present.
253 #[must_use]
254 pub fn transmission_date(&self) -> Option<time::Date> {
255 self.header.transmission_date()
256 }
257
258 /// Build a [`ReceiptContext`] for constructing an acknowledgement.
259 ///
260 /// The context mirrors sender/receiver so that `AperakBuilder::for_receipt()`
261 /// and `ContrlBuilder::for_interchange()` swap them correctly.
262 #[must_use]
263 pub fn receipt_context(&self) -> ReceiptContext<'_> {
264 let release = EdiEnergyMessage::detect_release(&self.message)
265 .cloned()
266 .unwrap_or_else(|_| Release::new(""));
267 ReceiptContext {
268 original_sender: &self.header.sender_id,
269 original_receiver: &self.header.receiver_id,
270 message_ref: EdiEnergyMessage::message_ref(&self.message),
271 release,
272 transmission_date: self.header.transmission_date(),
273 }
274 }
275}
276
277// ── Interchange ───────────────────────────────────────────────────────────────
278
279/// A fully parsed EDIFACT interchange with envelope metadata preserved.
280///
281/// Produced by `Parser::parse_interchange_full`. Contains the interchange
282/// header (UNB fields) and all contained messages with their shared header
283/// attached to each envelope.
284#[derive(Debug)]
285pub struct ParsedInterchange {
286 /// The UNB interchange header.
287 pub header: InterchangeHeader,
288 /// All contained messages in document order.
289 pub messages: Vec<MessageEnvelope>,
290 /// UNZ control reference (must match [`InterchangeHeader::control_ref`]).
291 pub trailer_ref: Box<str>,
292 /// Message count declared in UNZ (should equal `messages.len()`).
293 pub declared_message_count: usize,
294}
295
296impl ParsedInterchange {
297 /// Return the number of messages that were actually parsed.
298 #[must_use]
299 pub fn message_count(&self) -> usize {
300 self.messages.len()
301 }
302
303 /// Check whether the declared message count in UNZ matches the actual count.
304 #[must_use]
305 pub fn count_matches_declared(&self) -> bool {
306 self.messages.len() == self.declared_message_count
307 }
308
309 /// Check whether the UNZ control reference matches the UNB control reference.
310 #[must_use]
311 pub fn control_refs_match(&self) -> bool {
312 self.trailer_ref == self.header.control_ref
313 }
314
315 /// Return `true` when both structural integrity checks pass.
316 #[must_use]
317 pub fn is_structurally_valid(&self) -> bool {
318 self.count_matches_declared() && self.control_refs_match()
319 }
320}
321
322// ── GLN extraction helper ─────────────────────────────────────────────────────
323
324fn extract_13digit_party_id(id: &str) -> Option<&str> {
325 if id.len() == 13 && id.bytes().all(|b| b.is_ascii_digit()) {
326 Some(id)
327 } else {
328 None
329 }
330}