├── .github └── workflows │ └── ci.yml ├── CONTRIBUTORS.md ├── EXAMPLES.md ├── LICENSE ├── README.md ├── composer.json ├── ecs.php └── src └── EDI ├── Analyser.php ├── Encoder.php ├── Interpreter.php ├── Parser.php ├── Reader.php └── ReaderException.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | types: 7 | - opened 8 | - synchronize 9 | 10 | jobs: 11 | build-test: 12 | runs-on: ubuntu-24.04 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Cache Composer dependencies 16 | uses: actions/cache@v4 17 | with: 18 | path: /tmp/composer-cache 19 | key: ${{ runner.os }}-${{ hashFiles('**/composer.lock') }} 20 | - uses: php-actions/composer@v6 21 | - name: PHPUnit tests 22 | run: ./vendor/bin/phpunit 23 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | Contributors 2 | ------------- 3 | 4 | * Stefano Sabatini https://github.com/sabas 5 | * Uldis Nelsons https://github.com/uldisn 6 | * Marius Teller https://github.com/Xiro -------------------------------------------------------------------------------- /EXAMPLES.md: -------------------------------------------------------------------------------- 1 | ##Examples 2 | 3 | Load a message and dump a json 4 | ------------------------------ 5 | Loading from file: 6 | ```php 7 | use EDI\Parser; 8 | $filePath = "example.edi"; 9 | $p = new EDI\Parser(); 10 | $p->load($filePath)->parse(); 11 | $p->checkEncoding(); // optional 12 | if (count($p->errors()) > 0) { 13 | echo json_encode($p->errors()); 14 | return; 15 | } 16 | echo json_encode($p->get()); 17 | ``` 18 | To load a single-line string (`"seg'seg"`) use `$p->loadString()`. 19 | To load an array of lines (`["seg","seg"]`) use `$p->loadArray()`. 20 | 21 | Convert a formatted array to EDIFACT message 22 | -------------------------------------------- 23 | Loading from a PHP array: 24 | ```php 25 | use EDI\Encoder; 26 | $arr = []; //array 27 | $enc = new EDI\Encoder($arr, false); //one segment per line 28 | echo $enc->get(); 29 | ``` 30 | 31 | Create human-readable file with comments from EDI file 32 | ------------------------------------------------------ 33 | 34 | ```php 35 | $filePath = 'demo.edi'; 36 | $parser = new EDI\Parser(); 37 | $parser->load($filePath); 38 | $segments = $parser->getRawSegments(); 39 | 40 | $analyser = new EDI\Analyser(); 41 | $analyser->loadSegmentsXml('edifact/src/EDI/Mapping/d95b/segments.xml'); 42 | 43 | $text = $analyser->process($parsed, $parser->get()); 44 | ``` 45 | 46 | EDI data reading from extracted group 47 | ------------------------------------- 48 | 49 | As not to have to go through the indexes for extracted groups, just use a reader with a different parser. 50 | 51 | E.g. inventory messages (snippet, not a valid EDI message!): 52 | 53 | ``` 54 | INV+2++1'QTY+156:1000:PCE'QTY+145:3000:PCE'LOC+18+YA:::567'DTM+179:20180509:102'RFF+AAK:TEST'DTM+171:20180509:102' 55 | INV+1++11'QTY+156:200:PCE'QTY+145:2800:PCE'LOC+18+YA:::567'DTM+179:20180509:102'RFF+ALO:4916165350'DTM+171:20180509:102' 56 | INV+1++11'QTY+156:200:PCE'QTY+145:2600:PCE'LOC+18+YA:::567'DTM+179:20180509:102'RFF+ALO:4916165351'DTM+171:20180509:102' 57 | INV+1++11'QTY+156:200:PCE'QTY+145:2400:PCE'LOC+18+YA:::567'DTM+179:20180509:102'RFF+ALO:4916165352'DTM+171:20180509:102' 58 | INV+1++11'QTY+156:100:PCE'QTY+145:2300:PCE'LOC+18+YA:::567'DTM+179:20180510:102'RFF+ALO:4916165359'DTM+171:20180510:102' 59 | ``` 60 | 61 | ```php 62 | $parser = new EDI\Parser(); 63 | $parser->load($filePath); 64 | $reader = new EDI\Reader($parser); 65 | $groups = $reader->groupsExtract('INV'); 66 | 67 | foreach ($groups as $record) { 68 | $parser->loadArray($record, false); 69 | $r = new EDI\Reader($parser); 70 | $records[] = [ 71 | 'storageLocation' => $r->readEdiDataValue(['LOC', ['2.0' => 'YA']], 2, 3), 72 | 'bookingDate' => $r->readEdiSegmentDTM(179), 73 | 'enteredOn' => $r->readEdiSegmentDTM(171), 74 | 'quantity' => $r->readEdiDataValue(['QTY', ['1.0' => 156]], 1, 1), 75 | 'actualStock' => $r->readEdiDataValue(['QTY', ['1.0' => 145]], 1, 1) 76 | ]; 77 | } 78 | ``` 79 | 80 | Readable EDI file 81 | ----------------- 82 | ``` 83 | UNB+UNOE:2+RIXCT++141028:0746+NBFILE027747' 84 | ``` 85 | 86 | ``` 87 | UNB - InterchangeHeader 88 | (To start, identify and specify an interchange) 89 | [1] UNOE,2 90 | unb1 - syntaxIdentifier 91 | Syntax identifier 92 | [0] UNOE 93 | id: unb11 - syntaxIdentifier 94 | Syntax identifier 95 | type: a 96 | required: true 97 | length: 4 98 | [1] 2 99 | id: unb12 - syntaxVersionNumber 100 | Syntax version number 101 | type: n 102 | required: true 103 | length: 1 104 | [2] RIXCT 105 | unb2 - interchangeSender 106 | Interchange sender 107 | [3] 108 | unb3 - interchangeRecipient 109 | Interchange recipient 110 | [4] 141028,0746 111 | unb4 - dateTimePreparation 112 | Date Time of preparation 113 | [0] 141028 114 | id: unb41 - date 115 | type: n 116 | required: true 117 | length: 6 118 | [1] 0746 119 | id: unb42 - time 120 | type: n 121 | required: true 122 | length: 4 123 | [5] NBFILE027747 124 | unb5 - interchangeControlReference 125 | ``` 126 | 127 | EDI data element reading 128 | ------------------------ 129 | 130 | ```php 131 | $filePath = 'files/truck_out_176699.edi'; 132 | $parser = new EDI\Parser(); 133 | $parser->load($filePath); 134 | $reader = new EDI\Reader($parser); 135 | 136 | $record = [ 137 | 'interchangeSender' => $reader->readEdiDataValue('UNB', 2), 138 | 'arrivalDateTimeEstimated' => $reader->$EdiReader->readEdiSegmentDTM('132'), 139 | 'messageReferenceNumber' => $reader->readEdiDataValue('UNH', 1), 140 | 'TareWeight' => $reader->readEdiDataValue(['MEA', ['2' => 'T']], 3, 0) 141 | . ' ' 142 | . $reader->readEdiDataValue(['MEA', ['2' => 'T']], 3, 1), 143 | 'GrossWeight' => $reader->readEdiDataValue(['MEA', ['2' => 'G']], 3, 0) 144 | . ' ' 145 | . $reader->readEdiDataValue(['MEA', ['2' => 'G']], 3, 1), 146 | ]; 147 | 148 | //error processing 149 | $readerErrors = $EdiReader->errors(); 150 | if (!empty($readerErrors)) { 151 | var_dump($readerErrors); 152 | } 153 | var_dump($record); 154 | ``` 155 | 156 | Demo 157 | ------- 158 | 159 | Message from Wikipedia page http://en.wikipedia.org/wiki/EDIFACT#Example 160 | ``` 161 | UNA:+.? ' 162 | UNB+IATB:1+6XPPC+LHPPC+940101:0950+1' 163 | UNH+1+PAORES:93:1:IA' 164 | MSG+1:45' 165 | IFT+3+XYZCOMPANY AVAILABILITY' 166 | ERC+A7V:1:AMD' 167 | IFT+3+NO MORE FLIGHTS' 168 | ODI' 169 | TVL+240493:1000::1220+FRA+JFK+DL+400+C' 170 | PDI++C:3+Y::3+F::1' 171 | APD+74C:0:::6++++++6X' 172 | TVL+240493:1740::2030+JFK+MIA+DL+081+C' 173 | PDI++C:4' 174 | APD+EM2:0:1630::6+++++++DA' 175 | UNT+13+1' 176 | UNZ+1+1' 177 | ``` 178 | 179 | Gets converted in json as 180 | ``` 181 | [["UNB",["IATB","1"],"6XPPC","LHPPC",["940101","0950"],"1"],["UNH","1",["PAORES","93","1","IA"]],["MSG",["1","45"]],["IFT","3","XYZCOMPANY AVAILABILITY"],["ERC",["A7V","1","AMD"]],["IFT","3","NO MORE FLIGHTS"],["ODI"],["TVL",["240493","1000","","1220"],"FRA","JFK","DL","400","C"],["PDI","",["C","3"],["Y","","3"],["F","","1"]],["APD",["74C","0","","","6"],"","","","","","6X"],["TVL",["240493","1740","","2030"],"JFK","MIA","DL","081","C"],["PDI","",["C","4"]],["APD",["EM2","0","1630","","6"],"","","","","","","DA"],["UNT","13","1"],["UNZ","1","1"]] 182 | ``` 183 | 184 | Converting back the message to EDIFACT (and enabling newlines) 185 | ``` 186 | UNB+IATB:1+6XPPC+LHPPC+940101:0950+1' 187 | UNH+1+PAORES:93:1:IA' 188 | MSG+1:45' 189 | IFT+3+XYZCOMPANY AVAILABILITY' 190 | ERC+A7V:1:AMD' 191 | IFT+3+NO MORE FLIGHTS' 192 | ODI' 193 | TVL+240493:1000::1220+FRA+JFK+DL+400+C' 194 | PDI++C:3+Y::3+F::1' 195 | APD+74C:0:::6++++++6X' 196 | TVL+240493:1740::2030+JFK+MIA+DL+081+C' 197 | PDI++C:4' 198 | APD+EM2:0:1630::6+++++++DA' 199 | UNT+13+1' 200 | UNZ+1+1' 201 | ``` 202 | Disabling newlines (passing true to encoder) 203 | ``` 204 | 205 | UNB+IATB:1+6XPPC+LHPPC+940101:0950+1'UNH+1+PAORES:93:1:IA'MSG+1:45'IFT+3+XYZCOMPANY AVAILABILITY'ERC+A7V:1:AMD'IFT+3+NO MORE FLIGHTS'ODI'TVL+240493:1000::1220+FRA+JFK+DL+400+C'PDI++C:3+Y::3+F::1'APD+74C:0:::6++++++6X'TVL+240493:1740::2030+JFK+MIA+DL+081+C'PDI++C:4'APD+EM2:0:1630::6+++++++DA'UNT+13+1'UNZ+1+1' 206 | ``` 207 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | edifact 2 | ======= 3 | 4 | Tools to process EDI messages in UN/EDIFACT format 5 | 6 | Supported syntax is version 3. 7 | 8 | It's provided in a Composer package: 9 | 10 | `composer require sabas/edifact` 11 | 12 | The mapping XML files are provided in a separate package: 13 | 14 | `composer require php-edifact/edifact-mapping` 15 | 16 | EDI\Parser 17 | ------------------ 18 | Given an EDI message checks the syntax, outputs errors and returns the message as a multidimensional array. 19 | 20 | **INPUT** 21 | ```php 22 | $p = new EDI\Parser(); 23 | $p->load($location); // a local path to a file or a URL (if file_get_contents() allows for remote access) 24 | $p->loadString($string); // a full message (single line text) 25 | $p->loadArray($array); // an array of strings (one segment per entry) 26 | ``` 27 | 28 | **OUTPUT** 29 | 30 | Errors 31 | ```php 32 | $c->errors(); 33 | ``` 34 | Array 35 | ```php 36 | $c->get(); 37 | ``` 38 | 39 | 40 | EDI\Encoder 41 | ------------------ 42 | Given a multidimensional array (formatted as the output of the parser), returns an EDI string, optionally one segment per line. 43 | 44 | **INPUT** 45 | ```php 46 | $c = new EDI\Encoder($x, $compact = true); 47 | ``` 48 | `$x` is a multidimensional array where first dimension is the EDI segment, second contains elements: 49 | * single value 50 | * array (representing composite elements) 51 | 52 | `$compact` is a boolean, if you need a segment per line. Set to false to enable wrapped lines. 53 | 54 | OR 55 | ```php 56 | $c = new EDI\Encoder(); 57 | $c->encode($array, false); 58 | ``` 59 | 60 | **OUTPUT** 61 | ```php 62 | $c->get(); // returns String 63 | ``` 64 | 65 | EDI\Analyser 66 | ------------------ 67 | Create human-readable, structured text with comments from `segments.xml`. 68 | Requires the EDI\Mapping package. 69 | 70 | ```php 71 | $parser = new EDI\Parser(); 72 | $parser->load($file); 73 | $segments = $parser->getRawSegments(); 74 | $parsed = $parser->get(); 75 | $analyser = new EDI\Analyser(); 76 | $mapping = new EDI\MappingProvider('D95B'); 77 | $analyser->loadSegmentsXml($mapping->getSegments()); 78 | $analyser->loadSegmentsXml($mapping->getServiceSegments(), false); 79 | $analyser->loadMessageXml($mapping->getMessage('coparn')); 80 | $analyser->loadCodesXml($mapping->getCodes()); 81 | $analyser->directory = 'D95B'; 82 | $result = $analyser->process($parsed, $segments); 83 | 84 | ``` 85 | * `$file` is the path to orginal EDI message file 86 | 87 | ### Example INPUT 88 | ```text 89 | UNA:+,? ' 90 | UNB+UNOA:1+MAEU+LVRIXBCT+200813:0816+1412605' 91 | UNH+141260500001+COPARN:D:95B:UN' 92 | BGM+12+20200813081626+9' 93 | RFF+BN:204549739' 94 | NAD+CA+MAE:172:20' 95 | EQD+CN++45G1:102:5+2+2+4' 96 | RFF+BN:204549739' 97 | RFF+SQ:7G3JTL39O0M3B' 98 | TMD+++2' 99 | DTM+201:202008130000:203' 100 | LOC+98+LVRIX:139:6+RIGA TERMINAL:TER:ZZZ' 101 | CNT+16:1' 102 | UNT+12+141260500001' 103 | UNZ+1+1412605' 104 | ``` 105 | 106 | ### Example Output 107 | ```text 108 | 109 | UNA:+,? ' 110 | UNB - InterchangeHeader 111 | (To start, identify and specify an interchange) 112 | [0] UNOA,1 113 | unb1 - syntaxIdentifier 114 | Syntax identifier 115 | [0] UNOA 116 | id: unb11 - syntaxIdentifier 117 | Syntax identifier 118 | type: a 119 | required: true 120 | length: 4 121 | [1] 1 122 | id: unb12 - syntaxVersionNumber 123 | Syntax version number 124 | type: n 125 | required: true 126 | length: 1 127 | [1] MAEU 128 | unb2 - interchangeSender 129 | Interchange sender 130 | [2] LVRIXBCT 131 | unb3 - interchangeRecipient 132 | Interchange recipient 133 | [3] 200813,0816 134 | unb4 - dateTimePreparation 135 | Date Time of preparation 136 | [0] 200813 137 | id: unb41 - date 138 | 139 | type: n 140 | required: true 141 | length: 6 142 | [1] 0816 143 | id: unb42 - time 144 | 145 | type: n 146 | required: true 147 | length: 4 148 | [4] 1412605 149 | unb5 - interchangeControlReference 150 | 151 | 152 | UNB+UNOA:1+MAEU+LVRIXBCT+200813:0816+1412605' 153 | UNH - messageHeader http://www.unece.org/trade/untdid/d95b/trsd/trsdunh.htm 154 | (To head, identify and specify a message.) 155 | [0] 141260500001 156 | unh1 - messageReferenceNumber 157 | 158 | [1] COPARN,D,95B,UN 159 | unh2 - messageType 160 | 161 | [0] COPARN 162 | id: unh21 - messageType 163 | 164 | type: an 165 | maxlen: 6 166 | required: true 167 | [1] D 168 | id: unh22 - messageVersion 169 | 170 | type: an 171 | maxlen: 3 172 | required: true 173 | [2] 95B 174 | id: unh23 - messageRelease 175 | 176 | type: an 177 | maxlen: 3 178 | required: true 179 | [3] UN 180 | id: unh24 - controllingAgency 181 | 182 | type: an 183 | maxlen: 3 184 | required: true 185 | 186 | UNH+141260500001+COPARN:D:95B:UN' 187 | BGM - beginningOfMessage http://www.unece.org/trade/untdid/d95b/trsd/trsdbgm.htm 188 | (To indicate the type and function of a message and to transmit the 189 | identifying number.) 190 | [0] 12 191 | C002 - documentmessageName 192 | Identification of a type of document/message by code or name. Code 193 | preferred. 194 | [1] 20200813081626 195 | 1004 - documentmessageNumber 196 | Reference number assigned to the document/message by the issuer. 197 | [2] 9 198 | 1225 - messageFunctionCoded 199 | Code indicating the function of the message. 200 | 201 | BGM+12+20200813081626+9' 202 | RFF - reference http://www.unece.org/trade/untdid/d95b/trsd/trsdrff.htm 203 | (To specify a reference.) 204 | [0] BN,204549739 205 | C506 - reference 206 | Identification of a reference. 207 | [0] BN - Booking reference number 208 | id: 1153 - referenceQualifier 209 | Code giving specific meaning to a reference segment or a reference 210 | number. 211 | type: an 212 | maxlen: 3 213 | required: true 214 | [1] 204549739 215 | id: 1154 - referenceNumber 216 | Identification number the nature and function of which can be 217 | qualified by an entry in data element 1153 Reference qualifier. 218 | type: an 219 | maxlen: 35 220 | 221 | RFF+BN:204549739' 222 | NAD - nameAndAddress http://www.unece.org/trade/untdid/d95b/trsd/trsdnad.htm 223 | (To specify the name/address and their related function, either by CO82 only 224 | and/or unstructured by CO58 or structured by CO80 thru 3207. be permitted 225 | to use the ADR segment and/or the PNA segment. After the conclusion of the 226 | Brazil JRT (scheduled for April 1996), this NAD segment shall NO LONGER BE 227 | PERMITTED FOR USE IN ANY NEW MESSAGES submitted for Status 1 in the 228 | UN/EDIFACT Directories. This means that either the ADR and/or the PNA 229 | segments shall be used in place of this NAD segment by the message 230 | designers. (See Rapporteurs' decision on the proposed ADR & PNA segments 231 | and the existing NAD segment - Sydney, April 1995).) 232 | [0] CA 233 | 3035 - partyQualifier 234 | Code giving specific meaning to a party. 235 | [1] MAE,172,20 236 | C082 - partyIdentificationDetails 237 | Identification of a transaction party by code. 238 | [0] MAE 239 | id: 3039 - partyIdIdentification 240 | Code identifying a party involved in a transaction. 241 | type: an 242 | maxlen: 35 243 | required: true 244 | [1] 172 - Carrier code 245 | id: 1131 - codeListQualifier 246 | Identification of a code list. 247 | type: an 248 | maxlen: 3 249 | [2] 20 - BIC (Bureau International des Containeurs) 250 | id: 3055 - codeListResponsibleAgencyCoded 251 | Code identifying the agency responsible for a code list. 252 | type: an 253 | maxlen: 3 254 | 255 | NAD+CA+MAE:172:20' 256 | EQD - equipmentDetails http://www.unece.org/trade/untdid/d95b/trsd/trsdeqd.htm 257 | (To identify a unit of equipment.) 258 | [0] CN 259 | 8053 - equipmentQualifier 260 | Code identifying type of equipment. 261 | [1] 262 | C237 - equipmentIdentification 263 | Marks (letters and/or numbers) identifying equipment used for transport 264 | such as a container. 265 | [2] 45G1,102,5 266 | C224 - equipmentSizeAndType 267 | Code and/or name identifying size and type of equipment used in 268 | transport. Code preferred. 269 | [0] 45G1 270 | id: 8155 - equipmentSizeAndTypeIdentification 271 | Coded description of the size and type of equipment e.g. unit load 272 | device. 273 | type: an 274 | maxlen: 10 275 | [1] 102 - Size and type 276 | id: 1131 - codeListQualifier 277 | Identification of a code list. 278 | type: an 279 | maxlen: 3 280 | [2] 5 - ISO (International Organization for Standardization) 281 | id: 3055 - codeListResponsibleAgencyCoded 282 | Code identifying the agency responsible for a code list. 283 | type: an 284 | maxlen: 3 285 | [3] 2 286 | 8077 - equipmentSupplierCoded 287 | To indicate the party that is the supplier of the equipment. 288 | [4] 2 289 | 8249 - equipmentStatusCoded 290 | Indication of the action related to the equipment. 291 | [5] 4 292 | 8169 - fullemptyIndicatorCoded 293 | To indicate the extent to which the equipment is full or empty. 294 | 295 | EQD+CN++45G1:102:5+2+2+4' 296 | RFF - reference http://www.unece.org/trade/untdid/d95b/trsd/trsdrff.htm 297 | (To specify a reference.) 298 | [0] BN,204549739 299 | C506 - reference 300 | Identification of a reference. 301 | [0] BN - Booking reference number 302 | id: 1153 - referenceQualifier 303 | Code giving specific meaning to a reference segment or a reference 304 | number. 305 | type: an 306 | maxlen: 3 307 | required: true 308 | [1] 204549739 309 | id: 1154 - referenceNumber 310 | Identification number the nature and function of which can be 311 | qualified by an entry in data element 1153 Reference qualifier. 312 | type: an 313 | maxlen: 35 314 | 315 | RFF+BN:204549739' 316 | RFF - reference http://www.unece.org/trade/untdid/d95b/trsd/trsdrff.htm 317 | (To specify a reference.) 318 | [0] SQ,7G3JTL39O0M3B 319 | C506 - reference 320 | Identification of a reference. 321 | [0] SQ - Container sequence number 322 | id: 1153 - referenceQualifier 323 | Code giving specific meaning to a reference segment or a reference 324 | number. 325 | type: an 326 | maxlen: 3 327 | required: true 328 | [1] 7G3JTL39O0M3B 329 | id: 1154 - referenceNumber 330 | Identification number the nature and function of which can be 331 | qualified by an entry in data element 1153 Reference qualifier. 332 | type: an 333 | maxlen: 35 334 | 335 | RFF+SQ:7G3JTL39O0M3B' 336 | TMD - transportMovementDetails http://www.unece.org/trade/untdid/d95b/trsd/trsdtmd.htm 337 | (To specify transport movement details for a goods item or equipment.) 338 | [0] 339 | C219 - movementType 340 | Description of type of service for movement of cargo. 341 | [1] 342 | 8332 - equipmentPlan 343 | Description indicating equipment plan, e.g. FCL or LCL. 344 | [2] 2 345 | 8341 - haulageArrangementsCoded 346 | Specification of the type of equipment haulage arrangements. 347 | 348 | TMD+++2' 349 | DTM - datetimeperiod http://www.unece.org/trade/untdid/d95b/trsd/trsddtm.htm 350 | (To specify date, and/or time, or period.) 351 | [0] 201,202008130000,203 352 | C507 - datetimeperiod 353 | Date and/or time, or period relevant to the specified date/time/period 354 | type. 355 | [0] 201 - Pick-up date/time of equipment 356 | id: 2005 - datetimeperiodQualifier 357 | Code giving specific meaning to a date, time or period. 358 | type: an 359 | maxlen: 3 360 | required: true 361 | [1] 202008130000 362 | id: 2380 - datetimeperiod 363 | The value of a date, a date and time, a time or of a period in a 364 | specified representation. 365 | type: an 366 | maxlen: 35 367 | [2] 203 - CCYYMMDDHHMM 368 | id: 2379 - datetimeperiodFormatQualifier 369 | Specification of the representation of a date, a date and time or of 370 | a period. 371 | type: an 372 | maxlen: 3 373 | 374 | DTM+201:202008130000:203' 375 | LOC - placelocationIdentification http://www.unece.org/trade/untdid/d95b/trsd/trsdloc.htm 376 | (To identify a country/place/location/related location one/related location 377 | two.) 378 | [0] 98 379 | 3227 - placelocationQualifier 380 | Code identifying the function of a location. 381 | [1] LVRIX,139,6 382 | C517 - locationIdentification 383 | Identification of a location by code or name. 384 | [0] LVRIX 385 | id: 3225 - placelocationIdentification 386 | Identification of the name of place/location, other than 3164 City 387 | name. 388 | type: an 389 | maxlen: 25 390 | [1] 139 - Port 391 | id: 1131 - codeListQualifier 392 | Identification of a code list. 393 | type: an 394 | maxlen: 3 395 | [2] 6 - UN/ECE (United Nations - Economic Commission for Europe) 396 | id: 3055 - codeListResponsibleAgencyCoded 397 | Code identifying the agency responsible for a code list. 398 | type: an 399 | maxlen: 3 400 | [2] RIGA TERMINAL,TER,ZZZ 401 | C519 - relatedLocationOneIdentification 402 | Identification the first related location by code or name. 403 | [0] RIGA TERMINAL 404 | id: 3223 - relatedPlacelocationOneIdentification 405 | Specification of the first related place/location by code. 406 | type: an 407 | maxlen: 25 408 | [1] TER 409 | id: 1131 - codeListQualifier 410 | Identification of a code list. 411 | type: an 412 | maxlen: 3 413 | [2] ZZZ - Mutually defined 414 | id: 3055 - codeListResponsibleAgencyCoded 415 | Code identifying the agency responsible for a code list. 416 | type: an 417 | maxlen: 3 418 | 419 | LOC+98+LVRIX:139:6+RIGA TERMINAL:TER:ZZZ' 420 | CNT - controlTotal http://www.unece.org/trade/untdid/d95b/trsd/trsdcnt.htm 421 | (To provide control total.) 422 | [0] 16,1 423 | C270 - control 424 | Control total for checking integrity of a message or part of a message. 425 | [0] 16 - Total number of equipment 426 | id: 6069 - controlQualifier 427 | Determines the source data elements in the message which forms the 428 | basis for 6066 Control value. 429 | type: an 430 | maxlen: 3 431 | required: true 432 | [1] 1 433 | id: 6066 - controlValue 434 | Value obtained from summing the values specified by the Control 435 | Qualifier throughout the message (Hash total). 436 | type: n 437 | maxlen: 18 438 | required: true 439 | 440 | CNT+16:1' 441 | UNT - MessageTrailer http://www.unece.org/trade/untdid/d95b/trsd/trsdunt.htm 442 | (MessageTrailer) 443 | [0] 12 444 | 9900 - segmentsNumber 445 | segmentsNumber 446 | [1] 141260500001 447 | 9901 - msgRefNumber 448 | msgRefNumber 449 | 450 | UNT+12+141260500001' 451 | UNZ - InterchangeTrailer http://www.unece.org/trade/untdid/d95b/trsd/trsdunz.htm 452 | (InterchangeTrailer) 453 | [0] 1 454 | 9902 - interchangeControlCount 455 | interchangeControlCount 456 | [1] 1412605 457 | 9903 - interchangeControlRef 458 | interchangeControlRef 459 | ``` 460 | 461 | EDI\Reader 462 | ------------------ 463 | Read specific segment element values from parsed EDI file. 464 | Pass the reader a parser with a message already loaded. 465 | 466 | **INPUT** 467 | ```php 468 | $p = new EDI\Parser(); 469 | $p->load($x); 470 | $r = new EDI\Reader($p); 471 | $sender = $r->readEdiDataValue('UNB', 2); 472 | $Dt = $r->readUNBDateTimeOfPreparation(); 473 | 474 | ``` 475 | See section about EDI\Parser above on how to load a file into a parser. 476 | 477 | **OUTPUT** 478 | 479 | Errors 480 | ```php 481 | $r->errors(); 482 | ``` 483 | Array 484 | ```php 485 | $r->getParsedFile(); 486 | ``` 487 | 488 | EDI\Interpreter 489 | --------------- 490 | Organizes the data parsed by EDI/Parser using the XML description of the message and the XML segments. 491 | 492 | **INPUT** 493 | ```php 494 | $p = new EDI\Parser(); 495 | $p->load($edifile); 496 | $edi = $p->get(); 497 | 498 | $mapping = new EDI\Mapping\MappingProvider('D95B'); 499 | 500 | $analyser = new EDI\Analyser(); 501 | $segs = $analyser->loadSegmentsXml($mapping->getSegments()); 502 | $svc = $analyser->loadSegmentsXml($mapping->getServiceSegments(3)); 503 | 504 | $interpreter = new EDI\Interpreter($mapping->getMessage('CODECO'), $segs, $svc); 505 | $prep = $interpreter->prepare($edi); 506 | ``` 507 | 508 | **OUTPUT** 509 | 510 | JSON 511 | ```php 512 | $interpreter->getJson() 513 | ``` 514 | 515 | JSON for interchange service segments (UNB / UNZ) 516 | ```php 517 | $interpreter->getJsonServiceSegments() 518 | ``` 519 | 520 | Errors (per message) 521 | ```php 522 | $interpreter->getErrors() 523 | ``` 524 | 525 | Example 526 | ------- 527 | 528 | **Edifact** 529 | 530 | `DTM+7:201309200717:203'` 531 | 532 | **Array** 533 | ```php 534 | ['DTM',['7','201309200717','203']] 535 | ``` 536 | 537 | Testing 538 | ------- 539 | The package should be required with composer, alongside edifact-mapping. The tests then can be run simply with phpunit in the root of the package. 540 | 541 | Notes 542 | ------ 543 | Valid characters are: A-Za-z0-9.,-()/'+:=?!"%&*;<> [UNECE](http://www.unece.org/trade/untdid/texts/d422_d.htm#p5.1) 544 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sabas/edifact", 3 | "description": "Tools in PHP for UN/EDIFACT", 4 | "keywords": [ 5 | "EDI", 6 | "EDIFACT", 7 | "message", 8 | "container" 9 | ], 10 | "homepage": "https://github.com/php-edifact/edifact", 11 | "license": "LGPL-3.0-or-later", 12 | "authors": [ 13 | { 14 | "name": "Stefano Sabatini", 15 | "email": "sabas88@gmail.com", 16 | "homepage": "http://stefanosabatini.com", 17 | "role": "Developer" 18 | }, 19 | { 20 | "name": "Uldis Nelsons", 21 | "role": "Developer" 22 | }, 23 | { 24 | "name": "Emil Vikström", 25 | "role": "Developer" 26 | } 27 | ], 28 | "require": { 29 | "php": "~8", 30 | "ext-json": "*", 31 | "ext-mbstring": "*", 32 | "ext-simplexml": "*" 33 | }, 34 | "require-dev": { 35 | "php-edifact/edifact-mapping": "dev-master", 36 | "phpunit/phpunit": "~11.0", 37 | "symplify/easy-coding-standard": "^12.0" 38 | }, 39 | "autoload": { 40 | "psr-4": { 41 | "EDI\\": "src/EDI/" 42 | } 43 | }, 44 | "repositories": [ 45 | { 46 | "type": "vcs", 47 | "url": "https://github.com/php-edifact/edifact" 48 | } 49 | ], 50 | "support": { 51 | "issues": "https://github.com/php-edifact/edifact/issues" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | paths([ 11 | __DIR__ . '/src', 12 | __DIR__ . '/tests', 13 | ]); 14 | 15 | // this way you add a single rule 16 | $ecsConfig->rules([ 17 | NoUnusedImportsFixer::class, 18 | ]); 19 | 20 | // this way you can add sets - group of rules 21 | $ecsConfig->sets([ 22 | // run and fix, one by one 23 | SetList::SPACES, 24 | SetList::ARRAY, 25 | // SetList::DOCBLOCK, 26 | // SetList::NAMESPACES, 27 | // SetList::COMMENTS, 28 | SetList::PSR_12, 29 | SetList::LARAVEL, 30 | SetList::CLEAN_CODE, 31 | ]); 32 | }; 33 | -------------------------------------------------------------------------------- /src/EDI/Analyser.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public $segments; 17 | 18 | /** 19 | * @var string 20 | */ 21 | public $directory; 22 | 23 | /** 24 | * @var array 25 | */ 26 | private $jsonedi; 27 | 28 | /** 29 | * @var array 30 | */ 31 | private $codes; 32 | 33 | public function setXml($segments, $codes) 34 | { 35 | $this->segments = $segments; 36 | $this->codes = $codes; 37 | } 38 | 39 | /** 40 | * @return array|false 41 | */ 42 | public function loadMessageXml(string $message_xml_file) 43 | { 44 | $messageXmlString = \file_get_contents($message_xml_file); 45 | if ($messageXmlString === false) { 46 | return false; 47 | } 48 | 49 | $messageXml = new \SimpleXMLIterator($messageXmlString); 50 | 51 | return [ 52 | 'defaults' => $this->readMessageDefaults($messageXml), 53 | 'segments' => $this->readXmlNodes($messageXml), 54 | ]; 55 | } 56 | 57 | /** 58 | * get all data element codes 59 | * 60 | * 61 | * @return array|false 62 | */ 63 | public function loadCodesXml(string $codesXml) 64 | { 65 | $codesXmlString = \file_get_contents($codesXml); 66 | if ($codesXmlString === false) { 67 | return false; 68 | } 69 | 70 | $codesXml = new \SimpleXMLIterator($codesXmlString); 71 | $this->codes = []; 72 | foreach ($codesXml as $codeCollection) { 73 | \assert($codeCollection instanceof \SimpleXMLIterator); 74 | 75 | $codeCollectionAttributes = $codeCollection->attributes(); 76 | if ($codeCollectionAttributes === null) { 77 | continue; 78 | } 79 | 80 | $id = (string) $codeCollectionAttributes->id; 81 | $this->codes[$id] = []; 82 | foreach ($codeCollection as $codeNode) { 83 | \assert($codeNode instanceof \SimpleXMLIterator); 84 | 85 | $codeAttributes = $codeNode->attributes(); 86 | if ($codeAttributes !== null) { 87 | $code = (string) $codeAttributes->id; 88 | $this->codes[$id][$code] = (string) $codeAttributes->desc; 89 | } 90 | } 91 | } 92 | 93 | return $this->codes; 94 | } 95 | 96 | /** 97 | * convert segment definition from XML to array. Sequence of data_elements and 98 | * composite_data_element same as in XML 99 | * For single message, it's vailable also in (new EDI\Mapping\MappingProvider($version))->loadSegmentsXml() 100 | * 101 | * 102 | * @return array|false 103 | */ 104 | public function loadSegmentsXml(string $segmentXmlFile, bool $discardOldSegments = true) 105 | { 106 | if ($discardOldSegments) { 107 | $this->segments = []; 108 | } 109 | 110 | $segmentsXml = \file_get_contents($segmentXmlFile); 111 | if ($segmentsXml === false) { 112 | return false; 113 | } 114 | 115 | $xml = \simplexml_load_string($segmentsXml); 116 | if ($xml === false) { 117 | return false; 118 | } 119 | // free memory 120 | unset($segmentsXml); 121 | 122 | foreach ($xml as $segmentNode) { 123 | \assert($segmentNode instanceof \SimpleXMLElement); 124 | 125 | $segmentNodeAttributes = $segmentNode->attributes(); 126 | if ($segmentNodeAttributes === null) { 127 | continue; 128 | } 129 | 130 | $qualifier = (string) $segmentNodeAttributes->id; 131 | $segment = []; 132 | $segment['attributes'] = $this->readAttributesArray($segmentNode); 133 | $details = $this->readXmlNodes($segmentNode); 134 | if (! empty($details)) { 135 | $segment['details'] = $details; 136 | } 137 | $this->segments[$qualifier] = $segment; 138 | } 139 | 140 | return $this->segments; 141 | } 142 | 143 | /** 144 | * Load segment definitions from multiple files 145 | * 146 | * @see Analyser::loadSegmentsXml() 147 | * 148 | * @param string[] $segmentXmlFiles 149 | * @return array|false 150 | */ 151 | public function loadMultiSegmentsXml(array $segmentXmlFiles) 152 | { 153 | foreach ($segmentXmlFiles as $xmlFile) { 154 | if (! $result = $this->loadSegmentsXml($xmlFile, false)) { 155 | return $result; 156 | } 157 | } 158 | 159 | return $this->segments; 160 | } 161 | 162 | /** 163 | * create readable EDI MESSAGE with comments 164 | * 165 | * @param array $data by EDI\Parser:parse() created array from plain EDI message 166 | * @param array|null $rawSegments (optional) List of raw segments from EDI\Parser::getRawSegments 167 | * @return string file 168 | */ 169 | public function process(array $data, ?array $rawSegments = null): string 170 | { 171 | $r = []; 172 | foreach ($data as $nrow => $segment) { 173 | $id = $segment[0]; 174 | 175 | $r[] = ''; 176 | $jsonsegment = []; 177 | if (isset($rawSegments[$nrow])) { 178 | $r[] = \trim($rawSegments[$nrow]); 179 | } 180 | 181 | if (isset($this->segments[$id])) { 182 | $attributes = $this->segments[$id]['attributes']; 183 | $details_desc = $this->segments[$id]['details']; 184 | 185 | $idHeader = $id.' - '.$attributes['name']; 186 | if ($this->directory && $id !== 'UNB') { 187 | $idHeader .= ' https://service.unece.org/trade/untdid/'.strtolower($this->directory).'/trsd/trsd'.strtolower($id).'.htm'; 188 | } 189 | $r[] = $idHeader; 190 | $r[] = ' ('.\wordwrap($attributes['desc'], 75, \PHP_EOL.' ').')'; 191 | 192 | $jsonelements = [ 193 | 'segmentCode' => $id, 194 | ]; 195 | foreach ($segment as $idx => $detail) { 196 | $n = $idx - 1; 197 | if ($idx == 0 || ! isset($details_desc[$n])) { 198 | continue; 199 | } 200 | $d_desc_attr = $details_desc[$n]['attributes']; 201 | $l1 = ' '.$d_desc_attr['id'].' - '.$d_desc_attr['name']; 202 | $l2 = ' '.\wordwrap($d_desc_attr['desc'], 71, \PHP_EOL.' '); 203 | 204 | if (\is_array($detail)) { 205 | $r[] = ' ['.$n.'] '.\implode(',', $detail); 206 | $r[] = $l1; 207 | $r[] = $l2; 208 | 209 | $jsoncomposite = []; 210 | if (isset($details_desc[$n]['details'])) { 211 | $sub_details_desc = $details_desc[$n]['details']; 212 | 213 | foreach ($detail as $d_n => $d_detail) { 214 | $d_sub_desc_attr = $sub_details_desc[$d_n]['attributes']; 215 | $codeElementId = $d_sub_desc_attr['id']; 216 | $line = ' ['.$d_n.'] '.$d_detail; 217 | if (isset($this->codes[(int) $codeElementId][$d_detail])) { 218 | $line .= ' - '.\wordwrap($this->codes[$codeElementId][$d_detail], 69, \PHP_EOL.' '); 219 | } 220 | $r[] = $line; 221 | 222 | $r[] = ' id: '.$codeElementId.' - '.$d_sub_desc_attr['name']; 223 | if (isset($d_sub_desc_attr['desc'])) { 224 | $r[] = ' '.\wordwrap($d_sub_desc_attr['desc'], 69, \PHP_EOL.' '); 225 | } 226 | $r[] = ' type: '.$d_sub_desc_attr['type']; 227 | 228 | if (isset($jsoncomposite[$d_sub_desc_attr['name']])) { 229 | if (is_array($jsoncomposite[$d_sub_desc_attr['name']])) { 230 | $jsoncomposite[$d_sub_desc_attr['name']][] = $d_detail; 231 | } else { 232 | $jsoncomposite[$d_sub_desc_attr['name']] = [ 233 | $jsoncomposite[$d_sub_desc_attr['name']], 234 | $d_detail 235 | ]; 236 | } 237 | } else { 238 | $jsoncomposite[$d_sub_desc_attr['name']] = $d_detail; 239 | } 240 | 241 | if (isset($d_sub_desc_attr['maxlength'])) { 242 | $r[] = ' maxlen: '.$d_sub_desc_attr['maxlength']; 243 | } 244 | if (isset($d_sub_desc_attr['required'])) { 245 | $r[] = ' required: '.$d_sub_desc_attr['required']; 246 | } 247 | if (isset($d_sub_desc_attr['length'])) { 248 | $r[] = ' length: '.$d_sub_desc_attr['length']; 249 | } 250 | 251 | // check for skipped data 252 | unset( 253 | $d_sub_desc_attr['id'], 254 | $d_sub_desc_attr['name'], 255 | $d_sub_desc_attr['desc'], 256 | $d_sub_desc_attr['type'], 257 | $d_sub_desc_attr['maxlength'], 258 | $d_sub_desc_attr['required'], 259 | $d_sub_desc_attr['length'] 260 | ); 261 | 262 | /* 263 | if (!empty($d_sub_desc_attr)) { 264 | var_dump($d_sub_desc_attr); 265 | } 266 | */ 267 | } 268 | } 269 | $jsonelements[$d_desc_attr['name']] = $jsoncomposite; 270 | } else { 271 | $codeElementId = $d_desc_attr['id']; 272 | $line = ' ['.$n.'] '.$detail; 273 | if (isset($this->codes[(int) $codeElementId][$detail])) { 274 | /* 275 | * for retrieving code element description when first element of the segment 276 | * is a data element and not a composite one. Ex: NAD segment. 277 | * We rewrite also l1 line for adding 'id:' prefix before data element id. 278 | * It's just a cosmetic fix 279 | */ 280 | $line .= ' - '.\wordwrap($this->codes[$codeElementId][$detail], 71, \PHP_EOL.' '); 281 | $l1 = ' id: '.$d_desc_attr['id'].' - '.$d_desc_attr['name']; 282 | } 283 | $r[] = $line; 284 | $r[] = $l1; 285 | $r[] = $l2; 286 | $jsonelements[$d_desc_attr['name']] = $detail; 287 | } 288 | } 289 | $jsonsegment[$attributes['name']] = $jsonelements; 290 | } else { 291 | $r[] = $id; 292 | $jsonsegment['UnrecognisedType'] = $segment; 293 | } 294 | $this->jsonedi[] = $jsonsegment; 295 | } 296 | 297 | return \implode(\PHP_EOL, $r); 298 | } 299 | 300 | /** 301 | * return the processed EDI in json format 302 | * 303 | * @return false|string 304 | */ 305 | public function getJson() 306 | { 307 | return \json_encode($this->jsonedi); 308 | } 309 | 310 | /** 311 | * read default values in given message xml 312 | */ 313 | protected function readMessageDefaults(\SimpleXMLElement $message): array 314 | { 315 | // init 316 | $defaults = []; 317 | 318 | /** @var \SimpleXMLElement $defaultValueNode */ 319 | foreach ($message->defaults[0] ?? [] as $defaultValueNode) { 320 | $attributes = $defaultValueNode->attributes(); 321 | $id = (string) $attributes->id; 322 | $defaults[$id] = (string) $attributes->value; 323 | } 324 | 325 | return $defaults; 326 | } 327 | 328 | /** 329 | * read message segments and groups 330 | */ 331 | protected function readXmlNodes(\SimpleXMLElement $element): array 332 | { 333 | $arrayElements = []; 334 | foreach ($element as $name => $node) { 335 | if ($name == 'defaults') { 336 | continue; 337 | } 338 | $arrayElement = []; 339 | $arrayElement['type'] = $name; 340 | $arrayElement['attributes'] = $this->readAttributesArray($node); 341 | $details = $this->readXmlNodes($node); 342 | if (! empty($details)) { 343 | $arrayElement['details'] = $details; 344 | } 345 | $arrayElements[] = $arrayElement; 346 | } 347 | 348 | return $arrayElements; 349 | } 350 | 351 | /** 352 | * return an xml elements attributes in as array 353 | */ 354 | protected function readAttributesArray(\SimpleXMLElement $element): array 355 | { 356 | $attributes = []; 357 | foreach ($element->attributes() ?? [] as $attrName => $attr) { 358 | $attributes[(string) $attrName] = (string) $attr; 359 | } 360 | 361 | return $attributes; 362 | } 363 | } 364 | -------------------------------------------------------------------------------- /src/EDI/Encoder.php: -------------------------------------------------------------------------------- 1 | setUNA(":+.? '", false); 72 | if ($array === null) { 73 | return; 74 | } 75 | 76 | /** @noinspection UnusedFunctionResultInspection */ 77 | $this->encode($array, $compact); 78 | } 79 | 80 | /** 81 | * @param array[] $array 82 | * @param bool $compact All segments on a single line? 83 | */ 84 | public function encode(array $array, $compact = true): string 85 | { 86 | $this->originalArray = $array; 87 | $this->compact = $compact; 88 | 89 | $edistring = ''; 90 | $count = \count($array); 91 | $k = 0; 92 | foreach ($array as $row) { 93 | $k++; 94 | $row = \array_values($row); 95 | $edistring .= $this->encodeSegment($row); 96 | if (! $compact && $k < $count) { 97 | $edistring .= "\n"; 98 | } 99 | } 100 | $this->output = $edistring; 101 | 102 | return $edistring; 103 | } 104 | 105 | public function encodeSegment(array $row): string 106 | { 107 | // init 108 | $str = ''; 109 | $t = \count($row); 110 | 111 | /** @noinspection AlterInForeachInspection */ 112 | foreach ($row as $i => &$iValue) { 113 | if (\is_array($iValue)) { 114 | if ( 115 | \count($iValue) === 1 116 | && 117 | \is_array(\reset($iValue)) 118 | ) { 119 | $iValue = \array_pop($iValue); 120 | } 121 | 122 | /** @noinspection NotOptimalIfConditionsInspection */ 123 | if (\is_array($iValue)) { 124 | foreach ($iValue as &$temp) { 125 | $temp = $this->escapeValue($temp); 126 | } 127 | unset($temp); 128 | } 129 | 130 | $elm = \implode($this->sepComp, $iValue); 131 | } else { 132 | $elm = $this->escapeValue($iValue); 133 | } 134 | 135 | $str .= $elm; 136 | if ($i == $t - 1) { 137 | break; 138 | } 139 | $str .= $this->sepData; 140 | } 141 | 142 | $str .= $this->symbEnd; 143 | 144 | return $str; 145 | } 146 | 147 | public function get(): string 148 | { 149 | if ($this->UNAActive) { 150 | $una = 'UNA'.$this->sepComp. 151 | $this->sepData. 152 | $this->sepDec. 153 | $this->symbRel. 154 | $this->symbRep. 155 | $this->symbEnd; 156 | if ($this->compact === false) { 157 | $una .= "\n"; 158 | } 159 | 160 | return $una.$this->output; 161 | } 162 | 163 | return $this->output; 164 | } 165 | 166 | public function setUNA(string $chars, bool $user_call = true): bool 167 | { 168 | if (\strlen($chars) == 6) { 169 | $this->sepComp = $chars[0]; 170 | $this->sepData = $chars[1]; 171 | $this->sepDec = $chars[2]; 172 | $this->symbRel = $chars[3]; 173 | $this->symbRep = $chars[4]; 174 | $this->symbEnd = $chars[5]; 175 | 176 | if ($user_call) { 177 | $this->enableUNA(); 178 | } 179 | 180 | if ($this->output != '') { 181 | $this->output = $this->encode($this->originalArray); 182 | } 183 | 184 | return true; 185 | } 186 | 187 | return false; 188 | } 189 | 190 | /** 191 | * @return void 192 | */ 193 | public function enableUNA() 194 | { 195 | $this->UNAActive = true; 196 | } 197 | 198 | /** 199 | * @return void 200 | */ 201 | public function disableUNA() 202 | { 203 | $this->UNAActive = false; 204 | } 205 | 206 | /** 207 | * @param int|string $str 208 | */ 209 | private function escapeValue(&$str): string 210 | { 211 | $search = [ 212 | $this->symbRel, 213 | $this->sepComp, 214 | $this->sepData, 215 | $this->symbEnd, 216 | ]; 217 | $replace = [ 218 | $this->symbRel.$this->symbRel, 219 | $this->symbRel.$this->sepComp, 220 | $this->symbRel.$this->sepData, 221 | $this->symbRel.$this->symbEnd, 222 | ]; 223 | 224 | return \str_replace($search, $replace, (string) $str); 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/EDI/Interpreter.php: -------------------------------------------------------------------------------- 1 | 'Missing required segment', 18 | 'MISSINGREQUIREDGROUP' => 'Missing required group', 19 | 'NOTCONFORMANT' => "It looks like that this message isn't conformant to the mapping provided. (Not all segments were added)", 20 | 'TOOMANYELEMENTS_COMPOSITE' => 'This composite data element has more elements than expected', 21 | 'TOOMANYELEMENTS' => 'This segment has more data elements than expected', 22 | 'MISSINGINTERCHANGEDELIMITER' => 'The file has at least one UNB or UNZ missing', 23 | 'MISSINGMESSAGEDELIMITER' => 'The message has at least one UNH or UNT missing', 24 | 'TOOMANYSEGMENTS' => 'The message has some additional segments beyond the maximum repetition allowed', 25 | 'TOOMANYGROUPS' => 'The message has some additional groups beyond the maximum repetition allowed', 26 | 'SPURIOUSSEGMENT' => 'This segment is spurious', 27 | ]; 28 | 29 | /** 30 | * @var array 31 | */ 32 | public $segmentTemplates = [ 33 | 'DTM' => ['DTM', '999', 'XXX'], 34 | ]; 35 | 36 | /** 37 | * @var array 38 | */ 39 | public $groupTemplates = [ 40 | 'SG1' => [['TDT', '20', 'XXX']], 41 | ]; 42 | 43 | /** 44 | * @var bool 45 | */ 46 | private $patchFiles = true; 47 | 48 | /** 49 | * @var bool 50 | */ 51 | private $segmentGroup = true; 52 | 53 | /** 54 | * @var bool 55 | */ 56 | private $forceArrayWhenRepeatable = false; 57 | 58 | /** 59 | * @var \SimpleXMLElement 60 | */ 61 | private $xmlMsg; 62 | 63 | /** 64 | * @var array 65 | */ 66 | private $xmlSeg; 67 | 68 | /** 69 | * @var array 70 | */ 71 | private $xmlSvc; 72 | 73 | /** 74 | * @var array 75 | */ 76 | private $ediGroups; 77 | 78 | /** 79 | * @var array 80 | */ 81 | private $errors; 82 | 83 | /** 84 | * @var array 85 | */ 86 | private $msgs; 87 | 88 | /** 89 | * @var array 90 | */ 91 | private $serviceSeg; 92 | 93 | /** 94 | * @var array 95 | */ 96 | private $codes; 97 | 98 | /** 99 | * @var callable 100 | */ 101 | private $comparisonFunction; 102 | 103 | /** 104 | * @var callable 105 | */ 106 | private $replacementFunction; 107 | 108 | /** 109 | * @var string 110 | */ 111 | private $outputKey = 'name'; 112 | 113 | /** 114 | * @var string 115 | */ 116 | private $currentGroup = ''; 117 | 118 | /** 119 | * @var string 120 | */ 121 | private $currentGroupHeader = ''; 122 | 123 | /** 124 | * Split multiple messages and process 125 | * 126 | * @param string $xmlMsg Path to XML Message representation 127 | * @param array $xmlSeg Segments processed by EDI\Analyser::loadSegmentsXml or EDI\Mapping\MappingProvider 128 | * @param array $xmlSvc Service segments processed by EDI\Analyser::loadSegmentsXml or EDI\Mapping\MappingProvider 129 | * @param array|null $messageTextConf Personalization of error messages 130 | */ 131 | public function __construct(string $xmlMsg, array $xmlSeg, array $xmlSvc, ?array $messageTextConf = null) 132 | { 133 | // simplexml_load_file: This can be affected by a PHP bug #62577 (https://bugs.php.net/bug.php?id=62577) 134 | $xmlData = \file_get_contents($xmlMsg); 135 | if ($xmlData === false) { 136 | throw new \InvalidArgumentException('file_get_contents for "'.$xmlMsg.'" failed'); 137 | } 138 | 139 | $xmlMsgTmp = \simplexml_load_string($xmlData); 140 | if ($xmlMsgTmp === false) { 141 | throw new \InvalidArgumentException('simplexml_load_string for "'.$xmlMsg.'" failed'); 142 | } 143 | 144 | $this->xmlMsg = $xmlMsgTmp; 145 | $this->xmlSeg = $xmlSeg; 146 | $this->xmlSvc = $xmlSvc; 147 | if ($messageTextConf !== null) { 148 | $this->messageTextConf = \array_replace($this->messageTextConf, $messageTextConf); 149 | } 150 | $this->errors = []; 151 | 152 | $this->comparisonFunction = static function ($segment, $elm) { 153 | return $segment[0] == $elm['id']; 154 | }; 155 | } 156 | 157 | /** 158 | * @return void 159 | */ 160 | public function togglePatching(bool $flag) 161 | { 162 | $this->patchFiles = $flag; 163 | 164 | return $this; 165 | } 166 | 167 | /** 168 | * @return void 169 | */ 170 | public function toggleSegmentGroup(bool $flag) 171 | { 172 | $this->segmentGroup = $flag; 173 | 174 | return $this; 175 | } 176 | 177 | /** 178 | * @return void 179 | */ 180 | public function forceArrayWhenRepeatable(bool $flag) 181 | { 182 | $this->forceArrayWhenRepeatable = $flag; 183 | 184 | return $this; 185 | } 186 | 187 | /** 188 | * Patch the error messages array 189 | * 190 | * @param array $messageTextConf An array with same keys as the internal $messageTextConf 191 | * @return void 192 | */ 193 | public function setMessageTextConf(array $messageTextConf) 194 | { 195 | $this->messageTextConf = \array_replace($this->messageTextConf, $messageTextConf); 196 | 197 | return $this; 198 | } 199 | 200 | /** 201 | * Add fake segments used to patch the message if a required segment is missing 202 | * 203 | * @param array $segmentTemplates An array with segments (having the segment name as key) 204 | * @return void 205 | */ 206 | public function setSegmentTemplates(array $segmentTemplates) 207 | { 208 | $this->segmentTemplates = $segmentTemplates; 209 | 210 | return $this; 211 | } 212 | 213 | /** 214 | * Add fake groups used to patch the message if a required group is missing 215 | * 216 | * @param array $groupTemplates An array with segments (having the group name as key) 217 | * @return void 218 | */ 219 | public function setGroupTemplates(array $groupTemplates) 220 | { 221 | $this->groupTemplates = $groupTemplates; 222 | 223 | return $this; 224 | } 225 | 226 | /** 227 | * Set the data parsed from the xml list of codes 228 | * 229 | * @param array $codes An array with codes from the chosen directory 230 | * @return void 231 | */ 232 | public function setCodes(array $codes) 233 | { 234 | $this->codes = $codes; 235 | 236 | return $this; 237 | } 238 | 239 | /** 240 | * Function that implements how to compare a message segment and its definition 241 | * 242 | * @param callable $func A function accepting two arguments, first is the segment array, then the element definition 243 | * @return void 244 | */ 245 | public function setComparisonFunction(callable $func) 246 | { 247 | $this->comparisonFunction = $func; 248 | 249 | return $this; 250 | } 251 | 252 | /** 253 | * Function that replaces a segment 254 | * 255 | * @param callable $func A function accepting two arguments, first is the segment array, then the element definition 256 | * @return void 257 | */ 258 | public function setReplacementFunction(callable $func) 259 | { 260 | $this->replacementFunction = $func; 261 | 262 | return $this; 263 | } 264 | 265 | /** 266 | * Set to true if UNCEFACT XML ID should be used instead of names 267 | * 268 | * 269 | * @return void 270 | */ 271 | public function toggleUseIdInsteadOfNameForOutput(bool $toggle) 272 | { 273 | if ($toggle) { 274 | $this->outputKey = 'id'; 275 | } else { 276 | $this->outputKey = 'name'; 277 | } 278 | 279 | return $this; 280 | } 281 | 282 | /** 283 | * Split multiple messages and process 284 | * 285 | * @param array $parsed An array coming from EDI\Parser 286 | */ 287 | public function prepare(array $parsed): array 288 | { 289 | $this->msgs = $this->splitMessages($parsed, $this->errors); 290 | $groups = []; 291 | $service = $this->msgs['service']; 292 | $this->serviceSeg = $this->processService($service); 293 | 294 | foreach ($this->msgs as $k => $msg) { 295 | if ($k === 'service') { 296 | continue; 297 | } 298 | $grouped = $this->loopMessage($msg, $this->xmlMsg, $this->errors); 299 | $groups[] = $grouped['message']; 300 | } 301 | $this->ediGroups = $groups; 302 | 303 | return $groups; 304 | } 305 | 306 | /** 307 | * Get result as json. 308 | * 309 | * 310 | * @return false|string 311 | */ 312 | public function getJson(bool $pretty = false) 313 | { 314 | if ($pretty) { 315 | return \json_encode($this->ediGroups, \JSON_PRETTY_PRINT); 316 | } 317 | 318 | return \json_encode($this->ediGroups); 319 | } 320 | 321 | /** 322 | * Get EDI groups. 323 | * 324 | * @return array 325 | */ 326 | public function getEdiGroups() 327 | { 328 | return $this->ediGroups; 329 | } 330 | 331 | /** 332 | * Set EDI groups. 333 | * 334 | * @return array 335 | */ 336 | public function setEdiGroups($groups) 337 | { 338 | $this->ediGroups = $groups; 339 | } 340 | 341 | /** 342 | * Get errors 343 | */ 344 | public function getErrors(): array 345 | { 346 | return $this->errors; 347 | } 348 | 349 | /** 350 | * Get splitted messages. 351 | */ 352 | public function getMessages(): array 353 | { 354 | return $this->msgs; 355 | } 356 | 357 | /** 358 | * Get service segments. 359 | * 360 | * @return array 361 | */ 362 | public function getServiceSegments() 363 | { 364 | return $this->serviceSeg; 365 | } 366 | 367 | /** 368 | * Get json service segments. 369 | * 370 | * 371 | * @return false|string 372 | */ 373 | public function getJsonServiceSegments(bool $pretty = false) 374 | { 375 | if ($pretty) { 376 | return \json_encode($this->serviceSeg, \JSON_PRETTY_PRINT); 377 | } 378 | 379 | return \json_encode($this->serviceSeg); 380 | } 381 | 382 | /** 383 | * Split multiple messages 384 | * 385 | * @param array $parsed An array coming from EDI\Parser 386 | * @param array $errors 387 | */ 388 | private function splitMessages(&$parsed, &$errors): array 389 | { 390 | // init 391 | $messages = []; 392 | $tmpmsg = []; 393 | $service = []; 394 | $hasInterchangeDelimiters = 0; 395 | $hasMessageDelimiters = 0; 396 | 397 | foreach ($parsed as $c => $segment) { 398 | switch ($segment[0]) { 399 | case 'UNB': 400 | $hasInterchangeDelimiters = 0; 401 | $hasInterchangeDelimiters++; 402 | $service['UNB'] = $segment; 403 | 404 | break; 405 | case 'UNZ': 406 | $hasInterchangeDelimiters--; 407 | if ($hasInterchangeDelimiters != 0) { 408 | $sid = ($hasInterchangeDelimiters < 0) ? 'UNB' : 'UNZ'; 409 | $errors[] = [ 410 | 'text' => $this->messageTextConf['MISSINGINTERCHANGEDELIMITER'], 411 | 'position' => $c, 412 | 'segmentId' => $sid, 413 | ]; 414 | } 415 | $service['UNZ'] = $segment; 416 | if ($this->patchFiles && $hasMessageDelimiters > 0) { 417 | $segment = ['UNT', '0', '1']; 418 | $hasMessageDelimiters--; 419 | $tmpmsg[] = $segment; 420 | $messages[] = $tmpmsg; 421 | } 422 | break; 423 | case 'UNH': 424 | $hasMessageDelimiters = 0; 425 | $hasMessageDelimiters++; 426 | $tmpmsg = [$segment]; 427 | 428 | break; 429 | case 'UNT': 430 | $hasMessageDelimiters--; 431 | $tmpmsg[] = $segment; 432 | $messages[] = $tmpmsg; 433 | if ($hasMessageDelimiters != 0) { 434 | $sid = ($hasMessageDelimiters < 0) ? 'UNH' : 'UNT'; 435 | $errors[] = [ 436 | 'text' => $this->messageTextConf['MISSINGMESSAGEDELIMITER'], 437 | 'position' => $c, 438 | 'segmentId' => $sid, 439 | ]; 440 | } 441 | 442 | break; 443 | default: 444 | $tmpmsg[] = $segment; 445 | 446 | break; 447 | } 448 | } 449 | 450 | if ($hasInterchangeDelimiters != 0) { 451 | $sid = ($hasInterchangeDelimiters < 0) ? 'UNB' : 'UNZ'; 452 | $errors[] = [ 453 | 'text' => $this->messageTextConf['MISSINGINTERCHANGEDELIMITER'], 454 | 'position' => $c ?? '', 455 | 'segmentId' => $sid, 456 | ]; 457 | } 458 | 459 | if ($hasMessageDelimiters != 0) { 460 | $sid = ($hasMessageDelimiters < 0) ? 'UNH' : 'UNT'; 461 | $errors[] = [ 462 | 'text' => $this->messageTextConf['MISSINGMESSAGEDELIMITER'], 463 | 'position' => $c ?? '', 464 | 'segmentId' => $sid, 465 | ]; 466 | } 467 | 468 | $messages['service'] = $service; 469 | 470 | return $messages; 471 | } 472 | 473 | /** 474 | * Transform a parsed edi in its groupings 475 | * 476 | * @param array $message Single message (Without UNB and UNZ) 477 | * @param \SimpleXMLElement $xml The xml representation of the message 478 | */ 479 | private function loopMessage(array &$message, \SimpleXMLElement $xml, array &$errors): array 480 | { 481 | // init 482 | $groupedEdi = []; 483 | $segmentIdx = 0; 484 | 485 | foreach ($xml->children() as $elm) { 486 | if ($elm->getName() == 'group') { 487 | $this->processXmlGroup($elm, $message, $segmentIdx, $groupedEdi, $errors); 488 | } elseif ($elm->getName() == 'segment') { 489 | $this->processXmlSegment($elm, $message, $segmentIdx, $groupedEdi, $errors); 490 | } 491 | } 492 | 493 | if ($segmentIdx != \count($message)) { 494 | $errors[] = [ 495 | 'text' => $this->messageTextConf['NOTCONFORMANT'], 496 | 'position' => $segmentIdx, 497 | 'segmentId' => $message[$segmentIdx][0], 498 | ]; 499 | } 500 | 501 | return [ 502 | 'message' => $groupedEdi, 503 | 'errors' => $errors, 504 | ]; 505 | } 506 | 507 | /** 508 | * Process an XML Group 509 | * 510 | * 511 | * @return void 512 | */ 513 | private function processXmlGroup(\SimpleXMLElement $elm, array &$message, int &$segmentIdx, array &$array, array &$errors) 514 | { 515 | // init 516 | $groupVisited = false; 517 | $newGroup = []; 518 | 519 | $this->currentGroupHeader = $elm->children()[0]['id']->__toString(); 520 | $this->currentGroup = $elm['id']->__toString(); 521 | 522 | for ($g = 0; $g < $elm['maxrepeat']; $g++) { 523 | $grouptemp = []; 524 | if ($message[$segmentIdx][0] != $elm->children()[0]['id']) { 525 | if ( 526 | ! $groupVisited 527 | && 528 | isset($elm['required']) 529 | ) { 530 | $elmType = $elm['id']->__toString(); 531 | $fixed = false; 532 | if ($this->patchFiles && isset($this->groupTemplates[$elmType])) { 533 | \array_splice($message, $segmentIdx, 0, $this->groupTemplates[$elmType]); 534 | $fixed = true; 535 | } 536 | 537 | $errors[] = [ 538 | 'text' => $this->messageTextConf['MISSINGREQUIREDGROUP'].' '.($fixed ? ' (patched)' : ''), 539 | 'position' => $segmentIdx, 540 | 'segmentId' => $elmType, 541 | ]; 542 | } else { 543 | break; 544 | } 545 | } 546 | 547 | foreach ($elm->children() as $elm2) { 548 | if ($elm2->getName() == 'group') { 549 | $this->processXmlGroup($elm2, $message, $segmentIdx, $grouptemp, $errors); 550 | $this->currentGroupHeader = $elm->children()[0]['id']->__toString(); 551 | $this->currentGroup = $elm['id']->__toString(); 552 | } else { 553 | $this->processXmlSegment($elm2, $message, $segmentIdx, $grouptemp, $errors); 554 | } 555 | $groupVisited = true; 556 | } 557 | 558 | $newGroup[] = $grouptemp; 559 | } 560 | 561 | // if additional groups are detected we are violating the maxrepeat attribute 562 | while (isset($message[$segmentIdx]) && \call_user_func($this->comparisonFunction, $message[$segmentIdx], $elm->children()[0])) { 563 | $errors[] = [ 564 | 'text' => $this->messageTextConf['TOOMANYGROUPS'], 565 | 'position' => $segmentIdx, 566 | 'segmentId' => (string) $elm['id'], 567 | ]; 568 | $this->currentGroup = $elm['id']->__toString(); 569 | 570 | foreach ($elm->children() as $elm2) { 571 | if ($elm2->getName() == 'group') { 572 | $this->processXmlGroup($elm2, $message, $segmentIdx, $grouptemp, $errors); 573 | $this->currentGroupHeader = $elm->children()[0]['id']->__toString(); 574 | $this->currentGroup = $elm['id']->__toString(); 575 | } else { 576 | $this->processXmlSegment($elm2, $message, $segmentIdx, $grouptemp, $errors); 577 | } 578 | $groupVisited = true; 579 | } 580 | 581 | $newGroup[] = $grouptemp; 582 | } 583 | 584 | $this->currentGroupHeader = ''; 585 | $this->currentGroup = ''; 586 | 587 | if (\count($newGroup) === 0) { 588 | return; 589 | } 590 | 591 | $array[$elm['id']->__toString()] = $newGroup; 592 | } 593 | 594 | /** 595 | * Process an XML Segment. 596 | * 597 | * 598 | * @return void 599 | */ 600 | private function processXmlSegment(\SimpleXMLElement $elm, array &$message, int &$segmentIdx, array &$array, array &$errors) 601 | { 602 | // init 603 | $segmentVisited = false; 604 | 605 | for ($i = 0; $i < $elm['maxrepeat']; $i++) { 606 | if (\call_user_func($this->comparisonFunction, $message[$segmentIdx], $elm)) { 607 | $jsonMessage = $this->processSegment($message[$segmentIdx], $this->xmlSeg, $segmentIdx, $errors); 608 | $segmentVisited = true; 609 | $this->doAddArray($array, $jsonMessage, (int) $elm['maxrepeat']); 610 | $segmentIdx++; 611 | } elseif ($this->replacementFunction !== null && $replacementSegment = \call_user_func($this->replacementFunction, $message[$segmentIdx], $elm)) { 612 | //the function shall return false, true or a new segment 613 | $fixed = false; 614 | 615 | if ($elm['replacewith'] !== null) { 616 | $elmType = (string) $elm['replacewith']; 617 | } else { 618 | $elmType = (string) $elm['id']; 619 | } 620 | 621 | if ($this->patchFiles && $replacementSegment === true && isset($this->segmentTemplates[$elmType])) { 622 | $jsonMessage = $this->processSegment($this->segmentTemplates[$elmType], $this->xmlSeg, $segmentIdx, $errors); 623 | $fixed = true; 624 | $this->doAddArray($array, $jsonMessage, (int) $elm['maxrepeat']); 625 | } 626 | 627 | if ($this->patchFiles && $replacementSegment !== true) { 628 | $jsonMessage = $this->processSegment($replacementSegment, $this->xmlSeg, $segmentIdx, $errors); 629 | $fixed = true; 630 | $this->doAddArray($array, $jsonMessage, (int) $elm['maxrepeat']); 631 | } 632 | 633 | $errors[] = [ 634 | 'text' => $this->messageTextConf['MISSINGREQUIREDSEGMENT'].' '.($fixed ? ' (replaced)' : ''), 635 | 'position' => $segmentIdx, 636 | 'segmentId' => (string) $elm['id'], 637 | ]; 638 | $segmentIdx++; 639 | 640 | continue; 641 | } else { 642 | if (! $segmentVisited && isset($elm['required'])) { 643 | $segmentVisited = true; 644 | if (isset($message[$segmentIdx + 1]) && \call_user_func($this->comparisonFunction, $message[$segmentIdx + 1], $elm)) { 645 | $errors[] = [ 646 | 'text' => $this->messageTextConf['SPURIOUSSEGMENT'].($this->patchFiles ? ' (skipped)' : ''), 647 | 'position' => $segmentIdx, 648 | 'segmentId' => (string) $message[$segmentIdx][0], 649 | ]; 650 | $segmentIdx++; //just move the index 651 | $i--; //but don't count as repetition 652 | 653 | continue; 654 | } 655 | 656 | if ($elm['replacewith'] !== null) { 657 | $elmType = (string) $elm['replacewith']; 658 | } else { 659 | $elmType = (string) $elm['id']; 660 | } 661 | 662 | $fixed = false; 663 | 664 | if ($this->patchFiles && isset($this->segmentTemplates[$elmType])) { 665 | $jsonMessage = $this->processSegment($this->segmentTemplates[$elmType], $this->xmlSeg, $segmentIdx, $errors); 666 | $fixed = true; 667 | $this->doAddArray($array, $jsonMessage, (int) $elm['maxrepeat']); 668 | } 669 | 670 | $errors[] = [ 671 | 'text' => $this->messageTextConf['MISSINGREQUIREDSEGMENT'].' '.($fixed ? ' (patched)' : ''), 672 | 'position' => $segmentIdx, 673 | 'segmentId' => (string) $elm['id'], 674 | ]; 675 | 676 | } 677 | 678 | return; 679 | } 680 | } 681 | 682 | // if additional segments are detected we are violating the maxrepeat attribute 683 | $loopMove = 0; 684 | while ( 685 | isset($message[$segmentIdx]) && 686 | \call_user_func($this->comparisonFunction, $message[$segmentIdx + $loopMove], $elm) && 687 | (string) $elm['id'] !== $this->currentGroupHeader 688 | ) { 689 | $errors[] = [ 690 | 'text' => $this->messageTextConf['TOOMANYSEGMENTS'].($this->patchFiles ? ' (skipped)' : ''), 691 | 'position' => $segmentIdx, 692 | 'segmentId' => (string) $elm['id'], 693 | ]; 694 | 695 | if ($this->patchFiles) { 696 | $segmentIdx++; 697 | } else { 698 | $loopMove++; //we move to the next segment only for this loop if the patching isn't active 699 | } 700 | } 701 | } 702 | 703 | /** 704 | * Adds a processed segment to the current group. 705 | * 706 | * @param array $array a reference to the group 707 | * @param array $jsonMessage a segment processed by processSegment() 708 | * @return void 709 | */ 710 | private function doAddArray(array &$array, array &$jsonMessage, $maxRepeat = 1) 711 | { 712 | if (isset($array[$jsonMessage['key']])) { 713 | if ( 714 | isset($array[$jsonMessage['key']]['segmentCode']) 715 | || 716 | $jsonMessage['key'] === 'UnrecognisedType' 717 | ) { 718 | $temp = $array[$jsonMessage['key']]; 719 | $array[$jsonMessage['key']] = []; 720 | $array[$jsonMessage['key']][] = $temp; 721 | } 722 | $array[$jsonMessage['key']][] = $jsonMessage['value']; 723 | } else { 724 | $array[$jsonMessage['key']] = $jsonMessage['value']; 725 | // if segment can be repeated then the flag forces to be an array also 726 | // if there's only one segment 727 | if ($maxRepeat > 1 && $this->forceArrayWhenRepeatable) { 728 | $array[$jsonMessage['key']] = [$jsonMessage['value']]; 729 | } 730 | } 731 | } 732 | 733 | /** 734 | * Add human readable keys as in Analyser. 735 | * 736 | * @param int|null $segmentIdx 737 | */ 738 | private function processSegment(array &$segment, array &$xmlMap, $segmentIdx, ?array &$errors = null): array 739 | { 740 | $id = $segment[0]; 741 | 742 | $jsonsegment = []; 743 | if (isset($xmlMap[$id])) { 744 | $attributes = $xmlMap[$id]['attributes']; 745 | $details_desc = $xmlMap[$id]['details']; 746 | 747 | $jsonelements = [ 748 | 'segmentIdx' => $segmentIdx, 749 | 'segmentCode' => $id, 750 | ]; 751 | 752 | if ($this->segmentGroup) { 753 | $jsonelements['segmentGroup'] = $this->currentGroup; 754 | } 755 | 756 | foreach ($segment as $idx => $detail) { 757 | $n = $idx - 1; 758 | if ($idx == 0) { 759 | continue; 760 | } 761 | 762 | if (! isset($details_desc[$n])) { 763 | $errors[] = [ 764 | 'text' => $this->messageTextConf['TOOMANYELEMENTS'], 765 | 'position' => $segmentIdx, 766 | 'segmentId' => $id, 767 | ]; 768 | $jsonelements['Extension'.$n] = $detail; 769 | 770 | continue; 771 | } 772 | 773 | $d_desc_attr = $details_desc[$n]['attributes']; 774 | 775 | $jsoncomposite = []; 776 | if ( 777 | $detail !== '' 778 | && 779 | isset($details_desc[$n]['details']) 780 | ) { 781 | $sub_details_desc = $details_desc[$n]['details']; 782 | 783 | if (\is_array($detail)) { 784 | foreach ($detail as $d_n => $d_detail) { 785 | if (! isset($sub_details_desc[$d_n])) { 786 | $errors[] = [ 787 | 'text' => $this->messageTextConf['TOOMANYELEMENTS_COMPOSITE'], 788 | 'position' => $segmentIdx.'/'.$idx, 789 | 'segmentId' => $id, 790 | ]; 791 | $jsoncomposite['CompositeExtension'.$d_n] = $d_detail; 792 | 793 | continue; 794 | } 795 | 796 | $d_sub_desc_attr = $sub_details_desc[$d_n]['attributes']; 797 | //print_r($d_sub_desc_attr); 798 | //print_r($d_detail); 799 | //die(); 800 | if (isset($this->codes) && isset($this->codes[$d_sub_desc_attr['id']]) && is_array($this->codes[$d_sub_desc_attr['id']])) { //if codes is set enable translation of the value 801 | if (isset($this->codes[$d_sub_desc_attr['id']][$d_detail])) { 802 | $d_detail = $this->codes[$d_sub_desc_attr['id']][$d_detail]; 803 | } 804 | } 805 | 806 | if (! isset($jsoncomposite[$d_sub_desc_attr[$this->outputKey]])) { //New 807 | $jsoncomposite[$d_sub_desc_attr[$this->outputKey]] = $d_detail; 808 | } elseif (\is_string($jsoncomposite[$d_sub_desc_attr[$this->outputKey]])) { // More data than one string 809 | $jsoncomposite[$d_sub_desc_attr[$this->outputKey]] = [ 810 | $jsoncomposite[$d_sub_desc_attr[$this->outputKey]], 811 | $d_detail, 812 | ]; 813 | } else { // More and more 814 | $jsoncomposite[$d_sub_desc_attr[$this->outputKey]][] = $d_detail; 815 | } 816 | } 817 | } else { 818 | $d_sub_desc_attr = $sub_details_desc[0]['attributes']; 819 | 820 | if (isset($this->codes) && isset($this->codes[$d_sub_desc_attr['id']]) && is_array($this->codes[$d_sub_desc_attr['id']])) { //if codes is set enable translation of the value 821 | if (isset($this->codes[$d_sub_desc_attr['id']][$detail]) && $this->codes[$d_sub_desc_attr['id']][$detail]) { 822 | $detail = $this->codes[$d_sub_desc_attr['id']][$detail]; 823 | } 824 | } 825 | $jsoncomposite[$d_sub_desc_attr[$this->outputKey]] = $detail; 826 | } 827 | } else { 828 | if (isset($this->codes) && isset($this->codes[$d_desc_attr['id']]) && is_array($this->codes[$d_desc_attr['id']])) { //if codes is set enable translation of the value 829 | if (isset($this->codes[$d_desc_attr['id']][$detail]) && $this->codes[$d_desc_attr['id']][$detail]) { 830 | $detail = $this->codes[$d_desc_attr['id']][$detail]; 831 | } 832 | } 833 | $jsoncomposite = $detail; 834 | } 835 | 836 | if (\array_key_exists($d_desc_attr[$this->outputKey], $jsonelements)) { 837 | $jsonelements[$d_desc_attr[$this->outputKey].$n] = $jsoncomposite; 838 | } else { 839 | $jsonelements[$d_desc_attr[$this->outputKey]] = $jsoncomposite; 840 | } 841 | } 842 | $jsonsegment['key'] = $attributes[$this->outputKey]; 843 | $jsonsegment['value'] = $jsonelements; 844 | } elseif ($xmlMap !== $this->xmlSvc) { 845 | $jsonsegment = $this->processSegment($segment, $this->xmlSvc, $segmentIdx, $errors); 846 | } else { 847 | $jsonsegment['key'] = 'UnrecognisedType'; 848 | $jsonsegment['value'] = $segment; 849 | } 850 | 851 | return $jsonsegment; 852 | } 853 | 854 | /** 855 | * Process UNB / UNZ segments 856 | */ 857 | private function processService(array &$segments): array 858 | { 859 | // init 860 | $processed = []; 861 | 862 | foreach ($segments as &$seg) { 863 | $jsonsegment = $this->processSegment($seg, $this->xmlSvc, null); 864 | $processed[$jsonsegment['key']] = $jsonsegment['value']; 865 | } 866 | 867 | return $processed; 868 | } 869 | 870 | public function rebuildArray() 871 | { 872 | if (isset($this->codes)) { 873 | throw new \LogicException('Run the Interpreter without calling setCodes()'); 874 | } 875 | $unh = $this->serviceSeg['interchangeHeader']; 876 | unset($unh['segmentIdx']); 877 | unset($unh['segmentGroup']); 878 | $unz = $this->serviceSeg['interchangeTrailer']; 879 | unset($unz['segmentIdx']); 880 | unset($unz['segmentGroup']); 881 | 882 | $rebuilt = $this->recursionReconstruct($this->ediGroups); 883 | 884 | array_unshift($rebuilt, $unh); 885 | $rebuilt[] = $unz; 886 | 887 | return $rebuilt; 888 | } 889 | 890 | private function recursionReconstruct($tempArr) 891 | { 892 | $reconstructArr = []; 893 | if (is_array($tempArr)) { 894 | foreach ($tempArr as $idx => $arr) { 895 | if (! isset($arr['segmentIdx'])) { 896 | $recurseArr = $this->recursionReconstruct($arr); 897 | foreach ($recurseArr as $k => $i) { 898 | $reconstructArr[$k] = $i; 899 | } 900 | } else { 901 | $idx = $arr['segmentIdx']; 902 | unset($arr['segmentIdx']); 903 | if ($this->segmentGroup) { 904 | unset($arr['segmentGroup']); 905 | } 906 | $reconstructArr[$idx] = $arr; 907 | } 908 | } 909 | } 910 | 911 | return $reconstructArr; 912 | } 913 | } 914 | -------------------------------------------------------------------------------- /src/EDI/Parser.php: -------------------------------------------------------------------------------- 1 | 107 | */ 108 | private static $encodingToStripChars = [ 109 | 'UNOA' => "/[\x01-\x1F\x80-\xFF]/", // not as restrictive as it should be 110 | 'UNOB' => "/[\x01-\x1F\x80-\xFF]/", 111 | 'UNOC' => "/[\x01-\x1F\x7F-\x9F]/", 112 | 'UNOE' => "/[\x20-\x7E]\xA0-\xFF/", 113 | ]; 114 | 115 | private static $charsets = [ 116 | // ISO 646, except lower case letters, alternative graphic chars, national or application-oriented graphic chars 117 | 'UNOA' => 'ASCII', 118 | // ISO 646, except alternative graphic chars and national or application-oriented graphic chars 119 | 'UNOB' => 'ASCII', 120 | 'UNOC' => 'ISO-8859-1', 121 | 'UNOD' => 'ISO-8859-2', 122 | 'UNOE' => 'ISO-8859-5', 123 | 'UNOF' => 'ISO-8859-7', 124 | ]; 125 | 126 | /** 127 | * @var bool TRUE when UNA characters are known. 128 | */ 129 | private $unaChecked = false; 130 | 131 | /** 132 | * @var bool TRUE when UNB encoding is known. 133 | */ 134 | private $unbChecked = false; 135 | 136 | /** 137 | * Optionally disable workarounds. 138 | */ 139 | private bool $strict = false; 140 | 141 | /** 142 | * Parse EDI array. 143 | */ 144 | public function parse(): self 145 | { 146 | $rawSegments = $this->getRawSegments(); 147 | if ($this->sourceEncoding && isset(self::$charsets[$this->syntaxID]) && self::$charsets[$this->syntaxID] !== $this->sourceEncoding) { 148 | $rawSegments = $this->convertEncoding($this->rawSegments, $this->sourceEncoding, self::$charsets[$this->syntaxID]); 149 | } 150 | 151 | $i = 0; 152 | foreach ($rawSegments as $line) { 153 | $i++; 154 | 155 | // Null byte and carriage return removal. (CR+LF) 156 | $line = \str_replace(["\x00", "\r", "\n"], '', $line); 157 | 158 | // Basic sanitization, remove non-printable chars. 159 | $lineTrim = \trim($line); 160 | $line = (string) \preg_replace($this->stripChars, '', $lineTrim); 161 | $line_bytes = \strlen($line); 162 | 163 | if ($line_bytes !== \strlen($lineTrim)) { 164 | $this->errors[] = 'Non-printable character on line '.$i.': '.$lineTrim; 165 | } 166 | 167 | if ($line_bytes < 2) { 168 | continue; 169 | } 170 | 171 | switch (\substr($line, 0, 3)) { 172 | case 'UNA': 173 | if (! $this->unaChecked) { 174 | $this->analyseUNA(\substr($line, 4, 6)); 175 | } 176 | 177 | break; 178 | case 'UNB': 179 | $line = $this->splitSegment($line); 180 | if (! $this->unbChecked) { 181 | $this->analyseUNB($line[1]); 182 | } 183 | $this->parsedfile[] = $line; 184 | 185 | break; 186 | case 'UNH': 187 | $line = $this->splitSegment($line); 188 | $this->analyseUNH($line); 189 | $this->parsedfile[] = $line; 190 | 191 | break; 192 | default: 193 | $line = $this->splitSegment($line); 194 | $this->parsedfile[] = $line; 195 | 196 | break; 197 | } 198 | } 199 | 200 | return $this; 201 | } 202 | 203 | /** 204 | * Read UNA's characters definition. 205 | * 206 | * @param string $line UNA definition line (without UNA tag). Example : :+.? ' 207 | * @return void 208 | */ 209 | public function analyseUNA(string $line) 210 | { 211 | $line = \substr($line, 0, 6); 212 | if (isset($line[0])) { 213 | $this->sepComp = \preg_quote($line[0], self::$DELIMITER); 214 | $this->sepUnescapedComp = $line[0]; 215 | if (isset($line[1])) { 216 | $this->sepData = \preg_quote($line[1], self::$DELIMITER); 217 | if (isset($line[2])) { 218 | $this->sepDec = $line[2]; // See later if a preg_quote is needed 219 | if (isset($line[3])) { 220 | $this->symbRel = \preg_quote($line[3], self::$DELIMITER); 221 | $this->symbUnescapedRel = $line[3]; 222 | if (isset($line[4])) { 223 | $this->symbRep = $line[4]; // See later if a preg_quote is needed 224 | if (isset($line[5])) { 225 | $this->symbEnd = \preg_quote($line[5], self::$DELIMITER); 226 | } 227 | } 228 | } 229 | } 230 | } 231 | $this->unaChecked = true; 232 | } 233 | } 234 | 235 | /** 236 | * UNB line analysis. 237 | * 238 | * @param string|string[] $line UNB definition line (without UNB tag). Example UNOA:2 239 | */ 240 | public function analyseUNB($line): void 241 | { 242 | if (\is_array($line)) { 243 | $line = $line[0]; 244 | } 245 | 246 | $this->syntaxID = $line; 247 | 248 | // If there's a regex defined for this character set, use it. 249 | /** @noinspection OffsetOperationsInspection */ 250 | if (isset(self::$encodingToStripChars[$line])) { 251 | /** @noinspection OffsetOperationsInspection */ 252 | $this->setStripRegex(self::$encodingToStripChars[$line]); 253 | } 254 | 255 | $this->unbChecked = true; 256 | } 257 | 258 | /** 259 | * Identify message type. 260 | * 261 | * @param array $line UNH segment 262 | */ 263 | public function analyseUNH(array $line): void 264 | { 265 | if (\count($line) < 3) { 266 | return; 267 | } 268 | 269 | $this->messageNumber = $line[1]; 270 | 271 | $lineElement = $line[2]; 272 | if (! \is_array($lineElement)) { 273 | $this->messageFormat = $lineElement; 274 | 275 | return; 276 | } 277 | 278 | $this->messageFormat = $lineElement[0]; 279 | $this->messageDirectory = $lineElement[2]; 280 | } 281 | 282 | /** 283 | * Check if the file's character encoding actually matches the one declared in the UNB header. 284 | * 285 | * @throws \LogicException 286 | * @throws \RuntimeException 287 | */ 288 | public function checkEncoding(): bool 289 | { 290 | if (empty($this->parsedfile)) { 291 | throw new \LogicException('No text has been parsed yet'); 292 | } 293 | if (! isset(self::$charsets[$this->syntaxID])) { 294 | throw new \RuntimeException('Unsupported syntax identifier: '.$this->syntaxID); 295 | } 296 | 297 | $check = mb_check_encoding($this->parsedfile, self::$charsets[$this->syntaxID]); 298 | if (! $check) { 299 | $this->errors[] = 'Character encoding does not match declaration in UNB interchange header'; 300 | } 301 | 302 | return $check; 303 | } 304 | 305 | /** 306 | * Get errors. 307 | */ 308 | public function errors(): array 309 | { 310 | return $this->errors; 311 | } 312 | 313 | /** 314 | * (Un)Set strict parsing. 315 | */ 316 | public function setStrict(bool $strict): void 317 | { 318 | $this->strict = $strict; 319 | } 320 | 321 | public function setSourceEncoding(string $sourceEncoding): void 322 | { 323 | $this->sourceEncoding = $sourceEncoding; 324 | } 325 | 326 | /** 327 | * Get parsed lines/segments. 328 | */ 329 | public function get(?string $encoding = null): array 330 | { 331 | if (empty($this->parsedfile)) { 332 | $this->parse(); 333 | } 334 | 335 | if (null === $encoding) { 336 | return $this->parsedfile; 337 | } 338 | 339 | return $this->convertEncoding($this->parsedfile, self::$charsets[$this->syntaxID], $encoding); 340 | } 341 | 342 | private function convertEncoding($data, string $from, string $to) 343 | { 344 | if (is_array($data)) { 345 | foreach ($data as $k => $v) { 346 | $data[$k] = $this->convertEncoding($v, $from, $to); 347 | } 348 | } elseif (is_string($data)) { 349 | $data = function_exists('iconv') ? iconv($from, $to.'//TRANSLIT', $data) : mb_convert_encoding($data, $to, $from); 350 | } 351 | 352 | return $data; 353 | } 354 | 355 | /** 356 | * Get raw segments array. 357 | * 358 | * @return array|string[] 359 | */ 360 | public function getRawSegments(): array 361 | { 362 | return $this->rawSegments; 363 | } 364 | 365 | /** 366 | * Get syntax identifier from the UNB header. 367 | * Does not necessarily mean that the text is actually encoded as such. 368 | * 369 | * @throws \RuntimeException 370 | */ 371 | public function getSyntaxIdentifier(): string 372 | { 373 | return $this->syntaxID; 374 | } 375 | 376 | /** 377 | * Load the message from file. 378 | * 379 | * @param string $location Either a local file path or a URL 380 | */ 381 | public function load(string $location): self 382 | { 383 | $contents = file_get_contents($location); 384 | if ($contents === false) { 385 | throw new \RuntimeException('File could not be retrieved'); 386 | } 387 | 388 | $this->loadString($contents); 389 | 390 | return $this; 391 | } 392 | 393 | /** 394 | * Load the message from a string. 395 | */ 396 | public function loadString(string $txt): self 397 | { 398 | $this->resetUNA(); 399 | $this->resetUNB(); 400 | $this->rawSegments = $this->unwrap($txt); 401 | 402 | return $this; 403 | } 404 | 405 | /** 406 | * Load a raw or parsed message from an array of strings. 407 | * 408 | * @param bool $raw If the data hasn't been parsed yet 409 | */ 410 | public function loadArray(array $lines, bool $raw = true): self 411 | { 412 | if ($raw) { 413 | $this->resetUNA(); 414 | $this->resetUNB(); 415 | $this->rawSegments = $lines; 416 | if (\count($lines) === 1) { 417 | $this->loadString($lines[0]); 418 | } 419 | } else { 420 | $this->rawSegments = []; 421 | $this->parsedfile = $lines; 422 | } 423 | 424 | return $this; 425 | } 426 | 427 | /** 428 | * Change the default regex used for stripping invalid characters. 429 | * 430 | * @return void 431 | */ 432 | public function setStripRegex(string $regex) 433 | { 434 | $this->stripChars = $regex; 435 | } 436 | 437 | /** 438 | * Get the message type ID. 439 | * 440 | * @return string|null 441 | */ 442 | public function getMessageFormat() 443 | { 444 | return $this->messageFormat; 445 | } 446 | 447 | /** 448 | * Get the message type release number. 449 | * 450 | * @return string|null 451 | */ 452 | public function getMessageDirectory() 453 | { 454 | return $this->messageDirectory; 455 | } 456 | 457 | /** 458 | * @return string|null 459 | */ 460 | public function getMessageNumber() 461 | { 462 | return $this->messageNumber; 463 | } 464 | 465 | /** 466 | * Reset UNA character definition to defaults. 467 | */ 468 | private function resetUNA(): void 469 | { 470 | $this->sepComp = "\:"; 471 | $this->sepUnescapedComp = ':'; 472 | $this->sepData = "\+"; 473 | $this->sepDec = '.'; // See later if a preg_quote is needed 474 | $this->symbRel = "\?"; 475 | $this->symbUnescapedRel = '?'; 476 | $this->symbRep = '*'; // See later if a preg_quote is needed 477 | $this->symbEnd = "'"; 478 | $this->stringSafe = '§SS§'; 479 | $this->unaChecked = false; 480 | } 481 | 482 | /** 483 | * Reset UNB encoding definition to defaults. 484 | */ 485 | private function resetUNB(): void 486 | { 487 | $this->syntaxID = ''; 488 | $this->unbChecked = false; 489 | } 490 | 491 | /** 492 | * Unwrap string splitting rows on terminator (if not escaped). 493 | * 494 | * @return string[] 495 | */ 496 | private function unwrap(string &$string): array 497 | { 498 | if ( 499 | ! $this->unaChecked 500 | && 501 | \strpos($string, 'UNA') === 0 502 | ) { 503 | $this->analyseUNA( 504 | \substr(\substr($string, 3), 0, 9) 505 | ); 506 | } 507 | 508 | $unbRegex = sprintf( 509 | '/^(UNA[^%1$s]+%1$s\W?\W?)?UNB%2$s(?\w{4})%3$s/m', 510 | $this->symbEnd, 511 | $this->sepData, 512 | $this->sepComp, 513 | ); 514 | if ( 515 | ! $this->unbChecked 516 | && false !== preg_match($unbRegex, $string, $unbMatches) 517 | && isset($unbMatches['syntax_identifier']) 518 | ) { 519 | $this->analyseUNB($unbMatches['syntax_identifier']); 520 | } 521 | if (preg_match_all("/[A-Z0-9]+(?:\?'|$)[\r\n]+/i", $string, $matches, PREG_OFFSET_CAPTURE) > 0) { 522 | $this->errors[] = 'This file contains some segments without terminators'; 523 | } 524 | 525 | $terminatorRegex = '/((?symbRel.')(?:'.$this->symbRel.$this->symbRel.')*)'.$this->symbEnd.'|[\r\n]+/'; 526 | 527 | if ($this->strict) { 528 | $terminatorRegex = '/((?symbRel.')(?:'.$this->symbRel.$this->symbRel.')*)'.$this->symbEnd.'/'; 529 | } 530 | 531 | $string = (string) \preg_replace( 532 | $terminatorRegex, 533 | '$1'.$this->stringSafe, 534 | $string 535 | ); 536 | 537 | $file = \preg_split( 538 | self::$DELIMITER.$this->stringSafe.self::$DELIMITER.'i', 539 | $string 540 | ); 541 | // fallback 542 | if ($file === false) { 543 | $file = []; 544 | } 545 | 546 | $end = \stripslashes($this->symbEnd.''); 547 | foreach ($file as $fc => &$line) { 548 | if (\trim($line) == '') { 549 | /* @noinspection OffsetOperationsInspection */ 550 | unset($file[$fc]); 551 | } 552 | $line .= $end; 553 | } 554 | 555 | return $file; 556 | } 557 | 558 | /** 559 | * Split segment. 560 | * 561 | * @return array|string[] 562 | */ 563 | private function splitSegment(string &$str): array 564 | { 565 | // remove ending "symbEnd" 566 | $str = \trim( 567 | (string) \preg_replace( 568 | self::$DELIMITER.$this->symbEnd.'$'.self::$DELIMITER, 569 | '', 570 | $str 571 | ) 572 | ); 573 | 574 | // replace duplicate "symbRel" 575 | $str = \str_replace( 576 | $this->symbUnescapedRel.$this->symbUnescapedRel.'', 577 | $this->stringSafe ?? '', 578 | $str 579 | ); 580 | 581 | // split on "sepData" if not escaped (negative lookbehind) 582 | $matches = \preg_split( 583 | self::$DELIMITER.'(?symbRel.')'.$this->sepData.self::$DELIMITER, 584 | $str 585 | ); 586 | // fallback 587 | if ($matches === false) { 588 | $matches = []; 589 | } 590 | 591 | foreach ($matches as &$value) { 592 | if ($value === '') { 593 | continue; 594 | } 595 | 596 | // INFO: 597 | // 598 | // ? immediately preceding one of the characters '+:? restores their normal meaning 599 | // 600 | // e.g. 10?+10=20 means 10+10=20 601 | // 602 | // Question mark is represented by ?? 603 | 604 | if ( 605 | $this->symbEnd 606 | && 607 | \strpos($value, $this->symbEnd) !== false 608 | ) { 609 | if (\preg_match(self::$DELIMITER.'(?symbRel.')'.$this->symbEnd.self::$DELIMITER, $value)) { 610 | $this->errors[] = "There's a ".\stripslashes($this->symbEnd).' not escaped in the data; string '.$str; 611 | } 612 | } 613 | 614 | if ( 615 | $this->symbUnescapedRel 616 | && 617 | \strpos($value, $this->symbUnescapedRel) !== false 618 | ) { 619 | if (\preg_match(self::$DELIMITER.'(?symbRel.')'.$this->symbRel.'(?!'.$this->symbRel.')(?!'.$this->sepData.')(?!'.$this->sepComp.')(?!'.$this->symbEnd.')'.self::$DELIMITER, $value)) { 620 | $this->errors[] = "There's a character not escaped with ".\stripslashes($this->symbRel ?? '').' in the data; string '.$value; 621 | } 622 | } 623 | 624 | // split on "sepComp" 625 | $value = $this->splitData($value); 626 | } 627 | 628 | return $matches; 629 | } 630 | 631 | /** 632 | * Composite data element. 633 | * 634 | * @return mixed 635 | */ 636 | private function splitData(string &$str) 637 | { 638 | if ($str === '') { 639 | return $str; 640 | } 641 | 642 | $replace = function (&$string) { 643 | if ($this->symbUnescapedRel && \strpos($string, $this->symbUnescapedRel) !== false) { 644 | $string = \preg_replace( 645 | self::$DELIMITER.$this->symbRel.'(?='.$this->symbRel.')|'.$this->symbRel.'(?='.$this->sepData.')|'.$this->symbRel.'(?='.$this->sepComp.')|'.$this->symbRel.'(?='.$this->symbEnd.')'.self::$DELIMITER, 646 | '', 647 | $string 648 | ); 649 | } 650 | 651 | return \str_replace( 652 | $this->stringSafe ?? '', 653 | $this->symbUnescapedRel ?? '', 654 | $string 655 | ); 656 | }; 657 | 658 | // check for "sepUnescapedComp" in the string 659 | if ($this->sepUnescapedComp && \strpos($str, $this->sepUnescapedComp) === false) { 660 | return $replace($str); 661 | } 662 | 663 | // split on "sepComp" if not escaped (negative lookbehind) 664 | $array = \preg_split( 665 | self::$DELIMITER.'(?symbRel.')'.$this->sepComp.self::$DELIMITER, 666 | $str 667 | ); 668 | // fallback 669 | if ($array === false) { 670 | $array = [[]]; 671 | } 672 | 673 | if (\count($array) === 1) { 674 | return $replace($str); 675 | } 676 | 677 | foreach ($array as &$value) { 678 | $value = $replace($value); 679 | } 680 | 681 | return $array; 682 | } 683 | } 684 | -------------------------------------------------------------------------------- /src/EDI/Reader.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | private $errors = []; 22 | 23 | /** 24 | * Reader constructor. 25 | * 26 | * @param Parser $parser With a message already loaded. 27 | */ 28 | public function __construct(Parser $parser) 29 | { 30 | $this->parser = $parser; 31 | $this->preValidate(); 32 | } 33 | 34 | /** 35 | * Get errors 36 | * 37 | * @return array 38 | */ 39 | public function errors(): array 40 | { 41 | return $this->errors; 42 | } 43 | 44 | /** 45 | * reset errors 46 | * 47 | * @return void 48 | */ 49 | public function resetErrors() 50 | { 51 | $this->errors = []; 52 | } 53 | 54 | /** 55 | * Returns the parsed message. 56 | * 57 | * @returns array 58 | */ 59 | public function getParsedFile(): array 60 | { 61 | return $this->parser->get(); 62 | } 63 | 64 | /** 65 | * Do initial validation 66 | */ 67 | public function preValidate(): bool 68 | { 69 | $this->errors = []; 70 | 71 | if (! \is_array($this->getParsedFile())) { 72 | $this->errors[] = 'Incorrect format parsed file'; 73 | 74 | return false; 75 | } 76 | 77 | $r = $this->readUNHmessageNumber(); 78 | if ( 79 | ! $r 80 | && 81 | ( 82 | $this->errors !== [] 83 | && 84 | $this->errors[0] == 'Segment "UNH" is ambiguous' 85 | ) 86 | ) { 87 | $this->errors = []; 88 | $this->errors[] = 'File has multiple messages'; 89 | 90 | return false; 91 | } 92 | 93 | return true; 94 | } 95 | 96 | /** 97 | * Split multi messages to separate messages 98 | * 99 | * 100 | * @return array 101 | */ 102 | public static function splitMultiMessage(string $ediMessage): array 103 | { 104 | // init 105 | $splicedMessages = []; 106 | $message = []; 107 | $unb = false; 108 | $segment = ''; 109 | 110 | foreach (self::unwrap($ediMessage) as $segment) { 111 | if (\strpos($segment, 'UNB') === 0) { 112 | $unb = $segment; 113 | 114 | continue; 115 | } 116 | 117 | if (\strpos($segment, 'UNH') === 0) { 118 | if ($unb) { 119 | $message[] = $unb; 120 | } 121 | $message[] = $segment; 122 | 123 | continue; 124 | } 125 | 126 | if (\strpos($segment, 'UNT') === 0) { 127 | $message[] = $segment; 128 | $splicedMessages[] = $message; 129 | $message = []; 130 | 131 | continue; 132 | } 133 | 134 | if ($message) { 135 | $message[] = $segment; 136 | } 137 | } 138 | 139 | if (\strpos($segment, 'UNZ') === 0) { 140 | $segment = \preg_replace('#UNZ\+\d+\+#', 'UNZ+1+', $segment); 141 | foreach ($splicedMessages as $k => $message) { 142 | $splicedMessages[$k][] = $segment; 143 | } 144 | } 145 | 146 | foreach ($splicedMessages as $k => &$message) { 147 | $message = \implode(\PHP_EOL, $splicedMessages[$k]); 148 | } 149 | 150 | return $splicedMessages; 151 | } 152 | 153 | /** 154 | * read required value. if no found, registered error 155 | * 156 | * @param array|string $filter segment filter by segment name and values 157 | * @param false|int $l2 158 | * @return string|null 159 | */ 160 | public function readEdiDataValueReq($filter, int $l1, $l2 = false) 161 | { 162 | return $this->readEdiDataValue($filter, $l1, $l2, true); 163 | } 164 | 165 | /** 166 | * read data value from parsed EDI data 167 | * 168 | * @param array|string $filter 'AGR' - segment code 169 | * or ['AGR',['1'=>'BB']], where AGR segment code and first element equal 'BB' 170 | * or ['AGR',['1.0'=>'BB']], where AGR segment code and first element zero subelement 171 | * equal 'BB' 172 | * @param int $l1 first level item number (start by 1) 173 | * @param false|int $l2 second level item number (start by 0) 174 | * @param bool $required if required, but no exist, register error 175 | * @param int|null $offset if multiple segments found, get segment by offset 176 | * @return string|null 177 | */ 178 | public function readEdiDataValue($filter, int $l1, $l2 = false, bool $required = false, ?int $offset = null) 179 | { 180 | $found_segments = []; 181 | $segment_name = $filter; 182 | $filter_elements = false; 183 | if (\is_array($filter)) { 184 | $segment_name = $filter[0]; 185 | $filter_elements = $filter[1]; 186 | } 187 | 188 | // search segments which conform to filter 189 | foreach ($this->getParsedFile() as $edi_row) { 190 | if ($edi_row[0] == $segment_name) { 191 | if ($filter_elements) { 192 | foreach ($filter_elements as $el_id => $el_value) { 193 | $filter_ok = false; 194 | $f_el_list = \explode('.', (string) $el_id); 195 | if (\count($f_el_list) === 1) { 196 | if (isset($edi_row[$el_id]) && $edi_row[$el_id] == $el_value) { 197 | $filter_ok = true; 198 | } 199 | } elseif ( 200 | isset($edi_row[$f_el_list[0]]) 201 | && ( 202 | ( 203 | isset($edi_row[$f_el_list[0]][$f_el_list[1]]) 204 | && \is_array($edi_row[$f_el_list[0]]) 205 | && $edi_row[$f_el_list[0]][$f_el_list[1]] == $el_value 206 | ) || ( 207 | isset($edi_row[$f_el_list[0]]) 208 | && \is_string($edi_row[$f_el_list[0]]) 209 | && $edi_row[$f_el_list[0]] == $el_value 210 | ) 211 | ) 212 | ) { 213 | $filter_ok = true; 214 | } 215 | if ($filter_ok === false) { 216 | break; 217 | } 218 | } 219 | 220 | if ($filter_ok === false) { 221 | continue; 222 | } 223 | } 224 | $found_segments[] = $edi_row; 225 | } 226 | } 227 | 228 | try { 229 | if ($offset !== null) { 230 | $segment = $this->getOffsetSegmentFromResult($found_segments, $offset, $required, $segment_name); 231 | } else { 232 | $segment = $this->getSegmentFromResult($found_segments, $required, $segment_name); 233 | } 234 | } catch (ReaderException $e) { 235 | $this->errors[] = $e->getMessage(); 236 | 237 | return null; 238 | } 239 | 240 | if ($segment === false) { 241 | return null; 242 | } 243 | 244 | // validate elements 245 | if (! isset($segment[$l1])) { 246 | if ($required) { 247 | $this->errors[] = 'Segment value "'.$segment_name.'['.$l1.']" no exist'; 248 | } 249 | 250 | return null; 251 | } 252 | 253 | // requested first level element 254 | if ($l2 === false) { 255 | return $segment[$l1]; 256 | } 257 | 258 | // requested second level element, but not exist 259 | if (! \is_array($segment[$l1]) || ! isset($segment[$l1][$l2])) { 260 | if ($required) { 261 | $this->errors[] = 'Segment value "'.$segment_name.'['.$l1.']['.$l2.']" no exist'; 262 | } 263 | 264 | return null; 265 | } 266 | 267 | // second level element 268 | return $segment[$l1][$l2]; 269 | } 270 | 271 | /** 272 | * read date from DTM segment period qualifier - codelist 2005 273 | * 274 | * @param int $PeriodQualifier period qualifier (codelist/2005) 275 | * @return string|null YYYY-MM-DD HH:MM:SS 276 | */ 277 | public function readEdiSegmentDTM($PeriodQualifier) 278 | { 279 | $date = $this->readEdiDataValue([ 280 | 'DTM', [ 281 | '1.0' => $PeriodQualifier, 282 | ]], 1, 1); 283 | $format = $this->readEdiDataValue([ 284 | 'DTM', [ 285 | '1.0' => $PeriodQualifier, 286 | ]], 1, 2); 287 | if (empty($date)) { 288 | return $date; 289 | } 290 | switch ($format) { 291 | case 203: //CCYYMMDDHHMM 292 | return \preg_replace('#(\d\d\d\d)(\d\d)(\d\d)(\d\d)(\d\d)#', '$1-$2-$3 $4:$5:00', $date); 293 | 294 | case 102: //CCYYMMDD 295 | return \preg_replace('/(\d{4})(\d{2})(\d{2})/', '$1-$2-$3', $date); 296 | 297 | default: 298 | return $date; 299 | } 300 | } 301 | 302 | /** 303 | * @deprecated 304 | * 305 | * @return string|null 306 | */ 307 | public function readUNBDateTimeOfPpreperation() 308 | { 309 | return $this->readUNBDateTimeOfPreparation(); 310 | } 311 | 312 | /** 313 | * @deprecated 314 | * 315 | * @return string|null 316 | */ 317 | public function readUNBDateTimeOfPreperation() 318 | { 319 | return $this->readUNBDateTimeOfPreparation(); 320 | } 321 | 322 | /** 323 | * get message preparation time 324 | * 325 | * @return string|null 326 | */ 327 | public function readUNBDateTimeOfPreparation() 328 | { 329 | // separate date (YYMMDD) and time (HHMM) 330 | $date = $this->readEdiDataValue('UNB', 4, 0); 331 | if (! empty($date)) { 332 | $time = $this->readEdiDataValue('UNB', 4, 1); 333 | if ($time !== null) { 334 | $time = (string) \preg_replace('#(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)#', '20$1-$2-$3 $4:$5:00', $date.$time); 335 | } 336 | 337 | return $time; 338 | } 339 | 340 | // common YYYYMMDDHHMM 341 | $datetime = $this->readEdiDataValue('UNB', 4); 342 | if ($datetime !== null) { 343 | $datetime = (string) \preg_replace('#(\d\d\d\d)(\d\d)(\d\d)(\d\d)(\d\d)#', '$1-$2-$3 $4:$5:00', $datetime); 344 | } 345 | 346 | return $datetime; 347 | } 348 | 349 | public function readUNBInterchangeSender() 350 | { 351 | return $this->readEdiDataValue('UNB', 2); 352 | } 353 | 354 | public function readUNBInterchangeRecipient() 355 | { 356 | return $this->readEdiDataValue('UNB', 3); 357 | } 358 | 359 | /** 360 | * read transport identification number 361 | * 362 | * @param mixed $transportStageQualifier 363 | * @return string|null 364 | */ 365 | public function readTDTtransportIdentification($transportStageQualifier) 366 | { 367 | $transportIdentification = $this->readEdiDataValue([ 368 | 'TDT', [ 369 | '1' => $transportStageQualifier, 370 | ]], 8, 0); 371 | if (! empty($transportIdentification)) { 372 | return $transportIdentification; 373 | } 374 | 375 | return $this->readEdiDataValue([ 376 | 'TDT', [ 377 | '1' => $transportStageQualifier, 378 | ]], 8); 379 | } 380 | 381 | /** 382 | * read message type 383 | * 384 | * @return string|null 385 | */ 386 | public function readUNHmessageType() 387 | { 388 | return $this->readEdiDataValue('UNH', 2, 0); 389 | } 390 | 391 | /** 392 | * read message number 393 | * 394 | * @return string|null 395 | */ 396 | public function readUNHmessageNumber() 397 | { 398 | return $this->readEdiDataValue('UNH', 1); 399 | } 400 | 401 | /** 402 | * read message number 403 | * 404 | * @return string|null 405 | */ 406 | public function readUNHmessageRealise() 407 | { 408 | return $this->readEdiDataValue('UNH', 3); 409 | } 410 | 411 | /** 412 | * Get groups from message. 413 | * 414 | * @param string $before segment before groups 415 | * @param string $start first segment of group 416 | * @param string $end last segment of group 417 | * @param string $after segment after groups 418 | * @return array|false 419 | */ 420 | public function readGroups(string $before, string $start, string $end, string $after) 421 | { 422 | // init 423 | $groups = []; 424 | $group = []; 425 | $position = 'before_search'; 426 | 427 | foreach ($this->getParsedFile() as $edi_row) { 428 | // search before group segment 429 | if ($position == 'before_search' && $edi_row[0] == $before) { 430 | $position = 'before_is'; 431 | 432 | continue; 433 | } 434 | 435 | if ($position == 'before_search') { 436 | continue; 437 | } 438 | 439 | if ($position == 'before_is' && $edi_row[0] == $before) { 440 | continue; 441 | } 442 | 443 | // after before search start 444 | if ($position == 'before_is' && $edi_row[0] == $start) { 445 | $position = 'group_is'; 446 | $group[] = $edi_row; 447 | 448 | continue; 449 | } 450 | 451 | // if after before segment no start segment, search again before segment 452 | if ($position == 'before_is') { 453 | $position = 'before_search'; 454 | 455 | continue; 456 | } 457 | 458 | // get group element 459 | if ($position == 'group_is' && $edi_row[0] != $end) { 460 | $group[] = $edi_row; 461 | 462 | continue; 463 | } 464 | 465 | // found end of group 466 | if ($position == 'group_is' && $edi_row[0] == $end) { 467 | $position = 'group_finish'; 468 | $group[] = $edi_row; 469 | $groups[] = $group; 470 | $group = []; 471 | 472 | continue; 473 | } 474 | 475 | // next group start 476 | if ($position == 'group_finish' && $edi_row[0] == $start) { 477 | $group[] = $edi_row; 478 | $position = 'group_is'; 479 | 480 | continue; 481 | } 482 | 483 | // finish 484 | if ($position == 'group_finish' && $edi_row[0] == $after) { 485 | break; 486 | } 487 | 488 | $this->errors[] = 'Reading group '.$before.'/'.$start.'/'.$end.'/'.$after 489 | .'. Error on position: '.$position; 490 | 491 | return false; 492 | } 493 | 494 | return $groups; 495 | } 496 | 497 | /** 498 | * Get groups from message when last segment is unknown but you know the barrier. 499 | * Useful for invoices by default. 500 | * 501 | * @param string $start first segment start a new group 502 | * @param array $barrier barrier segment (NOT in group) 503 | * @return array Containing parsed lines 504 | */ 505 | public function groupsExtract(string $start = 'LIN', array $barrier = ['UNS']): array 506 | { 507 | // init 508 | $groups = []; 509 | $group = []; 510 | $position = 'before_search'; 511 | 512 | foreach ($this->getParsedFile() as $edi_row) { 513 | $segment = $edi_row[0]; 514 | if ( 515 | $position == 'group_is' 516 | && ( 517 | $segment == $start 518 | || 519 | \in_array($segment, $barrier, true) 520 | ) 521 | ) { 522 | // end of group 523 | $groups[] = $group; 524 | // start new group 525 | $group = []; 526 | $position = 'group_finish'; 527 | } 528 | 529 | if ($segment == $start) { 530 | $position = 'group_is'; 531 | } 532 | 533 | // add element to group 534 | if ($position == 'group_is') { 535 | $group[] = $edi_row; 536 | } 537 | } 538 | 539 | return $groups; 540 | } 541 | 542 | /** 543 | * unwrap string splitting rows on terminator (if not escaped) 544 | * 545 | * @param string $string 546 | * @return \Generator 547 | */ 548 | private static function unwrap($string) 549 | { 550 | $array = \preg_split("/(? 1) { 587 | throw new ReaderException('Segment "'.$segment_name.'" is ambiguous'); 588 | } 589 | 590 | if ($required && ! isset($matchingSegments[0])) { 591 | throw new ReaderException('Segment "'.$segment_name.'" no exist'); 592 | } 593 | 594 | return $matchingSegments[0] ?? false; 595 | } 596 | } 597 | -------------------------------------------------------------------------------- /src/EDI/ReaderException.php: -------------------------------------------------------------------------------- 1 |