From ec8a103909964f028911587e468e6a0043f3dcde Mon Sep 17 00:00:00 2001 From: Josh Green Date: Thu, 26 Feb 2026 11:57:37 +0000 Subject: [PATCH 1/4] feat(orders): add compatibility APIs for custom segments --- src/Generator/Orders.php | 42 +++++++++++- src/Generator/Traits/Item.php | 105 +++++++++++++++++++++++++++-- src/Generator/Traits/ItemPrice.php | 30 ++++++++- 3 files changed, 167 insertions(+), 10 deletions(-) diff --git a/src/Generator/Orders.php b/src/Generator/Orders.php index 6c33fb8..47ff8f0 100755 --- a/src/Generator/Orders.php +++ b/src/Generator/Orders.php @@ -59,6 +59,9 @@ class Orders extends Message /** @var array */ protected $objectDescription1; + /** @var array */ + protected $customIdentifier; + /** @var array */ protected $objectDescription2; @@ -99,6 +102,7 @@ class Orders extends Message 'objectNumber', 'objectDescription1', 'objectDescription2', + 'customIdentifier', 'vatNumber', 'currency', 'manufacturerAddress', @@ -219,13 +223,33 @@ public function setOrderNumber($orderNumber, $documentType = '220') /** * Order number without documentType validation - * @param $orderNumber + * + * If the document type is non-numeric (e.g. EANCOM style), use code-list agency 28. + * + * @param string $orderNumber * @param string $documentType + * @param string|null $documentTypeCodeListAgency * @return $this */ - public function setCustomOrderNumber($orderNumber, $documentType = '220') - { + public function setCustomOrderNumber($orderNumber, $documentType = '220', $documentTypeCodeListAgency = null) + { + if ($documentTypeCodeListAgency !== null || preg_match('/[A-Za-z]/', (string) $documentType) === 1) { + $this->orderNumber = [ + 'BGM', + [ + (string) $documentType, + '', + (string) ($documentTypeCodeListAgency ?? '28'), + ], + (string) $orderNumber, + '9', + ]; + + return $this; + } + $this->orderNumber = ['BGM', $documentType, $orderNumber, '9']; + return $this; } @@ -442,6 +466,18 @@ public function setObjectNumber($objectNumber) return $this; } + /** + * set a custom identifier reference for qualifier ON + * @param string $customOrderIdentifier + * @return $this + */ + public function setCustomIdentifier($customOrderIdentifier) + { + $this->customIdentifier = $this->addRFFSegment('ON', $customOrderIdentifier); + + return $this; + } + /** * @return array */ diff --git a/src/Generator/Traits/Item.php b/src/Generator/Traits/Item.php index e0414ec..9e07622 100755 --- a/src/Generator/Traits/Item.php +++ b/src/Generator/Traits/Item.php @@ -38,6 +38,12 @@ trait Item /** @var array */ protected $deliveryNotePosition; + + /** @var array */ + protected $qli; + + /** @var int */ + protected $dynamicSegmentCounter = 0; /** @var array IMD ZU */ protected $additionalText; @@ -62,6 +68,7 @@ trait Item 'orderPosition', 'deliveryNoteNumber', 'deliveryNotePosition', + 'qli', ]; /** @@ -171,14 +178,91 @@ public function setQuantity($quantity, $unit = 'PCE', $qualifier = '21') ] ); + $qty = [ + (string) $qualifier, + (string) $quantity, + ]; + + if ((string) $qualifier !== '1') { + $qty[] = $unit; + } + $this->quantity = [ 'QTY', + $qty, + ]; + + return $this; + } + + /** + * Add item information line (IMD). + * + * @param string $code + * @param string $information + * @return $this + */ + public function addInformation($code, $information) + { + return $this->addDynamicSegment([ + 'IMD', + 'L', + (string) $code, [ - (string)$qualifier, - (string)$quantity, - $unit, + '', + '', + '', + (string) $information, ], - ]; + ]); + } + + /** + * Add item details line (GIR). + * + * @param int $index + * @param string $lloLocationCode + * @param string $lfnPurchaseFundCode + * @param string $lcvDecimalPrice + * @param string|null $lsqFundCode + * @return $this + */ + public function addGir($index, $lloLocationCode, $lfnPurchaseFundCode, $lcvDecimalPrice, $lsqFundCode = '') + { + return $this->addDynamicSegment([ + 'GIR', + str_pad((string) $index, 3, '0', STR_PAD_LEFT), + [ + (string) $lloLocationCode, + 'LLO', + ], + [ + (string) ($lsqFundCode ?? ''), + 'LSQ', + ], + [ + (string) $lfnPurchaseFundCode, + 'LFN', + ], + [ + (string) $lcvDecimalPrice, + 'LCV', + ], + ]); + } + + /** + * Register a dynamic segment key while preserving insertion order. + * + * @param array $segment + * @return $this + */ + private function addDynamicSegment($segment) + { + $key = 'dynamicSegment' . (++$this->dynamicSegmentCounter); + + $this->{$key} = $segment; + $this->addKeyToCompose($key); return $this; } @@ -365,6 +449,19 @@ public function setOrderPosition($orderPosition) return $this; } + + /** + * Set quote/order line identifier. + * + * @param string $orderPosition + * @return $this + */ + public function setQli($orderPosition) + { + $this->qli = $this->addRFFSegment('QLI', $orderPosition); + + return $this; + } /** * @return array */ diff --git a/src/Generator/Traits/ItemPrice.php b/src/Generator/Traits/ItemPrice.php index 703a0d9..7d96af4 100755 --- a/src/Generator/Traits/ItemPrice.php +++ b/src/Generator/Traits/ItemPrice.php @@ -17,6 +17,9 @@ trait ItemPrice /** @var array */ protected $netPrice; + /** @var array */ + protected $ourPrice; + /** * @param $qualifier * @param $value @@ -35,9 +38,9 @@ public static function addPRISegment($qualifier, $value, $priceBase = 1, $priceB EdiFactNumber::convert($value, $decimals, $format), '', '', - (string)$priceBase, - $priceBaseUnit - ] + (string) $priceBase, + $priceBaseUnit, + ], ]; } @@ -84,4 +87,25 @@ public function setNetPrice($netPrice, $format = EdiFactNumber::DECIMAL_COMMA, $ return $this; } + + /** + * @param string $ourPrice + * @param string $format + * @param int $decimals + * @return $this + */ + public function setOurPrice($ourPrice, $format = EdiFactNumber::DECIMAL_POINT, $decimals = 2) + { + $this->ourPrice = [ + 'PRI', + [ + 'AAE', + EdiFactNumber::convert($ourPrice, $decimals, $format), + ], + ]; + + $this->addKeyToCompose('ourPrice'); + + return $this; + } } From 1963e051b08e80fd0222fee45a4dee56c8ea1c93 Mon Sep 17 00:00:00 2001 From: Josh Green Date: Thu, 26 Feb 2026 11:57:42 +0000 Subject: [PATCH 2/4] fix(format): preserve legacy UNB and NAD output shape --- src/Generator/Interchange.php | 43 +++++++++++++++++++------ src/Generator/Traits/NameAndAddress.php | 41 ++++++++++++++++++++++- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/Generator/Interchange.php b/src/Generator/Interchange.php index 0c7e101..84eada4 100644 --- a/src/Generator/Interchange.php +++ b/src/Generator/Interchange.php @@ -106,23 +106,48 @@ public function getMessages() public function compose() { $temp = []; - $unb = ['UNB', $this->charset, $this->sender, $this->receiver, [$this->date, $this->time], $this->interchangeCode]; - if ($this->appref !== null) { - $unb[] = ''; - $unb[] = $this->appref; - } + $composedMessages = []; - $temp[] = $unb; foreach ($this->messages as $msg) { $msgContent = $msg->getComposed(); if ($msgContent === null) { $msgContent = $msg->compose()->getComposed(); } - foreach ($msgContent as $i) { - $temp[] = $i; + + $composedMessages[] = $msgContent; + } + + $applicationReference = $this->appref; + if ($applicationReference === null && isset($composedMessages[0][0][2][0])) { + $applicationReference = (string) $composedMessages[0][0][2][0]; + } + + $unb = [ + 'UNB', + $this->charset, + $this->sender, + $this->receiver, + [$this->date, $this->time], + $this->interchangeCode, + ]; + + if ($applicationReference !== null && $applicationReference !== '') { + $unb[] = $applicationReference; + $unb[] = []; + $unb[] = []; + $unb[] = []; + $unb[] = []; + } + + $temp[] = $unb; + + foreach ($composedMessages as $msgContent) { + foreach ($msgContent as $entry) { + $temp[] = $entry; } } - $temp[] = ['UNZ', (string)count($this->messages), $this->interchangeCode]; + + $temp[] = ['UNZ', (string) count($this->messages), $this->interchangeCode]; $this->composed = $temp; return $this; diff --git a/src/Generator/Traits/NameAndAddress.php b/src/Generator/Traits/NameAndAddress.php index 3960be6..66d5224 100755 --- a/src/Generator/Traits/NameAndAddress.php +++ b/src/Generator/Traits/NameAndAddress.php @@ -166,7 +166,8 @@ public function addNameAndAddress( if ($name3) { $name[] = self::maxChars($name3); } - return [ + + $segment = [ 'NAD', $type, [ @@ -188,6 +189,44 @@ public function addNameAndAddress( self::maxChars($countryCode, 2), ], ]; + + return $this->trimTrailingEmptyValues($segment); + } + + /** + * @param array $segment + * @return array + */ + private function trimTrailingEmptyValues($segment) + { + while (!empty($segment)) { + $last = end($segment); + if (!$this->isEmptyValue($last)) { + break; + } + array_pop($segment); + } + + return $segment; + } + + /** + * @param mixed $value + * @return bool + */ + private function isEmptyValue($value) + { + if (is_array($value)) { + foreach ($value as $entry) { + if (!$this->isEmptyValue($entry)) { + return false; + } + } + + return true; + } + + return $value === '' || $value === null; } /** From 4a8857de6d5064f61354fa659dfb8ce895483fde Mon Sep 17 00:00:00 2001 From: Josh Green Date: Thu, 26 Feb 2026 11:57:47 +0000 Subject: [PATCH 3/4] test(orders): add compatibility coverage for custom EDI output --- .../GeneratorTest/OrdersCompatibilityTest.php | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/GeneratorTest/OrdersCompatibilityTest.php diff --git a/tests/GeneratorTest/OrdersCompatibilityTest.php b/tests/GeneratorTest/OrdersCompatibilityTest.php new file mode 100644 index 0000000..948838b --- /dev/null +++ b/tests/GeneratorTest/OrdersCompatibilityTest.php @@ -0,0 +1,126 @@ +setCustomOrderNumber('ORDER-1001', '31C') + ->setCustomIdentifier('ORDER-1001'); + + $item = (new Orders\Item()) + ->setPosition('1', '1234567890123', 'EN') + ->setQuantity('1'); + + $orders->addItem($item); + $orders->compose(); + + $message = $this->encodeOrders($orders); + + self::assertStringContainsString("BGM+31C::28+ORDER-1001+9'", $message); + self::assertStringContainsString("RFF+ON:ORDER-1001'", $message); + } + + public function test_numeric_custom_order_number_stays_scalar_by_default(): void + { + $orders = (new Orders()) + ->setCustomOrderNumber('ORDER-220', '220'); + + $item = (new Orders\Item()) + ->setPosition('1', '1234567890123', 'EN') + ->setQuantity('1'); + + $orders->addItem($item); + $orders->compose(); + + $message = $this->encodeOrders($orders); + + self::assertStringContainsString("BGM+220+ORDER-220+9'", $message); + self::assertStringNotContainsString("BGM+220::28+ORDER-220+9'", $message); + } + + public function test_item_compatibility_segments_are_emitted_in_expected_shape(): void + { + $orders = (new Orders()) + ->setCustomOrderNumber('ORDER-2001', '31C') + ->setCustomIdentifier('ORDER-2001'); + + $item = (new Orders\Item()) + ->setPosition('1', '1234567890123', 'EN') + ->setQuantity('2', 'PCE', 1) + ->setQli('QLI-REFERENCE') + ->setOurPrice('10.00') + ->addInformation('050', 'Sample Item') + ->addGir(1, 'LOC', 'FUND', '10.00', 'COL') + ->addGir(2, 'LOC2', 'FUND2', '12.34', null); + + $orders->addItem($item); + $orders->compose(); + + $message = $this->encodeOrders($orders); + + self::assertStringContainsString("QTY+1:2'", $message); + self::assertStringNotContainsString("QTY+1:2:PCE'", $message); + self::assertStringContainsString("RFF+QLI:QLI-REFERENCE'", $message); + self::assertStringContainsString("PRI+AAE:10.00'", $message); + self::assertStringContainsString("IMD+L+050+:::Sample Item'", $message); + self::assertStringContainsString("GIR+001+LOC:LLO+COL:LSQ+FUND:LFN+10.00:LCV'", $message); + self::assertStringContainsString("GIR+002+LOC2:LLO+:LSQ+FUND2:LFN+12.34:LCV'", $message); + } + + + public function test_interchange_uses_first_message_identifier_as_default_application_reference(): void + { + $orders = new Orders('MSG-1', 'QUOTES', 'D', '96A', 'UN', 'EAN002'); + $orders->setCustomOrderNumber('ORDER-3001', '31C'); + + $item = (new Orders\Item()) + ->setPosition('1', '1234567890123', 'EN') + ->setQuantity('1'); + + $orders->addItem($item); + $orders->compose(); + + $message = $this->encodeOrders($orders); + + self::assertStringContainsString('+QUOTES++++\'UNH+', $message); + } + + public function test_nad_segments_trim_trailing_empty_components_when_only_identifier_is_provided(): void + { + $orders = (new Orders()) + ->setCustomOrderNumber('ORDER-4001', '31C') + ->setBuyerAddress(null, '', '', '', '', '', null, '9', 'BUYER') + ->setSupplierAddress(null, '', '', '', '', '', null, '9', 'SUPPLIER'); + + $item = (new Orders\Item()) + ->setPosition('1', '1234567890123', 'EN') + ->setQuantity('1'); + + $orders->addItem($item); + $orders->compose(); + + $message = $this->encodeOrders($orders); + + self::assertStringContainsString("NAD+BY+BUYER::9'", $message); + self::assertStringContainsString("NAD+SU+SUPPLIER::9'", $message); + self::assertStringNotContainsString("NAD+BY+BUYER::9+++++++", $message); + self::assertStringNotContainsString("NAD+SU+SUPPLIER::9+++++++", $message); + } + private function encodeOrders(Orders $orders): string + { + $interchange = (new Interchange('SENDER', 'RECEIVER'))->setCharset('UNOC', '3'); + + $encoder = new Encoder($interchange->addMessage($orders)->getComposed(), true); + $encoder->setUNA(":+,? '"); + + return $encoder->get(); + } +} From 551715936118c362e876ec473d824160ad7bbc17 Mon Sep 17 00:00:00 2001 From: Josh Green Date: Thu, 26 Feb 2026 12:04:25 +0000 Subject: [PATCH 4/4] fix(php): avoid dynamic property creation in item trait --- src/Generator/Traits/Item.php | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/Generator/Traits/Item.php b/src/Generator/Traits/Item.php index 9e07622..a0beea0 100755 --- a/src/Generator/Traits/Item.php +++ b/src/Generator/Traits/Item.php @@ -42,8 +42,9 @@ trait Item /** @var array */ protected $qli; - /** @var int */ - protected $dynamicSegmentCounter = 0; + /** @var array */ + protected $appendedSegments = []; + /** @var array IMD ZU */ protected $additionalText; @@ -76,7 +77,13 @@ trait Item */ public function compose() { - return $this->composeByKeys($this->composeKeys); + $content = $this->composeByKeys($this->composeKeys); + + foreach ($this->appendedSegments as $segment) { + $content[] = $segment; + } + + return $content; } /** @@ -259,10 +266,7 @@ public function addGir($index, $lloLocationCode, $lfnPurchaseFundCode, $lcvDecim */ private function addDynamicSegment($segment) { - $key = 'dynamicSegment' . (++$this->dynamicSegmentCounter); - - $this->{$key} = $segment; - $this->addKeyToCompose($key); + $this->appendedSegments[] = $segment; return $this; } @@ -378,11 +382,9 @@ public function setFeaturesText($text) private function splitTexts($varName, $text, $maxLength, $lineLength, $type = 'ZU') { $this->{$varName} = str_split(mb_substr($text, 0, $maxLength), $lineLength); - $nr = 0; + foreach ($this->{$varName} as $line) { - $property = $varName . $nr++; - $this->{$property} = self::addIMDSegment($line, $type); - $this->addKeyToCompose($property); + $this->addDynamicSegment(self::addIMDSegment($line, $type)); } return $this;