Skip to main content

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}