edi-energy 0.20.0

EDI@Energy EDIFACT parser and validator for the German energy market
Documentation
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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
//! UTILMD SG4/SG5 wire vocabulary — the codes the MIG fixes, as constants.
//!
//! Every value here is quoted from the BDEW UTILMD MIG (Strom **S2.2**, Gas
//! **G1.2**, both effective 01.04.2026) rather than inferred from a sibling
//! message. The two tracks agree on all of it, so one module serves both.
//!
//! ## Why this module exists
//!
//! UTILMD encodes four different things in three segments that all look alike:
//!
//! | Wire | Meaning | Constants |
//! |---|---|---|
//! | `IDE+24+<Vorgangsnummer>` | the transaction — **never** a location ID | [`IDE_VORGANG`] |
//! | `SG5 LOC+<Z16…Z22>+<id>` | the Marktlokation / Messlokation / Tranche | [`loc`] |
//! | `SG4 DTM+<2005>` | eight distinct process dates | [`dtm`] |
//! | `SG4 STS+7` / `STS+E01` | Transaktionsgrund / **Antwortcode** | [`transaktionsgrund`], [`AntwortStatus`] |
//!
//! DE 7495 has exactly two values in UTILMD, and `163`/`164` are
//! Messperioden-Qualifier that never occur at SG4 level. A Marktlokations-ID in
//! `IDE`, or a process date as `DTM+163`, is rejected by a conformant
//! counterparty.

// ── IDE (SG4) ─────────────────────────────────────────────────────────────────

/// `IDE` DE 7495 — **the only two values UTILMD defines**.
///
/// MIG Strom S2.2 Zähler 0190 (Nr. 00010 / 00018), MIG Gas G1.2 likewise.
/// DE 7402 alongside it carries the **Vorgangsnummer**, not a location ID.
pub const IDE_VORGANG: &str = "24";

/// `IDE+Z01` — Identifikation einer Liste (`MaBiS` Summenzeitreihen).
pub const IDE_LISTE: &str = "Z01";

/// `BGM` DE 1001 `Z07` — **Aktivierung/Deaktivierung von `MaBiS`-ZP**.
///
/// All three Prüfidentifikatoren of the `MaBiS`-ZP lifecycle carry it (UTILMD
/// AHB Strom 2.2 Kap. 13.3). A Zählpunkt is activated, not angemeldet, so the
/// `E01` an ordinary Vorgang uses states the wrong Dokumentenart.
pub const BGM_MABIS_ZP_LIFECYCLE: &str = "Z07";

/// `BGM` DE 1001 `Z05` — **Clearingliste**.
///
/// Every `MaBiS` Clearingliste and every answer to one carries it (UTILMD AHB
/// Strom 2.2 Kap. 13.4), in place of the `E01`/`E02`/`E44` an ordinary Vorgang
/// uses.
pub const BGM_CLEARINGLISTE: &str = "Z05";

/// `SG8 SEQ+Z22` — Daten der Summenzeitreihe.
///
/// The Clearinglisten head block. Muss on both 55065 and 55066 (UTILMD AHB
/// Strom 2.2 Kap. 13.4), paired with [`RFF_ZEITREIHE`].
pub const SEQ_SUMMENZEITREIHE: &str = "Z22";
/// `SG8 SEQ+Z01` — Daten der Marktlokation, the Stammdaten block a
/// Vorgang's `CCI` Merkmale belong to.
pub const SEQ_DATEN_DER_MARKTLOKATION: &str = "Z01";

/// `SG8 SEQ+Z98` — „Daten der Marktlokation" on an **answer**.
///
/// Not the same place as [`SEQ_DATEN_DER_MARKTLOKATION`] (`Z01`), which the
/// Anmeldung uses: the Bestätigung's own block is `Z98`, Muss behind `ZW7`
/// „Gemessene Marktlokation" (Bedingung `[483]`).
pub const SEQ_DATEN_DER_MARKTLOKATION_ANTWORT: &str = "Z98";
/// `SG8 SEQ+Z75` — Daten des Kunden des Lieferanten (EnFG-Grundlage).
pub const SEQ_DATEN_DES_KUNDEN: &str = "Z75";

/// `SG8 RFF+AUU` — Referenz auf eine Zeitreihe; DE 1154 is its Version.
///
/// `MaBiS` keys a Summenzeitreihe's versions on the Erstellungszeitpunkt, so a
/// Clearingliste that names no version cannot be matched to the one it
/// reconciles.
pub const RFF_ZEITREIHE: &str = "AUU";

// ── SG5 LOC ───────────────────────────────────────────────────────────────────

/// `SG5 LOC` DE 3227 — the Lokationstyp qualifiers (MIG Zähler 0330).
pub mod loc {
    /// `LOC+172` — **Meldepunkt**, the one Lokationsqualifier UTILMD Gas uses.
    ///
    /// UTILMD AHB Gas G1.1/G1.2 names `172` in every `SG5 LOC` it defines and
    /// `Z16`/`Z17` in none: where Strom distinguishes the object by qualifier,
    /// Gas distinguishes it by the **format of DE 3225** — Bedingung `[950]`
    /// Marktlokations-ID, `[951]` Zählpunktbezeichnung. Sending `Z16` on a Gas
    /// UTILMD states a qualifier the receiving AHB does not define.
    pub const MELDEPUNKT: &str = "172";
    /// `LOC+Z15` — `MaBiS`-Zählpunkt.
    pub const MABIS_ZAEHLPUNKT: &str = "Z15";
    /// `LOC+Z16` — Marktlokation.
    pub const MARKTLOKATION: &str = "Z16";
    /// `LOC+Z17` — Messlokation.
    pub const MESSLOKATION: &str = "Z17";
    /// `LOC+Z18` — Netzlokation.
    pub const NETZLOKATION: &str = "Z18";
    /// `LOC+Z19` — Steuerbare Ressource (§14a).
    pub const STEUERBARE_RESSOURCE: &str = "Z19";
    /// `LOC+Z20` — Technische Ressource.
    pub const TECHNISCHE_RESSOURCE: &str = "Z20";
    /// `LOC+Z21` — Tranche.
    pub const TRANCHE: &str = "Z21";
    /// `LOC+Z22` — Ruhende Marktlokation (§ 20 Abs. 1d `EnWG` / § 10c EEG).
    pub const RUHENDE_MARKTLOKATION: &str = "Z22";
}

// ── SG4 DTM ───────────────────────────────────────────────────────────────────

/// `SG4 DTM` DE 2005 — the process-date qualifiers (MIG Zähler 0230).
///
/// `163`/`164` are deliberately absent: they are *Verarbeitung Beginn-/
/// Endedatum* on the SG8/SG9 Messperiode, never a SG4 process date.
pub mod dtm {
    /// `DTM+76` — Datum zum geplanten Leistungsbeginn (Nr. 00019).
    pub const LEISTUNGSBEGINN_GEPLANT: &str = "76";
    /// `DTM+92` — Beginn zum / Datum Vertragsbeginn (Nr. 00020).
    ///
    /// The Zuordnungsbeginn of every Anmeldung.
    pub const BEGINN_ZUM: &str = "92";
    /// `DTM+93` — Ende zum / Datum Vertragsende (Nr. 00021).
    ///
    /// The Zuordnungsende of every Abmeldung, Kündigung and Beendigung.
    pub const ENDE_ZUM: &str = "93";
    /// `DTM+Z05` — gegenüber Kunde bestätigtes Vertragsende (Nr. 00022).
    pub const BESTAETIGTES_VERTRAGSENDE: &str = "Z05";
    /// `DTM+157` — Änderung zum / Gültigkeit, Beginndatum (Nr. 00023).
    pub const AENDERUNG_ZUM: &str = "157";
    /// `DTM+471` — Ende zum nächstmöglichen Termin (Nr. 00024).
    ///
    /// The date an LFA returns with `Z01` „Zustimmung mit Terminänderung".
    pub const ENDE_NAECHSTMOEGLICH: &str = "471";
    /// `DTM+158` — Bilanzierungsbeginn (Nr. 00025).
    pub const BILANZIERUNGSBEGINN: &str = "158";
    /// `DTM+159` — Bilanzierungsende (Nr. 00026).
    pub const BILANZIERUNGSENDE: &str = "159";
    /// `DTM+154` — ÜT der Lieferanmeldung des LFN (Nr. 00027).
    ///
    /// The Übertragungstag the `E_0624` Prüfschritt 5 Frist runs from.
    pub const UET_LIEFERANMELDUNG: &str = "154";
    /// `DTM+Z01` — Kündigungsfrist des Vertrags (Nr. 00028).
    pub const KUENDIGUNGSFRIST: &str = "Z01";
    /// `DTM+Z10` — Kündigungstermin des Vertrags (Nr. 00029).
    pub const KUENDIGUNGSTERMIN: &str = "Z10";
    /// `DTM+137` — Dokumenten-/Nachrichtendatum (message header).
    pub const NACHRICHTENDATUM: &str = "137";
}

// ── SG4 STS ───────────────────────────────────────────────────────────────────

/// `STS` DE 9015 — Statuskategorie `7`, Transaktionsgrund (MIG Nr. 00033).
pub const STS_TRANSAKTIONSGRUND: &str = "7";

/// `STS` DE 9015 — Statuskategorie `Z35`, **Status der Antwort des dritten
/// Marktbeteiligten** (MIG Nr. 00035).
///
/// A *second* `SG4 STS` beside `E01`, and the only place a Marktrolle restates
/// somebody else's Antwortcode. UTILMD AHB Strom 2.1/2.2 marks it **Muss** on a
/// 55003 „wenn `SG4 STS+E01++A50` vorhanden" (Bedingung `[356]`) and on a 55080
/// „wenn `STS+E01++A57` vorhanden" (`[84]`) — the two codes that mean „der LFA
/// hat der Anfrage zur Beendigung der Zuordnung widersprochen".
///
/// This is how GPKE Teil 2 § 2.1.2 Nr. 6's „der NB gibt zusätzlich den Grund der
/// Ablehnung des LFA an" reaches the wire. Without it the LFN learns that its
/// Anmeldung was refused and not why the incumbent refused to release the
/// Marktlokation, which is the only fact it can act on.
pub const STS_ANTWORT_DRITTER: &str = "Z35";

/// `STS` DE 9015 — Statuskategorie `E01`, **Status der Antwort** (MIG Nr. 00034).
///
/// Carries the EBD Antwortcode in DE 9013 and the EBD id in DE 1131:
/// `STS+E01++A10:E_0609'`.
pub const STS_STATUS_ANTWORT: &str = "E01";

/// `SG4 STS+7` DE 9013 (element 2) — Transaktionsgrund codes.
pub mod transaktionsgrund {
    /// `E01` — Ein-/Auszug (Umzug).
    pub const EIN_AUSZUG: &str = "E01";
    /// `E02` — Einzug in Neuanlage.
    pub const EINZUG_NEUANLAGE: &str = "E02";
    /// `E03` — Wechsel.
    pub const WECHSEL: &str = "E03";
    /// `E05` — Stornierung.
    pub const STORNIERUNG: &str = "E05";
    /// `E06` — Ersatzbelieferung.
    pub const ERSATZBELIEFERUNG: &str = "E06";
    /// `Z02` — Kündigung Lieferantenrahmenvertrag.
    pub const KUENDIGUNG_LRV: &str = "Z02";
    /// `Z26` — Information über existierende Zuordnung.
    pub const INFO_EXISTIERENDE_ZUORDNUNG: &str = "Z26";
    /// `Z33` — Auszug wegen Stilllegung.
    pub const AUSZUG_STILLLEGUNG: &str = "Z33";
    /// `Z36` — `EoG` aus Ein-/Auszug (Umzug).
    pub const EOG_UMZUG: &str = "Z36";
    /// `Z37` — `EoG` wegen Einzug in Neuanlage.
    pub const EOG_NEUANLAGE: &str = "Z37";
    /// `Z39` — `EoG` aus vorübergehendem Anschluss.
    pub const EOG_VORUEBERGEHEND: &str = "Z39";
    /// `Z41` — Ende der `ESV` ohne Folgelieferung.
    pub const ESV_ENDE_OHNE_FOLGE: &str = "Z41";
    /// `ZC6` — `EoG` aus Bilanzkreisschließung.
    pub const EOG_BK_SCHLIESSUNG: &str = "ZC6";
    /// `ZC7` — `EoG` aufgrund Erlöschen der Zuordnungsermächtigung.
    pub const EOG_ZUORDNUNGSERMAECHTIGUNG: &str = "ZC7";
    /// `ZC8` — Beendigung der Zuordnung.
    pub const BEENDIGUNG_ZUORDNUNG: &str = "ZC8";
    /// `ZD9` — Beendigung wegen Rückzuordnungsmeldung.
    pub const BEENDIGUNG_RUECKZUORDNUNG: &str = "ZD9";
    /// `ZG5` — Aufhebung einer zukünftigen Zuordnung aufgrund § 38 EEG 2014
    /// bzw. § 21b Abs. 1 Nr. 2 EEG 2017.
    ///
    /// The one Aufhebungsgrund that names **no** beteiligter Marktpartner: the
    /// SG12 NAD is Muss on a 55038 only „wenn `ZG5` nicht vorhanden"
    /// (UTILMD AHB Strom 2.1/2.2 Bedingung `[206]`).
    pub const AUFHEBUNG_EEG38: &str = "ZG5";
    /// `ZG6` — Beendigung der Zuordnung aufgrund EEG 2014 § 38.
    pub const BEENDIGUNG_EEG38: &str = "ZG6";
    /// `ZG9` — Aufhebung einer zukünftigen Zuordnung wegen Auszug des Kunden.
    pub const AUFHEBUNG_AUSZUG: &str = "ZG9";
    /// `ZH0` — Aufhebung einer zukünftigen Zuordnung wegen Anmeldung eines
    /// anderen Lieferanten zu einem früheren Termin.
    pub const AUFHEBUNG_FRUEHERE_ANMELDUNG: &str = "ZH0";
    /// `ZH1` — Aufhebung einer zukünftigen Zuordnung wegen Stilllegung.
    pub const AUFHEBUNG_STILLLEGUNG: &str = "ZH1";
    /// `ZH2` — Aufhebung einer zukünftigen Zuordnung wegen aufgehobenem
    /// Vertragsverhältnis.
    ///
    /// „Vertrag zwischen Absender des Geschäftsvorfalls und Kunde wurde
    /// aufgehoben, wird z. B. verwendet wenn der Kunde den Vertrag widerruft."
    /// The one Aufhebungsgrund `E_0607` Prüfschritte 60 / 560 route on.
    pub const AUFHEBUNG_VERTRAGSVERHAELTNIS: &str = "ZH2";
    /// `Z15` — Zusätzlicher Datensatz.
    pub const ZUSAETZLICHER_DATENSATZ: &str = "Z15";
    /// `ZE3` — Stammdatenänderung.
    pub const STAMMDATENAENDERUNG: &str = "ZE3";
    /// `ZJ4` — Übernahme aufgrund nicht erfolgtem iMS-Einbau.
    pub const UEBERNAHME_KEIN_IMS: &str = "ZJ4";
    /// `ZP3` — Stammdaten.
    pub const STAMMDATEN: &str = "ZP3";
    /// `ZP4` — Werte.
    pub const WERTE: &str = "ZP4";
    /// `ZQ7` — Abmeldung wg. fehlender Zuordnungsermächtigung.
    pub const ABMELDUNG_FEHLENDE_ZUORDNUNGSERMAECHTIGUNG: &str = "ZQ7";
    /// `ZR9` — Kündigung aufgrund Vertrag mit Anschlussnehmer.
    pub const KUENDIGUNG_ANSCHLUSSNEHMER: &str = "ZR9";
    /// `ZT0` — Abmeldung wegen fehlender Zuordnungsermächtigung aufgrund
    /// Änderung ZRT.
    pub const ABMELDUNG_FEHLENDE_ZE_ZRT: &str = "ZT0";
    /// `ZT4` — Ende wegen Kündigung durch LF (den bislang beliefernden LFA).
    pub const ENDE_KUENDIGUNG_LF: &str = "ZT4";
    /// `ZT5` — Ende wegen Kündigung durch Kunde/LFN.
    ///
    /// Also covers „keine Kündigung des Vertrages notwendig da Vertrag nur auf
    /// bestimmte Zeit gelaufen ist" and a Kündigung durch Dritte.
    pub const ENDE_KUENDIGUNG_KUNDE: &str = "ZT5";
    /// `ZT6` — `EoG` wegen Kündigung durch LF.
    pub const EOG_KUENDIGUNG_LF: &str = "ZT6";
    /// `ZT7` — `EoG` wegen Kündigung durch Kunde/LFN.
    pub const EOG_KUENDIGUNG_KUNDE: &str = "ZT7";
    /// `ZU1` — Änderung von MSB Abrechnungsdaten.
    pub const AENDERUNG_MSB_ABRECHNUNGSDATEN: &str = "ZU1";
    /// `ZX2` — Abrechnungsdaten BK-Abrechnung erzeugender `MaLo`.
    pub const ABRECHNUNGSDATEN_BK_ERZEUGEND: &str = "ZX2";
    /// `ZX3` — Abrechnungsdaten BK-Abrechnung verbrauchender `MaLo`.
    pub const ABRECHNUNGSDATEN_BK_VERBRAUCHEND: &str = "ZX3";
    /// `ZX4` — Abrechnungsdaten `NNA`.
    pub const ABRECHNUNGSDATEN_NNA: &str = "ZX4";
    /// `ZX5` — Änderung Blindabrechnungsdaten der `NeLo`.
    pub const AENDERUNG_BLINDABRECHNUNGSDATEN_NELO: &str = "ZX5";
    /// `ZX6` — Änderung Daten der `MaLo`.
    pub const AENDERUNG_DATEN_MALO: &str = "ZX6";
    /// `ZX7` — Änderung Daten der `MeLo`.
    pub const AENDERUNG_DATEN_MELO: &str = "ZX7";
    /// `ZX8` — Änderung Daten der `NeLo`.
    pub const AENDERUNG_DATEN_NELO: &str = "ZX8";
    /// `ZX9` — Änderung Daten der `SR` (Steuerbare Ressource).
    pub const AENDERUNG_DATEN_SR: &str = "ZX9";
    /// `ZY0` — Änderung Daten der `TR` (Technische Ressource).
    pub const AENDERUNG_DATEN_TR: &str = "ZY0";
    /// `ZY1` — Änderung Daten der Tranche.
    pub const AENDERUNG_DATEN_TRANCHE: &str = "ZY1";
    /// `ZY2` — Änderung der Lokationsbündelstruktur.
    pub const AENDERUNG_LOKATIONSBUENDELSTRUKTUR: &str = "ZY2";
    /// `ZY4` — Antwort auf `GDA` an `MSB`.
    pub const ANTWORT_GDA_MSB: &str = "ZY4";
    /// `ZY5` — Antwort auf `GDA` (Strom an Gas).
    pub const ANTWORT_GDA_STROM_AN_GAS: &str = "ZY5";
    /// `ZY6` — Antwort auf `GDA` erzeugende `MaLo`.
    pub const ANTWORT_GDA_ERZEUGENDE_MALO: &str = "ZY6";
    /// `ZY7` — Antwort auf `GDA` verbrauchende `MaLo`.
    pub const ANTWORT_GDA_VERBRAUCHENDE_MALO: &str = "ZY7";
    /// `ZY9` — Daten auf individuelle Bestellung.
    pub const DATEN_INDIVIDUELLE_BESTELLUNG: &str = "ZY9";
    /// `ZAM` — Stammdaten `BK`-Treue.
    pub const STAMMDATEN_BK_TREUE: &str = "ZAM";
    /// `ZAN` — Korrektur Abrechnungsdaten BK-Abrechnung verbrauchender `MaLo`.
    pub const KORREKTUR_ABRECHNUNGSDATEN_BK_VERBRAUCHEND: &str = "ZAN";
    /// `ZAO` — Korrektur Abrechnungsdaten BK-Abrechnung erzeugender `MaLo`.
    pub const KORREKTUR_ABRECHNUNGSDATEN_BK_ERZEUGEND: &str = "ZAO";
    /// `ZZA` — Änderung Paket-ID der `MaLo`.
    pub const AENDERUNG_PAKET_ID_MALO: &str = "ZZA";
    /// `ZZD` — Übergangsversorgung.
    ///
    /// „Übergangsversorgung gibt es nur bei Marktlokationen, die unter
    /// § 38a `EnWG` fallen. Grundlage ist eine bilaterale Vereinbarung."
    ///
    /// A Transaktionsgrund, not a Versorgungsart — the `CCI+Z36` code space it
    /// is mistaken for names how a Marktlokation is supplied, and § 38a names
    /// why.
    pub const UEBERGANGSVERSORGUNG: &str = "ZZD";
}

/// `SG4 STS+7` DE 9013 (element 3) — Transaktionsgrundergänzung.
///
/// One element carrying **two disjoint code spaces**, and which one applies
/// follows from the Prüfidentifikator (UTILMD MIG Strom S2.2, `SG4 STS` DE 9013;
/// UTILMD AHB Strom 2.2):
///
/// - **Which kind of object** the Vorgang is about — `ZW3`…`ZW7`, `ZAP`,
///   `ZZB`/`ZZC` — which is the branch of `E_0609` / `E_0624` that applies.
///   Without it an LFA cannot tell a verbrauchende Marktlokation from a Tranche.
/// - **Which Geschäftsvorfall** a Lieferbeginn an einer erzeugenden
///   Marktlokation is — `ZW0`/`ZW1`/`ZW2`, the element `E_0622` Prüfschritte
///   300/310 branch on. The AHB marks these on 55077/55078 and 55601/55603; the
///   object codes do not appear there.
///
/// The `ZW8`…`ZX1` Zuordnungsfälle are a third space in the same element and
/// live in [`zuordnungsfall`].
pub mod ergaenzung {
    /// `ZW0` — Geschäftsvorfall 1 (Anmeldung 100 %).
    ///
    /// „Der LFN wird einer Marktlokation vollständig zugeordnet (vollständige
    /// (100%ige) Zuordnung)." Also the code for turning a tranchierte
    /// Marktlokation back into a nicht-tranchierte one.
    pub const GESCHAEFTSVORFALL_1: &str = "ZW0";
    /// `ZW1` — Geschäftsvorfall 2.
    ///
    /// „Der LFN wird einer **bestehenden** Tranche vollständig zugeordnet",
    /// at a direct handover that keeps the Tranche.
    pub const GESCHAEFTSVORFALL_2: &str = "ZW1";
    /// `ZW2` — Geschäftsvorfall 3.
    ///
    /// „Der LFN wird einer **neu zu bildenden** Tranche zugeordnet (anteiliger
    /// Zuordnungsvorgang unter Bildung neuer Tranchen)." This is the code that
    /// makes the `SG8` Tranchengröße (`9991000002090`) mandatory.
    pub const GESCHAEFTSVORFALL_3: &str = "ZW2";
    /// `ZW3` — Erzeugende Marktlokation.
    pub const ERZEUGENDE_MALO: &str = "ZW3";
    /// `ZW4` — Verbrauchende Marktlokation.
    pub const VERBRAUCHENDE_MALO: &str = "ZW4";
    /// `ZW5` — Tranche.
    pub const TRANCHE: &str = "ZW5";
    /// `ZW6` — Pauschale Marktlokation.
    pub const PAUSCHALE_MALO: &str = "ZW6";
    /// `ZW7` — Gemessene Marktlokation.
    pub const GEMESSENE_MALO: &str = "ZW7";
    /// `ZAP` — Ruhende Marktlokation.
    pub const RUHENDE_MALO: &str = "ZAP";
    /// `ZZB` — Stilllegung **inkl.** Stilllegung der `MaLo`.
    pub const STILLLEGUNG_INKL_MALO: &str = "ZZB";
    /// `ZZC` — Stilllegung **exkl.** Stilllegung der `MaLo`.
    pub const STILLLEGUNG_EXKL_MALO: &str = "ZZC";
}

/// `SG4 STS+7` DE 9013 (element 3) on the **Ankündigung Zuordnung LF**.
///
/// 55607 / 55608 / 55609 use their own code space in the same element:
/// `ZW8`–`ZX1` name Fall 1 to Fall 4 of GPKE Teil 2 § 2.4, and UTILMD AHB Strom
/// 2.2 Bedingungen `[161]`–`[164]` map them one-to-one onto the answering EBD
/// `E_0603`–`E_0606` in `SG4 STS+E01` DE 1131. They are **not** [`ergaenzung`]
/// values, and reading a 55607 through that module yields nothing.
pub mod zuordnungsfall {
    /// `ZW8` — Fall 1: EEG-MaLo bzw. KWKG-MaLo ohne DV-Pflicht (`E_0603`).
    pub const FALL_1: &str = "ZW8";
    /// `ZW9` — Fall 2: EEG-MaLo mit DV-Pflicht (`E_0604`).
    pub const FALL_2: &str = "ZW9";
    /// `ZX0` — Fall 3: KWKG-MaLo mit DV-Pflicht bzw. Nicht-EEG-/Nicht-KWKG-MaLo,
    /// nicht-tranchiert (`E_0605`).
    pub const FALL_3: &str = "ZX0";
    /// `ZX1` — Fall 4: dieselben Fälle, tranchiert abgebildet (`E_0606`).
    pub const FALL_4: &str = "ZX1";
}

/// `NAD` DE 3035 — Beteiligter, Qualifier.
///
/// `MS`/`MR` open `SG2` at message level; the rest are `SG12` parties inside a
/// Vorgang.
pub mod nad {
    /// `MS` — Dokumenten-/Nachrichtenaussteller (message sender).
    pub const ABSENDER: &str = "MS";
    /// `MR` — Nachrichtenempfänger.
    pub const EMPFAENGER: &str = "MR";
    /// `Z09` — **Kunde des Lieferanten**, `SG12`.
    ///
    /// Muss on a 55010 whose Transaktionsgrundergänzung is `ZW4`/`ZAP`
    /// (UTILMD AHB Strom 2.2 Bedingung `[279]`); Bedingung `[572]` says it is
    /// the „Kundenname aus Anmeldung Lieferant neu".
    pub const KUNDE_DES_LF: &str = "Z09";
    /// `NAD+Z04` — Korrespondenzanschrift des Kunden des Lieferanten.
    pub const KORRESPONDENZANSCHRIFT_KUNDE: &str = "Z04";
    /// `VY` — andere zugehörige Partei, `SG12`. On a 55010 the
    /// **Neulieferant** (Bedingung `[567]`).
    pub const ZUGEHOERIGE_PARTEI: &str = "VY";
}

/// `SG10 CCI` DE 7037 — Merkmal codes.
pub mod cci {
    /// `ZB3` — „Zugeordneter Marktpartner".
    pub const ZUGEORDNETER_MARKTPARTNER: &str = "ZB3";
}

/// `SG10 CAV` DE 7111 — Wertecodes.
pub mod cav {
    /// `Z91` — Messstellenbetreiber; DE 1131 carries its MP-ID.
    pub const MSB: &str = "Z91";
    /// `ZF0` — grundzuständiger Messstellenbetreiber; DE 1131 its MP-ID.
    ///
    /// A separate place from [`MSB`] because the two need not be the same
    /// party: a wettbewerblicher MSB operates the Messstelle, and the LFN still
    /// has to know which gMSB it displaced.
    pub const GMSB: &str = "ZF0";
}

/// `SG10 CAV` DE 7110 — the two Merkmalswerte an MSB assignment carries.
///
/// The first says **which kind** of Messstellenbetreiber it is, the second **on
/// what basis** it operates. Both are Muss on a Bestätigung Anmeldung; a
/// message stating only the first is refused with
/// `AHB-…-CAV-7110-MISSING`.
pub mod msb {
    /// `Z39` — grundzuständiger Messstellenbetreiber.
    pub const GRUNDZUSTAENDIG: &str = "Z39";
    /// `Z40` — wettbewerblicher Messstellenbetreiber.
    pub const WETTBEWERBLICH: &str = "Z40";
    /// `Z41` — Auffangmessstellenbetreiber.
    pub const AUFFANG: &str = "Z41";

    /// `Z19` — „Auf vertraglicher Grundlage gegenüber Anschlussnutzer /
    /// Anschlussnehmer".
    pub const GRUNDLAGE_VERTRAG: &str = "Z19";
    /// `Z20` — „In der Ausübung der Weiterverpflichtung durch den gMSB".
    pub const GRUNDLAGE_WEITERVERPFLICHTUNG: &str = "Z20";
}

/// `SG8 SEQ` DE 1229 — the Datenblock an answer opens.
pub mod seq_antwort {
    /// `ZF3` — „Informative Daten der Messlokation".
    pub const DATEN_DER_MESSLOKATION: &str = "ZF3";
}

/// `SG8 RFF` DE 1153 — the Lokation a Datenblock refers to.
pub mod rff_lokation {
    /// `Z19` — Messlokation.
    pub const MESSLOKATION: &str = "Z19";
}

/// `SG6 RFF` DE 1153 — Referenz-Qualifier inside a Vorgang.
pub mod rff {
    /// `Z13` — Prüfidentifikator.
    pub const PRUEFIDENTIFIKATOR: &str = "Z13";
    /// `TN` — Referenz Vorgangsnummer (aus Anfragenachricht).
    pub const REFERENZ_VORGANGSNUMMER: &str = "TN";
    /// `Z60` — „Informativ zur Umsetzung geplantes Produktpaket": the
    /// Produktpaket-ID the NB will implement, Muss on a Bestätigung Anmeldung.
    pub const GEPLANTES_PRODUKTPAKET: &str = "Z60";
}

/// `NAD` `C080` DE 3045 — Format für den Namen des Beteiligten.
pub mod namensformat {
    /// `Z01` — Struktur von Personennamen: the `C080` components are
    /// Nachname, Vorname, …
    pub const PERSON: &str = "Z01";
    /// `Z02` — Struktur der Firmenbezeichnung.
    pub const FIRMA: &str = "Z02";
}

/// `SG8` / `SG10` — the **Produktpaket** an Anmeldung and its Bestätigung carry.
///
/// A UTILMD Anmeldung einer Zuordnung does not merely name a Marktlokation and
/// a date: the AHB makes `SG8 SEQ+Z79` („Bestandteil eines Produktpakets")
/// Muss on 55001, 55077, 55600, 55601, 55014 and 55608, and the Codeliste der
/// Konfigurationen 1.4 Kap. 6.1.1 lists the products that must appear in it.
/// One of them is unconditional:
///
/// > `9991000002082` **Bilanzkreis** — „Dieses Produkt ist je Produktpaket-ID
/// > in der UTILMD zwingend anzugeben."
///
/// So the Bilanzkreis is not a remark beside the answer; it is the answer's
/// mandatory payload, and `SG4 FTX+ACB` is not where it goes — the AHB admits
/// that segment on the Ablehnung only.
pub mod produkt {
    /// `SG8 SEQ` DE 1229 — Bestandteil eines Produktpakets.
    pub const SEQ_PRODUKTPAKET: &str = "Z79";
    /// `SG8 PIA` DE 4347 — Produktidentifikation.
    pub const PIA_ERFORDERLICHES_PRODUKT: &str = "5";
    /// `SG8 PIA` DE 7143 — Produkt.
    pub const PIA_TYP_PRODUKT: &str = "Z11";
    /// `SG10 CCI` DE 7059 — Produkteigenschaft.
    pub const CCI_PRODUKTEIGENSCHAFT: &str = "Z66";
    /// `SG10 CAV` DE 7111 — Code der Produkteigenschaft.
    pub const CAV_EIGENSCHAFT: &str = "ZH9";
    /// `SG10 CAV` DE 7111 — Wertedetails zum Produkt.
    pub const CAV_WERT: &str = "ZV4";
    /// `SG8 SEQ` DE 1229 — Priorisierung erforderliches Produktpaket.
    pub const SEQ_PRIORISIERUNG: &str = "ZH0";
    /// `SG10 CCI` DE 7059 — Umsetzungsgradvorgabe des Produktpakets.
    pub const CCI_UMSETZUNGSGRAD: &str = "Z65";
    /// `SG10 CCI` DE 4051 — Produktpaket ist vollumfänglich umzusetzen.
    pub const UMSETZUNG_VOLLUMFAENGLICH: &str = "Z01";
    /// `SG10 CCI` DE 4051 — Produktpaket kann in Teilen umgesetzt werden.
    pub const UMSETZUNG_IN_TEILEN: &str = "Z02";
    /// Produkt-Code `9991000002082` — **Bilanzkreis**, format `an..17`
    /// (Bedingung `[970]`).
    pub const BILANZKREIS: &str = "9991000002082";
    /// Produkt-Code `9991000002090` — **Tranchengröße**.
    ///
    /// Muss in Geschäftsvorfall 3 of an Anmeldung einer Zuordnung des LFN
    /// („`STS+7++xxx+ZW2`"), bestellbar over 55077 and 55601, max. once per
    /// Produktpaket-ID (Codeliste der Konfigurationen 1.4 Kap. 6.1.1).
    pub const TRANCHENGROESSE: &str = "9991000002090";
    /// `CAV+ZH9` — Tranchengröße als **prozentuale Aufteilung**.
    ///
    /// The Wertedetails are then Muss, and Bedingung `[914]` fixes the range:
    /// „Möglicher Wert: > 0".
    pub const TRANCHE_PROZENTUALE_AUFTEILUNG: &str = "9991000003014";
    /// `CAV+ZH9` — Tranchengröße als **Aufteilungsfaktor** auf Basis von
    /// Referenzträger bzw. installierter Leistung.
    pub const TRANCHE_AUFTEILUNGSFAKTOR: &str = "9991000003022";
    /// `CAV+ZH9` — Tranchengröße als **Aufteilung auf Technische Ressourcen**.
    ///
    /// The Wertedetails then name every Technische Ressource to assign, so the
    /// value is not a single number.
    pub const TRANCHE_AUFTEILUNG_TR: &str = "9991000003220";
    /// `SG10 CCI` DE 7059 — **Bilanzkreis**, the `GeLi` Gas shape.
    ///
    /// `GeLi` Gas has no Produktpaket: UTILMD AHB Gas 1.2 marks `SG10 CCI+Z19`
    /// with the Bilanzkreis in DE 7037 Muss on 44001 and on the Bestandsliste
    /// family. The Strom Produktpaket and this segment carry the same fact and
    /// are not interchangeable.
    pub const CCI_BILANZKREIS_GAS: &str = "Z19";
    /// `SG10 CAV` DE 7111 — Priorisierung erforderliches Produktpaket, 1. to
    /// 5. Priorität. Bedingung `[42]` requires it only where a Geschäftsvorfall
    /// carries more than one Produktpaket; the AHB caps it at five.
    pub const PRIORITAET: [&str; 5] = ["Z75", "Z76", "Z77", "Z78", "Z79"];
}

// ── Typed SG4 payloads ────────────────────────────────────────────────────────

/// One entry of a `SG8 SEQ+Z79` Produktpaket: a Produkt-Code with the
/// Produkteigenschaft and the Merkmalswert the Codeliste attaches to it.
///
/// Serialises as
///
/// ```text
/// SEQ+Z79+1
/// PIA+5+9991000002082:Z11
/// CCI+Z66
/// CAV+ZV4:::11XBK-EEG-----1
/// ```
///
/// `CAV+ZH9` is emitted only where the Codeliste gives the product a Code der
/// Produkteigenschaft; the Bilanzkreis has none („--"), and Bedingung `[36]`
/// makes the segment conditional on exactly that.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Produkt {
    /// `SG8 PIA+5` DE 7140 — the Produkt-Code from the Codeliste der
    /// Konfigurationen Kap. 6.1.
    pub produkt_code: String,
    /// `SG10 CAV+ZH9` DE 7110 — Code der Produkteigenschaft, where the product
    /// defines a Wertebereich.
    pub eigenschaft: Option<String>,
    /// `SG10 CAV+ZV4` DE 7110 — Merkmalswert, „Wertedetails für Position".
    pub wert: Option<String>,
}

impl Produkt {
    /// The mandatory **Bilanzkreis** product (`9991000002082`), whose
    /// Merkmalswert is the Bilanzkreis itself.
    #[must_use]
    pub fn bilanzkreis(bk: impl Into<String>) -> Self {
        Self {
            produkt_code: produkt::BILANZKREIS.to_owned(),
            eigenschaft: None,
            wert: Some(bk.into()),
        }
    }

    /// The **Tranchengröße** product (`9991000002090`).
    ///
    /// Codeliste der Konfigurationen 1.4 Kap. 6.1.1: „Im Geschäftsvorfall 3 der
    /// Anmeldung einer Zuordnung des LFN `STS+7++xxx+ZW2` … ist **zwingend**
    /// dieses Produkt anzugeben", bestellbar über 55077 und 55601, höchstens
    /// einmal je Produktpaket-ID.
    ///
    /// The Eigenschaft travels with the value rather than being assumed,
    /// because the three forms are not interchangeable — see
    /// [`Tranchengroesse`].
    #[must_use]
    pub fn tranchengroesse(groesse: &Tranchengroesse) -> Self {
        Self {
            produkt_code: produkt::TRANCHENGROESSE.to_owned(),
            eigenschaft: groesse.eigenschaft.clone(),
            wert: Some(groesse.wert.clone()),
        }
    }
}

/// The `SG8` **Tranchengröße** of a Geschäftsvorfall 3, as it stands on the wire.
///
/// Three Produkteigenschaften share the Produkt-Code `9991000002090` and they
/// are not interchangeable (Codeliste der Konfigurationen 1.4 Kap. 6.1.1):
/// a percentage, an Aufteilungsfaktor on a Referenzträger or the installierte
/// Leistung, and a list of Technische Ressourcen. Only the first is a share
/// `E_0623` Prüfschritte 510–530 can add up, so the Eigenschaft travels with
/// the value instead of being assumed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tranchengroesse {
    /// `SG10 CAV+ZH9` DE 7110 — the Code der Produkteigenschaft, where stated.
    pub eigenschaft: Option<String>,
    /// `SG10 CAV+ZV4` DE 7110 — the Merkmalswert, verbatim.
    pub wert: String,
}

impl Tranchengroesse {
    /// The **prozentuale Aufteilung** form (`9991000003014`).
    ///
    /// `wert` is the share as the wire carries it. Codeliste der
    /// Konfigurationen 1.4 Kap. 6.1.1 attaches `[914] ∧ [930] ∧ [955]` to it —
    /// greater than zero, at most two decimal places, less than 100 — which
    /// [`is_valid_prozent_wert`] checks; this crate holds the grammar, not the
    /// arithmetic, so the caller decides what to do about a value that fails.
    #[must_use]
    pub fn prozent(wert: impl Into<String>) -> Self {
        Self {
            eigenschaft: Some(produkt::TRANCHE_PROZENTUALE_AUFTEILUNG.to_owned()),
            wert: wert.into(),
        }
    }

    /// The **Aufteilungsfaktor** form (`9991000003022`) — a factor on a
    /// Referenzträger or the installierte Leistung, not a share.
    #[must_use]
    pub fn aufteilungsfaktor(wert: impl Into<String>) -> Self {
        Self {
            eigenschaft: Some(produkt::TRANCHE_AUFTEILUNGSFAKTOR.to_owned()),
            wert: wert.into(),
        }
    }

    /// The **Aufteilung auf Technische Ressourcen** form (`9991000003220`).
    ///
    /// The Wertedetails name every Technische Ressource to assign, so `wert` is
    /// a list and not a number.
    #[must_use]
    pub fn technische_ressourcen(wert: impl Into<String>) -> Self {
        Self {
            eigenschaft: Some(produkt::TRANCHE_AUFTEILUNG_TR.to_owned()),
            wert: wert.into(),
        }
    }

    /// The value, but only where the Eigenschaft says it is a **percentage**.
    ///
    /// `None` for the Aufteilungsfaktor and the Technische-Ressourcen forms:
    /// neither is a share `E_0623` Prüfschritte 510–530 can add up, and reading
    /// one as one would put a fabricated percentage into the Tranchen
    /// arithmetic. Returned verbatim — the range Bedingung `[914]` fixes
    /// („Möglicher Wert: > 0") is the reader's to enforce, since this crate
    /// carries no decimal arithmetic.
    #[must_use]
    pub fn prozent_wert(&self) -> Option<&str> {
        (self.eigenschaft.as_deref() == Some(produkt::TRANCHE_PROZENTUALE_AUFTEILUNG))
            .then_some(self.wert.as_str())
    }
}

/// `SG10 CCI+Z65` DE 4051 — how much of a Produktpaket the NB must honour.
///
/// UTILMD AHB Strom 2.2 Kap. 5.3: `Z01` means the NB may only assign the LF
/// when **every** product of the package can be applied from the
/// Zuordnungsbeginn; `Z02` means a partial application is enough — „unabhängig
/// vom Bilanzkreis, der immer erfüllt sein muss".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Umsetzungsgrad {
    /// `Z01` — Produktpaket ist vollumfänglich umzusetzen.
    #[default]
    Vollumfaenglich,
    /// `Z02` — Produktpaket kann in Teilen umgesetzt werden.
    InTeilen,
}

impl Umsetzungsgrad {
    /// The `CCI+Z65` DE 4051 code.
    #[must_use]
    pub const fn code(self) -> &'static str {
        match self {
            Self::Vollumfaenglich => produkt::UMSETZUNG_VOLLUMFAENGLICH,
            Self::InTeilen => produkt::UMSETZUNG_IN_TEILEN,
        }
    }
}

/// A `SG8 SEQ+Z79` Produktpaket — a Produktpaket-ID, its products, and the
/// `SG8 SEQ+ZH0` Umsetzungsgradvorgabe that goes with it.
///
/// DE 1050 is „Produktpaket-ID", Bedingungen `[914]` ∧ `[937]`: a positive integer
/// without decimals. A Geschäftsvorfall carries at most five (AHB Kap. 5.3),
/// and every one of them needs its own `SEQ+ZH0` — the AHB marks that group
/// Muss, so a Produktpaket emitted without it is an incomplete message.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Produktpaket {
    /// `SG8 SEQ` DE 1050 — the Produktpaket-ID.
    pub paket_id: u32,
    /// The products in the package, in Codeliste order.
    pub produkte: Vec<Produkt>,
    /// `SG10 CCI+Z65` DE 4051 — the Umsetzungsgradvorgabe.
    pub umsetzung: Umsetzungsgrad,
}

impl Produktpaket {
    /// The single-product package a Zuordnung needs: Produktpaket 1 carrying
    /// the Bilanzkreis, to be applied in full.
    ///
    /// This is the package for Geschäftsvorfall 1 and 2. A Geschäftsvorfall 3
    /// needs [`Produktpaket::tranchenbildung`] — the Codeliste makes the
    /// Tranchengröße mandatory there, and it is not in this package.
    #[must_use]
    pub fn bilanzkreis(bk: impl Into<String>) -> Self {
        Self {
            paket_id: 1,
            produkte: vec![Produkt::bilanzkreis(bk)],
            umsetzung: Umsetzungsgrad::Vollumfaenglich,
        }
    }

    /// Produktpaket 1 for a **Geschäftsvorfall 3** — the Bilanzkreis plus the
    /// Tranchengröße, to be applied in full.
    ///
    /// Both products are verpflichtend per Produktpaket-ID (Codeliste der
    /// Konfigurationen 1.4 Kap. 6.1.1): the Bilanzkreis „ist je Produktpaket-ID
    /// in der UTILMD zwingend anzugeben", and the Tranchengröße is „im
    /// Geschäftsvorfall 3 … zwingend". An Anmeldung under `ZW2` carrying only
    /// the Bilanzkreis is one the NB has to refuse.
    #[must_use]
    pub fn tranchenbildung(bk: impl Into<String>, groesse: &Tranchengroesse) -> Self {
        Self {
            paket_id: 1,
            produkte: vec![Produkt::bilanzkreis(bk), Produkt::tranchengroesse(groesse)],
            umsetzung: Umsetzungsgrad::Vollumfaenglich,
        }
    }
}

/// `true` when `wert` satisfies the prozentuale Tranchengröße's own Bedingungen.
///
/// Codeliste der Konfigurationen 1.4 Kap. 6.1.1 attaches three to the
/// Wertedetails of `9991000003014`: `[914]` „Möglicher Wert: > 0", `[930]`
/// „max. 2 Nachkommastellen", `[955]` „Möglicher Wert: < 100". Both bounds are
/// strict, so neither 0 nor 100 is a Tranche — a 100 % Zuordnung is
/// Geschäftsvorfall 1 and carries no Tranchengröße at all.
///
/// Checked lexically on the decimal the wire carries, because that is what the
/// Bedingungen are written about: `10.5` and `10,5` are the same share and
/// `10.500` is a third decimal place whatever its value.
#[must_use]
pub fn is_valid_prozent_wert(wert: &str) -> bool {
    let (ganz, bruch) = match wert.split_once(['.', ',']) {
        Some((g, b)) => (g, b),
        None => (wert, ""),
    };
    if ganz.is_empty() && bruch.is_empty() {
        return false;
    }
    if !ganz.chars().all(|c| c.is_ascii_digit())
        || !bruch.chars().all(|c| c.is_ascii_digit())
        || bruch.len() > 2
    {
        return false;
    }
    // `[914]` > 0 and `[955]` < 100, on the digits themselves: no decimal type
    // here, and a parse would turn a malformed value into a plausible one.
    let ganz_wert: u32 = if ganz.is_empty() {
        0
    } else if ganz.len() > 3 {
        return false;
    } else {
        ganz.parse().unwrap_or(u32::MAX)
    };
    if ganz_wert >= 100 {
        return false;
    }
    ganz_wert > 0 || bruch.chars().any(|c| c != '0')
}

/// `SG4 STS+7` — Transaktionsgrund, Ergänzung and befristete Anmeldung.
///
/// Serialises as `STS+7++<grund>+<ergaenzung>+<befristet>'` — the MIG's own
/// example is `STS+7++E01+ZW4+E03'`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Transaktionsgrund {
    /// DE 9013 element 2 — the Transaktionsgrund (`E01`, `E03`, `Z33`, …).
    pub grund: String,
    /// DE 9013 element 3 — the Transaktionsgrundergänzung (`ZW3`…`ZAP`).
    pub ergaenzung: Option<String>,
    /// DE 9013 element 4 — Transaktionsgrund für das Lieferende einer
    /// befristeten Anmeldung.
    pub befristet: Option<String>,
}

impl Transaktionsgrund {
    /// A Transaktionsgrund for a **verbrauchende Marktlokation** — the ordinary
    /// case, and the one the AHB marks Muss on the GPKE core processes.
    #[must_use]
    pub fn verbrauchende_malo(grund: impl Into<String>) -> Self {
        Self {
            grund: grund.into(),
            ergaenzung: Some(ergaenzung::VERBRAUCHENDE_MALO.to_owned()),
            befristet: None,
        }
    }

    /// A Transaktionsgrund with an explicit Ergänzung.
    #[must_use]
    pub fn new(grund: impl Into<String>, ergaenzung: impl Into<String>) -> Self {
        Self {
            grund: grund.into(),
            ergaenzung: Some(ergaenzung.into()),
            befristet: None,
        }
    }

    /// A Transaktionsgrund with **no Ergänzung** — the `WiM` MSB-Wechsel shape.
    ///
    /// The `WiM` Anwendungsübersichten list `SG4 STS 9015 = 7` with DE 9013 and
    /// nothing after it (UTILMD AHB Strom 2.2 Kap. 10, Gas 1.2 Kap. 6). The
    /// GPKE Ergänzung (`ZW4` verbrauchende Marktlokation and friends) names a
    /// property of a *Marktlokation*, and a `WiM` Vorgang is keyed on the
    /// Messlokation — emitting one asserts something the Anwendungsfall has no
    /// element for.
    #[must_use]
    pub fn bare(grund: impl Into<String>) -> Self {
        Self {
            grund: grund.into(),
            ergaenzung: None,
            befristet: None,
        }
    }

    /// Attach the DE 9013 element 4 (befristete Anmeldung) code.
    #[must_use]
    pub fn befristet(mut self, code: impl Into<String>) -> Self {
        self.befristet = Some(code.into());
        self
    }
}

/// `SG4 STS+E01` — the EBD Antwortcode on a Bestätigung or Ablehnung.
///
/// Serialises as `STS+E01++<code>:<ebd>'`. The AHB marks this segment **Muss**
/// on every Antwortnachricht and constrains the code to the Zustimmungs- or
/// Ablehnungs-Cluster of the named EBD, so it is not optional metadata: an
/// answer without it is not a well-formed answer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AntwortStatus {
    /// DE 9013 — „Code des Prüfschritts" (`A10`, `A35`, `E15`, `Z12`, …).
    pub code: String,
    /// DE 1131 — the **Codeliste** the code is drawn from.
    ///
    /// The AHB prints one of two things in this column, and they are not
    /// interchangeable:
    ///
    /// | AHB wording | Example | Where |
    /// |---|---|---|
    /// | „EBD Nr. `E_xxxx`" | `E_0622`, `E_3005` | GPKE and `GeLi` Gas answers |
    /// | „Codeliste Strom/Gas Nr. `S_xxxx`/`G_xxxx`" | `S_0090`, `G_0051` | every `WiM` MSB-Wechsel answer |
    ///
    /// **Both Sparten require it.** UTILMD AHB Gas 1.2 Kap. 6.1 marks
    /// `SG4 STS 1131` with an `X` on 44040/44041 and names `G_0052`/`G_0051`.
    ///
    /// `None` only for an answer whose AHB column really is empty.
    pub codeliste: Option<String>,
}

/// `SG4 STS+Z35` — the **third market participant's** answer, restated by the
/// party that is refusing on the strength of it.
///
/// Only the Ablehnung einer Anmeldung uses it, and only when the ground is the
/// LFA's Widerspruch (`A50` verbrauchend, `A57` erzeugend). The erzeugende form
/// carries two more things than the verbrauchende one, because Geschäftsvorfall
/// 3 splits a Marktlokation across Tranchen and several LFA answer: which object
/// the restated answer is about (`ZW3` Erzeugende Marktlokation / `ZW5` Tranche)
/// and its MaLo-ID (DE 9012, UTILMD AHB Strom 2.2 Bedingung `[950]`).
///
/// Wire form: `STS+Z35++A35:E_0624'` on a 55003,
/// `STS+Z35+51238696781+A39:E_0624+ZW5'` on a 55080.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DritterAntwortStatus {
    /// DE 9013 — the third party's own Prüfschritt code.
    ///
    /// „Bis auf den Code `A30` sind alle Codes aus EBD `E_0624` im Cluster
    /// Ablehnung erlaubt" (Bedingung `[366]`; `[368]` says `A41` on the
    /// erzeugende branch) — the „bereits abgemeldet" answer confirms the
    /// Anmeldung instead, so it never reaches this segment.
    pub code: String,
    /// DE 1131 — always `E_0624`, the tree the LFA answered from.
    pub codeliste: String,
    /// DE 9012 in `C555` — „Referenz auf ID der Marktlokation / Tranche".
    ///
    /// `None` on a 55003, whose AHB column is empty: a verbrauchende
    /// Marktlokation has exactly one LFA and the Vorgang already names it.
    pub referenz_lokation: Option<String>,
    /// The second DE 9013 — `ZW3` Erzeugende Marktlokation or `ZW5` Tranche.
    ///
    /// `None` on a 55003, for the same reason.
    pub objekt: Option<String>,
}

impl DritterAntwortStatus {
    /// The verbrauchende form — code and Codeliste only (PID 55003).
    #[must_use]
    pub fn verbrauchend(code: impl Into<String>) -> Self {
        Self {
            code: code.into(),
            codeliste: EBD_BEENDIGUNG_ZUORDNUNG.to_owned(),
            referenz_lokation: None,
            objekt: None,
        }
    }

    /// The erzeugende form — additionally naming the object the restated answer
    /// is about (PID 55080).
    #[must_use]
    pub fn erzeugend(
        code: impl Into<String>,
        referenz_lokation: impl Into<String>,
        objekt: impl Into<String>,
    ) -> Self {
        Self {
            code: code.into(),
            codeliste: EBD_BEENDIGUNG_ZUORDNUNG.to_owned(),
            referenz_lokation: Some(referenz_lokation.into()),
            objekt: Some(objekt.into()),
        }
    }
}

/// The EBD a `SG4 STS+Z35` always names in DE 1131 — the tree the LFA answered
/// the Anfrage zur Beendigung der Zuordnung from.
pub const EBD_BEENDIGUNG_ZUORDNUNG: &str = "E_0624";

/// The `E_0623` Ablehnungscodes that make a `SG4 STS+Z35` **Muss**.
///
/// `A50` on a verbrauchende oder ruhende Marktlokation (Bedingung `[356]`),
/// `A57` on an erzeugende one (`[84]`). Both mean „der LFA hat der Anfrage zur
/// Beendigung der Zuordnung widersprochen", and neither is answerable without
/// naming the LFA's own Grund.
pub const CODES_REQUIRING_DRITTER: &[&str] = &["A50", "A57"];

impl AntwortStatus {
    /// An Antwortcode together with the Codeliste DE 1131 must name.
    ///
    /// Ask [`mako_pruefung::codes::AntwortCode::wire_codeliste`] for the second
    /// argument — it is the EBD number only where the AHB says „EBD-Nummer".
    ///
    /// [`mako_pruefung::codes::AntwortCode::wire_codeliste`]: https://docs.rs/mako-pruefung
    #[must_use]
    pub fn from_codeliste(code: impl Into<String>, codeliste: impl Into<String>) -> Self {
        Self {
            code: code.into(),
            codeliste: Some(codeliste.into()),
        }
    }

    /// A bare Antwortcode, for the few answers whose DE 1131 column is empty.
    #[must_use]
    pub fn bare(code: impl Into<String>) -> Self {
        Self {
            code: code.into(),
            codeliste: None,
        }
    }
}

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

    /// The MIG's own worked example, kept as a test so a future edit to the
    /// element order fails loudly.
    #[test]
    fn the_mig_example_shape_is_grund_ergaenzung_befristet() {
        let t = Transaktionsgrund::verbrauchende_malo(transaktionsgrund::EIN_AUSZUG)
            .befristet(transaktionsgrund::WECHSEL);
        assert_eq!(t.grund, "E01");
        assert_eq!(t.ergaenzung.as_deref(), Some("ZW4"));
        assert_eq!(t.befristet.as_deref(), Some("E03"));
    }

    #[test]
    fn ide_carries_a_vorgang_not_a_location() {
        // DE 7495 has exactly two values in UTILMD. `Z19` is a location
        // qualifier and belongs in SG5 LOC.
        assert_eq!(IDE_VORGANG, "24");
        assert_eq!(loc::STEUERBARE_RESSOURCE, "Z19");
        assert_ne!(IDE_VORGANG, loc::MARKTLOKATION);
    }
}

#[cfg(all(test, feature = "utilmd"))]
mod de9013_tests {
    use super::STS_TRANSAKTIONSGRUND;
    use crate::registry::ReleaseRegistry;

    /// `SG4 STS+7` DE 9013 has three composites, and each is its own code
    /// space. The MIG's own tables are the source; this holds the named
    /// constants against them, so a re-import that adds or drops a code fails
    /// here rather than at a counterparty.
    ///
    /// A code missing from element 2 is a Geschäftsvorfall mako cannot name; a
    /// code missing from element 3 is one it reads as „not stated", which on
    /// 55077 turns a decidable Anmeldung into an escalation.
    #[test]
    fn the_de9013_tables_are_the_mig_tables() {
        for (name, shipped, published) in [
            ("element 2", SHIPPED_GRUND, &de9013(0)[..]),
            ("element 3", SHIPPED_ERGAENZUNG, &de9013(1)[..]),
        ] {
            let mut have: Vec<&str> = shipped.to_vec();
            have.sort_unstable();
            have.dedup();
            let mut want: Vec<&str> = published.to_vec();
            want.sort_unstable();
            want.dedup();
            assert_eq!(have, want, "{name} drifted from the UTILMD MIG");
        }
    }

    /// The `occurrence`-th DE 9013 code table of the Transaktionsgrund `STS`,
    /// as the newest UTILMD Strom MIG publishes it.
    ///
    /// Strom and Gas agree on both tables; the Strom track is the one both are
    /// quoted from. The segment is found by its own Statuskategorie — the MIG
    /// renumbers its segments between Nachrichtentypversionen.
    fn de9013(occurrence: usize) -> Vec<&'static str> {
        let profile = ReleaseRegistry::global()
            .all_profiles()
            .iter()
            .filter(|p| p.mig.message_type == "UTILMD" && p.mig.track.as_deref() == Some("Strom"))
            .max_by(|a, b| a.mig.valid_from.cmp(&b.mig.valid_from))
            .expect("a UTILMD Strom profile is embedded");
        let sts = profile
            .mig
            .segments()
            .into_iter()
            .find(|s| {
                s.tag == "STS"
                    && s.elements
                        .iter()
                        .flat_map(|e| e.components.iter())
                        .any(|c| {
                            c.id == "9015"
                                && c.codes.iter().any(|k| k.code == STS_TRANSAKTIONSGRUND)
                        })
            })
            .expect("UTILMD SG4 carries the Transaktionsgrund STS");
        let tables: Vec<Vec<&str>> = sts
            .elements
            .iter()
            .flat_map(|el| el.components.iter())
            .filter(|c| c.id == "9013")
            .map(|c| c.codes.iter().map(|k| k.code.as_str()).collect())
            .collect();
        assert_eq!(
            tables.len(),
            3,
            "the MIG prints DE 9013 three times in {} {}",
            sts.nr,
            sts.name
        );
        tables[occurrence].clone()
    }

    /// Element 2 — the Transaktionsgründe [`super::transaktionsgrund`] names.
    const SHIPPED_GRUND: &[&str] = {
        use super::transaktionsgrund as g;
        &[
            g::EIN_AUSZUG,
            g::EINZUG_NEUANLAGE,
            g::WECHSEL,
            g::STORNIERUNG,
            g::ERSATZBELIEFERUNG,
            g::KUENDIGUNG_LRV,
            g::INFO_EXISTIERENDE_ZUORDNUNG,
            g::AUSZUG_STILLLEGUNG,
            g::EOG_UMZUG,
            g::EOG_NEUANLAGE,
            g::EOG_VORUEBERGEHEND,
            g::ESV_ENDE_OHNE_FOLGE,
            g::EOG_BK_SCHLIESSUNG,
            g::EOG_ZUORDNUNGSERMAECHTIGUNG,
            g::BEENDIGUNG_ZUORDNUNG,
            g::BEENDIGUNG_RUECKZUORDNUNG,
            g::AUFHEBUNG_EEG38,
            g::BEENDIGUNG_EEG38,
            g::AUFHEBUNG_AUSZUG,
            g::AUFHEBUNG_FRUEHERE_ANMELDUNG,
            g::AUFHEBUNG_STILLLEGUNG,
            g::AUFHEBUNG_VERTRAGSVERHAELTNIS,
            g::ZUSAETZLICHER_DATENSATZ,
            g::STAMMDATENAENDERUNG,
            g::UEBERNAHME_KEIN_IMS,
            g::STAMMDATEN,
            g::WERTE,
            g::ABMELDUNG_FEHLENDE_ZUORDNUNGSERMAECHTIGUNG,
            g::KUENDIGUNG_ANSCHLUSSNEHMER,
            g::ABMELDUNG_FEHLENDE_ZE_ZRT,
            g::ENDE_KUENDIGUNG_LF,
            g::ENDE_KUENDIGUNG_KUNDE,
            g::EOG_KUENDIGUNG_LF,
            g::EOG_KUENDIGUNG_KUNDE,
            g::AENDERUNG_MSB_ABRECHNUNGSDATEN,
            g::ABRECHNUNGSDATEN_BK_ERZEUGEND,
            g::ABRECHNUNGSDATEN_BK_VERBRAUCHEND,
            g::ABRECHNUNGSDATEN_NNA,
            g::AENDERUNG_BLINDABRECHNUNGSDATEN_NELO,
            g::AENDERUNG_DATEN_MALO,
            g::AENDERUNG_DATEN_MELO,
            g::AENDERUNG_DATEN_NELO,
            g::AENDERUNG_DATEN_SR,
            g::AENDERUNG_DATEN_TR,
            g::AENDERUNG_DATEN_TRANCHE,
            g::AENDERUNG_LOKATIONSBUENDELSTRUKTUR,
            g::ANTWORT_GDA_MSB,
            g::ANTWORT_GDA_STROM_AN_GAS,
            g::ANTWORT_GDA_ERZEUGENDE_MALO,
            g::ANTWORT_GDA_VERBRAUCHENDE_MALO,
            g::DATEN_INDIVIDUELLE_BESTELLUNG,
            g::STAMMDATEN_BK_TREUE,
            g::KORREKTUR_ABRECHNUNGSDATEN_BK_VERBRAUCHEND,
            g::KORREKTUR_ABRECHNUNGSDATEN_BK_ERZEUGEND,
            g::AENDERUNG_PAKET_ID_MALO,
            g::UEBERGANGSVERSORGUNG,
        ]
    };

    /// Element 3 — the Transaktionsgrundergänzungen. `ZW8`…`ZX1` are the
    /// Zuordnungsfälle and live in their own module; the element is one code
    /// space on the wire, so they belong here.
    const SHIPPED_ERGAENZUNG: &[&str] = {
        use super::{ergaenzung as e, zuordnungsfall as z};
        &[
            e::GESCHAEFTSVORFALL_1,
            e::GESCHAEFTSVORFALL_2,
            e::GESCHAEFTSVORFALL_3,
            e::ERZEUGENDE_MALO,
            e::VERBRAUCHENDE_MALO,
            e::TRANCHE,
            e::PAUSCHALE_MALO,
            e::GEMESSENE_MALO,
            e::RUHENDE_MALO,
            e::STILLLEGUNG_INKL_MALO,
            e::STILLLEGUNG_EXKL_MALO,
            z::FALL_1,
            z::FALL_2,
            z::FALL_3,
            z::FALL_4,
        ]
    };
}