mig-assembly 0.8.0

MIG-guided EDIFACT tree assembly — parse RawSegments into typed MIG trees
Documentation
//! PID detection from EDIFACT segments.
//!
//! Determines the Pruefidentifikator (PID) from a list of parsed EDIFACT
//! segments. Most BDEW message types embed the PID directly in RFF+Z13;
//! APERAK and CONTRL don't — their AHB workflows carry no Prüfidentifikator at
//! all. APERAK's two workflows are told apart by the BGM document code; CONTRL's
//! three are one PID (91001), so naming it needs nothing but the message type.

use crate::tokenize::OwnedSegment;
use crate::AssemblyError;

/// Detect the PID (Pruefidentifikator) from a list of parsed EDIFACT segments.
///
/// Resolution order:
///   1. RFF+Z13 at message level (UTILMD, MSCONS, ORDERS, INVOIC, …).
///   2. Message-type-specific heuristics based on UNH's message-type field:
///      - APERAK: BGM document-code (313 → 92001, 312 → 92002).
///      - CONTRL: always 91001 — its AHB has one PID for the whole message type.
///      - UTILMD: BGM+STS combination, else BGM alone.
///
/// Returns the PID as a string (e.g., "55001").
pub fn detect_pid(segments: &[OwnedSegment]) -> Result<String, AssemblyError> {
    // Strategy 1: Look for RFF+Z13 which directly contains the PID number.
    // In UTILMD/MSCONS/ORDERS/… the PID reference lives in SG1/SG6 as
    // RFF+Z13:<pid_number>.
    for seg in segments {
        if seg.is("RFF") {
            let qualifier = seg.get_component(0, 0);
            let reference = seg.get_component(0, 1);
            if qualifier == "Z13" && !reference.is_empty() {
                return Ok(reference.to_string());
            }
        }
    }

    // Strategy 2: Dispatch on UNH message type — each class of message
    // encodes its PID differently, and applying UTILMD's BGM table to
    // an APERAK would silently produce the wrong PID.
    let msg_type = segments
        .iter()
        .find(|s| s.is("UNH"))
        .map(|s| s.get_component(1, 0).to_string())
        .unwrap_or_default();

    match msg_type.as_str() {
        "APERAK" => resolve_aperak_pid(segments),
        "CONTRL" => Ok(CONTRL_PID.to_string()),
        "UTILMD" => resolve_utilmd_pid_fallback(segments),
        // For any other message class without RFF+Z13, we have no defined
        // resolution yet — fail explicitly rather than guess.
        _ => Err(AssemblyError::PidDetectionFailed),
    }
}

/// UTILMD fallback: BGM+STS combination, else BGM alone. Kept as a
/// separate helper now that the entry point dispatches by message type.
fn resolve_utilmd_pid_fallback(segments: &[OwnedSegment]) -> Result<String, AssemblyError> {
    let bgm = segments.iter().find(|s| s.is("BGM"));
    let sts = segments.iter().find(|s| s.is("STS"));

    match (bgm, sts) {
        (Some(bgm_seg), Some(sts_seg)) => {
            let doc_code = bgm_seg.get_element(0);
            let reason = sts_seg.get_component(1, 0);
            resolve_utilmd_pid(doc_code, reason)
        }
        (Some(bgm_seg), None) => {
            let doc_code = bgm_seg.get_element(0);
            resolve_utilmd_pid_from_bgm(doc_code)
        }
        _ => Err(AssemblyError::PidDetectionFailed),
    }
}

/// Resolve APERAK PID from the BGM document-code.
///
/// The APERAK AHB carries two AWF rows and leaves the `Pruefidentifikator`
/// empty on both, so it is not the AHB that tells them apart — the message is.
/// They are two different shapes: the Fehlermeldung has the `SG4` error group
/// (`ERC`/`FTX`) with its `SG5` reference variants, the Anerkennungsmeldung has
/// no `SG4` and splits `SG2` into `ACE`/`AGO`/`TN`. Each PID's schema allows
/// `BGM` C002/1001 exactly one code, and a message must carry it:
///   - `313` (Anwendungssystemfehlermeldung) → 92001 "Fehlermeldung"
///   - `312` (Anerkennungsmeldung)           → 92002 "Anerkennungsmeldung"
///
/// So detection is decidable from the message alone, and does not fall back to
/// a guess: an APERAK with any other document code fails, because naming either
/// PID would hand the caller the wrong message shape.
///
/// Fixture filenames in the public corpus all start `92001_` — a convention
/// that predates the two PIDs and is wrong for at least one file. The BGM value
/// is authoritative.
fn resolve_aperak_pid(segments: &[OwnedSegment]) -> Result<String, AssemblyError> {
    let bgm = segments
        .iter()
        .find(|s| s.is("BGM"))
        .ok_or(AssemblyError::PidDetectionFailed)?;
    match bgm.get_element(0) {
        "313" => Ok("92001".to_string()),
        "312" => Ok("92002".to_string()),
        _ => Err(AssemblyError::PidDetectionFailed),
    }
}

/// The one PID every CONTRL is addressed by.
///
/// CONTRL is a syntax acknowledgement: it carries no Prüfidentifikator and no
/// `RFF+Z13`. Its AHB has three AWF rows — "Empfangsbestätigung",
/// "Syntaxfehlermeldung in der Übertragungsdatei", "Syntaxfehlermeldung in der
/// Nachricht" — and leaves the `Pruefidentifikator` attribute empty on all
/// three, because they are three things one acknowledgement can report, told
/// apart inside the message by `UCI` DE0083 and the presence of a `UCM`, not
/// three message types. Market communication addresses all of them as 91001,
/// and this repo generates exactly that one PID
/// (`automapper_generator::parsing::ahb_parser::CONTRL_PID`).
///
/// So detection here is total: a message whose `UNH` says CONTRL is 91001.
pub const CONTRL_PID: &str = "91001";

/// Resolve PID from BGM document code + STS transaction reason.
///
/// The mapping table is derived from the AHB. Common combinations:
/// - E01 (Anmeldung) + various STS reasons -> 55001-55009
/// - E02 (Abmeldung) + various STS reasons -> 55101-55109
/// - E03 (Bestellung) -> 55201-55209
fn resolve_utilmd_pid(doc_code: &str, reason: &str) -> Result<String, AssemblyError> {
    // For UTILMD, common PID mappings based on BGM doc code + STS reason
    // These are the most common ones; this can be extended as needed
    match (doc_code, reason) {
        ("E01", "Z33") => Ok("55001".to_string()),
        ("E01", "Z34") => Ok("55002".to_string()),
        ("E01", "Z35") => Ok("55003".to_string()),
        ("E02", "Z33") => Ok("55101".to_string()),
        ("E02", "Z34") => Ok("55102".to_string()),
        ("E03", "Z33") => Ok("55201".to_string()),
        _ => Err(AssemblyError::PidDetectionFailed),
    }
}

/// Resolve PID from BGM document code alone (fallback).
fn resolve_utilmd_pid_from_bgm(doc_code: &str) -> Result<String, AssemblyError> {
    match doc_code {
        "E01" => Ok("55001".to_string()),
        "E02" => Ok("55101".to_string()),
        "E03" => Ok("55201".to_string()),
        _ => Err(AssemblyError::PidDetectionFailed),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_segment(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
        OwnedSegment {
            id: id.to_string(),
            elements: elements
                .into_iter()
                .map(|e| e.into_iter().map(|c| c.to_string()).collect())
                .collect(),
            segment_number: 0,
        }
    }

    #[test]
    fn test_detect_pid_from_rff_z13() {
        let segments = vec![
            make_segment("UNH", vec![vec!["001"]]),
            make_segment("BGM", vec![vec!["E01"]]),
            make_segment("RFF", vec![vec!["Z13", "55001"]]),
            make_segment("UNT", vec![vec!["3", "001"]]),
        ];
        let pid = detect_pid(&segments).unwrap();
        assert_eq!(pid, "55001");
    }

    #[test]
    fn test_detect_pid_from_bgm_and_sts() {
        let segments = vec![
            make_segment(
                "UNH",
                vec![vec!["001"], vec!["UTILMD", "D", "11A", "UN", "S2.1"]],
            ),
            make_segment("BGM", vec![vec!["E01"]]),
            make_segment("STS", vec![vec![""], vec!["Z33"]]),
            make_segment("UNT", vec![vec!["3", "001"]]),
        ];
        let pid = detect_pid(&segments).unwrap();
        assert_eq!(pid, "55001");
    }

    #[test]
    fn test_detect_pid_from_bgm_only() {
        let segments = vec![
            make_segment(
                "UNH",
                vec![vec!["001"], vec!["UTILMD", "D", "11A", "UN", "S2.1"]],
            ),
            make_segment("BGM", vec![vec!["E03"]]),
            make_segment("UNT", vec![vec!["2", "001"]]),
        ];
        let pid = detect_pid(&segments).unwrap();
        assert_eq!(pid, "55201");
    }

    #[test]
    fn test_detect_pid_fails_no_bgm() {
        let segments = vec![
            make_segment("UNH", vec![vec!["001"]]),
            make_segment("UNT", vec![vec!["1", "001"]]),
        ];
        let result = detect_pid(&segments);
        assert!(result.is_err());
    }

    #[test]
    fn test_detect_pid_prefers_rff_z13_over_bgm() {
        // If both RFF+Z13 and BGM are present, RFF+Z13 wins
        let segments = vec![
            make_segment("UNH", vec![vec!["001"]]),
            make_segment("BGM", vec![vec!["E01"]]),
            make_segment("STS", vec![vec![""], vec!["Z33"]]),
            make_segment("RFF", vec![vec!["Z13", "99999"]]),
            make_segment("UNT", vec![vec!["4", "001"]]),
        ];
        let pid = detect_pid(&segments).unwrap();
        assert_eq!(pid, "99999"); // RFF+Z13 takes priority
    }

    #[test]
    fn test_detect_pid_from_parsed_edifact() {
        // Test with actual parsed EDIFACT input
        let input = b"UNA:+.? 'UNB+UNOC:3+SENDER+RECEIVER+210101:1200+REF001'UNH+MSG001+UTILMD:D:11A:UN:S2.1'BGM+E01+DOC001'RFF+Z13:55001'UNT+3+MSG001'UNZ+1+REF001'";
        let segments = crate::tokenize::parse_to_segments(input).unwrap();
        let pid = detect_pid(&segments).unwrap();
        assert_eq!(pid, "55001");
    }

    #[test]
    fn test_detect_aperak_fehlermeldung_from_bgm_313() {
        // APERAK has no RFF+Z13 — must be resolved via UNH message type
        // and BGM document code. BGM+313 = Anwendungssystemfehlermeldung → 92001.
        let segments = vec![
            make_segment(
                "UNH",
                vec![vec!["MSG"], vec!["APERAK", "D", "07B", "UN", "2.1i"]],
            ),
            make_segment("BGM", vec![vec!["313"], vec!["MSG-BGM"]]),
            make_segment("UNT", vec![vec!["3"], vec!["MSG"]]),
        ];
        let pid = detect_pid(&segments).unwrap();
        assert_eq!(pid, "92001");
    }

    #[test]
    fn test_detect_aperak_anerkennungsmeldung_from_bgm_312() {
        // BGM+312 = Anerkennungsmeldung → 92002
        let segments = vec![
            make_segment(
                "UNH",
                vec![vec!["MSG"], vec!["APERAK", "D", "07B", "UN", "2.1i"]],
            ),
            make_segment("BGM", vec![vec!["312"], vec!["MSG-BGM"]]),
            make_segment("UNT", vec![vec!["3"], vec!["MSG"]]),
        ];
        let pid = detect_pid(&segments).unwrap();
        assert_eq!(pid, "92002");
    }

    #[test]
    fn test_detect_aperak_unknown_bgm_code_fails() {
        let segments = vec![
            make_segment(
                "UNH",
                vec![vec!["MSG"], vec!["APERAK", "D", "07B", "UN", "2.1i"]],
            ),
            make_segment("BGM", vec![vec!["999"], vec!["MSG-BGM"]]),
            make_segment("UNT", vec![vec!["3"], vec!["MSG"]]),
        ];
        assert!(detect_pid(&segments).is_err());
    }

    /// Every CONTRL is 91001, whichever of the AHB's three reports it carries.
    /// An Empfangsbestätigung: `UCI` action 7, no `UCM`.
    #[test]
    fn test_detect_contrl_empfangsbestaetigung() {
        let input = b"UNB+UNOC:3+9903111000003:500+9900262000007:500+220628:2255+X165'\
UNH+X165+CONTRL:D:3:UN:2.0b'\
UCI+GPKELF18197957+9903111000003:500+9900262000007:500+7'\
UNT+3+X165'UNZ+1+X165'";
        let segments = crate::tokenize::parse_to_segments(input).unwrap();
        assert_eq!(detect_pid(&segments).unwrap(), CONTRL_PID);
    }

    /// A syntax error in the interchange itself: `UCI` action 4, no `UCM`.
    #[test]
    fn test_detect_contrl_interchange_error() {
        let input = b"UNB+UNOC:3+9903111000003:500+9900269000000:500+220614:1519+X315'\
UNH+X315+CONTRL:D:3:UN:2.0b'\
UCI+10000000172UCI+9903111000003:500+9900269000000:500+4+13+UNB'\
UNT+3+X315'UNZ+1+X315'";
        let segments = crate::tokenize::parse_to_segments(input).unwrap();
        assert_eq!(detect_pid(&segments).unwrap(), CONTRL_PID);
    }

    /// A syntax error inside a checked message: `UCM` with its `UCS`/`UCD`.
    #[test]
    fn test_detect_contrl_message_error() {
        let input = b"UNB+UNOC:3+9903111000003:500+9900269000000:500+220614:1519+X315'\
UNH+X315+CONTRL:D:3:UN:2.0b'\
UCI+10000000172UCI+9903111000003:500+9900269000000:500+4'\
UCM+10000000172UCM+UTILMD:D:11A:UN:5.2c+4'UCS+90'UCD+21+6:4'\
UNT+6+X315'UNZ+1+X315'";
        let segments = crate::tokenize::parse_to_segments(input).unwrap();
        assert_eq!(detect_pid(&segments).unwrap(), CONTRL_PID);
    }

    /// Detection needs nothing but the message type — not even a `UCI`.
    #[test]
    fn test_detect_contrl_needs_only_the_message_type() {
        let input = b"UNB+UNOC:3+A:500+B:500+220614:1519+X315'\
UNH+X315+CONTRL:D:3:UN:2.0b'\
UNT+2+X315'UNZ+1+X315'";
        let segments = crate::tokenize::parse_to_segments(input).unwrap();
        assert_eq!(detect_pid(&segments).unwrap(), CONTRL_PID);
    }

    #[test]
    fn test_aperak_detector_not_applied_to_non_aperak() {
        // BGM+312 on a UTILMD message must NOT be resolved to APERAK 92002 —
        // the dispatch happens AFTER message-type detection from UNH.
        let segments = vec![
            make_segment(
                "UNH",
                vec![vec!["MSG"], vec!["UTILMD", "D", "11A", "UN", "S2.1"]],
            ),
            make_segment("BGM", vec![vec!["312"], vec!["MSG-BGM"]]),
            make_segment("UNT", vec![vec!["3"], vec!["MSG"]]),
        ];
        // Should NOT match APERAK table; UTILMD fallback has no entry for "312"
        let result = detect_pid(&segments);
        assert!(
            result.is_err()
                || result
                    .as_ref()
                    .map(|s| !s.starts_with("92"))
                    .unwrap_or(true),
            "UTILMD BGM+312 must not resolve to APERAK PID, got {:?}",
            result
        );
    }
}