From 2889d54e9bf2706b749494cec3330a38c6638df5 Mon Sep 17 00:00:00 2001 From: Emilian Scibisz Date: Wed, 19 Aug 2026 10:33:35 +0200 Subject: [PATCH 1/2] fix: correct the getEvents, getColliNo and getDocument request envelopes All three read methods were built with an envelope SUUS does not accept, so every one of them answered PRJ000001 ("no order found for the given waybill number or reference") for orders that demonstrably exist. addOrder was unaffected, which is what hid this: shipments booked correctly and only the read side was dead. Labels could never be downloaded, tracking returned zero events for every shipment, and getColliNumbers() returned an empty array. Reported against 1.0.0 from a live production integration, confirmed here against the WS PK 1.0 specification (sections 5.2-5.4) and verified end to end on the sandbox. Envelope fixes * getEvents and getColliNo now send the documented wrapper. Both take an ArrayOfShipments, never a bare ; sent flat the shipment list arrives empty and SUUS reports the order as not found. * getDocument now names the document symbol , not . SUUS saw no document symbol and answered PRJ000001, which reads as "order not found" and points every investigation at the order rather than at the request. * ResponseParser::colliNumbers() read one level too high. The element in a getColliNo response is an ArrayOfColli wrapper holding leaves; reading the wrapper concatenated every child, so a six-package shipment yielded one run-together string instead of six numbers. A single-package shipment came out correct by accident, which kept this latent. Error surfacing * fetchStatus() and getColliNumbers() no longer swallow SUUS errors. A success=false response raised nothing and came back as an empty result, so a rejected request was indistinguishable from a shipment with no events yet. Both now raise SuusApiException with the return code and description, as fetchDocument() already did. All read methods share one assertSuccess() path. New capability * fetchDocument() accepts an array of colli numbers to request the label for one or several specific packages rather than the shipment's whole set. Verified on the sandbox: 3 colli = 132469 B, 2 colli = 88589 B, 1 colli = 44705 B. * fetchLoadingList(string $masterNo) - loadingList is the one document keyed by the master waybill number rather than by shipment, and could not be requested at all. * Reference-keyed variants of every read call, since an integration usually holds its own reference rather than the SUUS waybill number and the spec treats the two as interchangeable: fetchStatusByReference(), fetchDocumentByReference(), getColliNumbersByReference(). Tests * Regression tests for all three envelopes and for the colli leaf parsing. * tests/Fixtures/get_colli_numbers_response.xml rewritten to the real nested shape from the spec; the previous fixture was invented flat and masked the parser bug. * Integration coverage for the read side: the suite creates a three-package order and reads its colli numbers and documents back. That is the only way to tell "SUUS cannot find this order" apart from "SUUS could not read the request", since both answer PRJ000001. This is the test whose absence let the bug ship. Two API behaviours found while verifying and now documented * getEvents lags addOrder. SUUS registers the first event (J_CR) asynchronously a few minutes later, so a just-created order legitimately answers PRJ000001 for a while. Combined with the envelope bug this is why the failure looked like a sandbox limitation rather than a defect. * Colli numbers do not come back in a stable order between calls, so the result of getColliNumbers() is a set. Never map a colli number to a package by index. No signature is broken: every new parameter is optional and appended. Verified: 370 unit tests / 587 assertions green; 6 sandbox integration tests / 29 assertions green; PHPStan level 8 clean. --- CHANGELOG.md | 67 +++++- README.md | 40 +++- examples/03_fetch_status.php | 9 +- examples/04_fetch_document.php | 25 ++- examples/README.md | 13 +- src/Enum/DocumentType.php | 8 + src/Internal/Soap/ResponseParser.php | 10 +- src/Internal/Soap/SoapEnvelopeBuilder.php | 80 ++++++- src/SuusClient.php | 197 +++++++++++++++--- tests/Fixtures/get_colli_numbers_response.xml | 18 +- tests/Fixtures/get_events_not_found.xml | 15 ++ .../Integration/SuusClientIntegrationTest.php | 122 ++++++++++- tests/Unit/ResponseParserTest.php | 15 ++ tests/Unit/SoapEnvelopeBuilderTest.php | 79 ++++++- tests/Unit/SuusClientTest.php | 148 ++++++++++++- 15 files changed, 765 insertions(+), 81 deletions(-) create mode 100644 tests/Fixtures/get_events_not_found.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 8495658..bf277f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,70 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm --- +## [1.1.0] - 2026-08-19 + +Fixes the read side of the API. `getEvents`, `getColliNo` and `getDocument` were all +built with an envelope SUUS does not accept, so every one of them answered +`PRJ000001` ("no order found for the given waybill number or reference") for orders +that demonstrably existed. `addOrder` was unaffected, which is what hid the problem: +shipments booked correctly and only reading was dead. Verified against the live +endpoint and against the SUUS WebApi specification (WS PK 1.0, sections 5.2-5.4). + +### Fixed + +- **`getEvents` and `getColliNo` now send the documented `` + wrapper.** Both take an `ArrayOfShipments`, never a bare `` (spec 5.2 / + 5.4); sent flat, the shipment list arrives empty and SUUS reports the order as not + found. `fetchStatus()` returned zero events for every shipment, so a polling job + saw no progress ever. +- **`getDocument` now names the document symbol ``, not ``** + (spec 5.3). SUUS saw no document symbol and answered `PRJ000001`, which reads as + "order not found" and points every investigation at the order rather than at the + request. Labels could not be downloaded at all. +- **`ResponseParser::colliNumbers()` read one level too high.** The `` + element in a `getColliNo` response is an `ArrayOfColli` wrapper holding + `` leaves (spec 5.4); reading the wrapper concatenated every child, + so a six-package shipment yielded one run-together string instead of six numbers. + A single-package shipment happened to come out correct, which kept this latent. +- **`fetchStatus()` and `getColliNumbers()` no longer swallow SUUS errors.** A + `success=false` response raised nothing and came back as an empty result, so a + rejected request was indistinguishable from a shipment with no events. Both now + raise `SuusApiException` with the return code and description, as + `fetchDocument()` already did. + +### Added + +- `fetchDocument()` accepts `array $colliNumbers` to request the label for one or + several specific packages rather than the shipment's whole set (spec 5.3). Pass + numbers from `getColliNumbers()`; left empty, SUUS returns every label. +- `fetchLoadingList(string $masterNo)` - the collective loading list is the one + document keyed by the master waybill number rather than by shipment, and could not + be requested before. +- Reference-keyed variants of every read call, since an integration usually holds its + own reference rather than the SUUS waybill number and the spec treats the two as + interchangeable: `fetchStatusByReference()`, `fetchDocumentByReference()`, + `getColliNumbersByReference()`. + +- Integration coverage for the read side: the suite now creates a three-package order + and reads its colli numbers and documents back, which is the only way to tell "SUUS + cannot find this order" apart from "SUUS could not read the request" - both answer + `PRJ000001`. Verified green against the sandbox. + +### Notes + +- `PRJ000001` from SUUS means "I could not find that order" **or** "I could not read + your request". An order visible in the portal that the API cannot find means the + envelope is wrong. +- `getEvents` lags `addOrder`. SUUS registers the first event (`J_CR`) asynchronously a + few minutes after the order, so a just-created shipment legitimately answers + `PRJ000001` for a while. This is what made the malformed envelope look like a sandbox + limitation for so long. +- Colli numbers do not come back in a stable order between calls. Treat the result of + `getColliNumbers()` as a set; never map a colli number to a package by index. +- No signature is broken: the new parameters are all optional and appended. + +--- + ## [1.0.0] - 2026-08-14 Initial public release. @@ -133,5 +197,6 @@ Initial public release. - `BTN*` codes are SUUS system errors (service temporarily unavailable), not validation failures; data-validation failures use the `DRG*` / `PRJ*` families. -[Unreleased]: https://github.com/very-code-com/suus-php/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/very-code-com/suus-php/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/very-code-com/suus-php/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/very-code-com/suus-php/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 120d015..6b7d560 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,13 @@ surface validation in your own UI before sending. See Polls events via SUUS `getEvents`. Returns `StatusResult` with `status` (`ShipmentStatus` enum), `rawLatestCode`, `events[]`. -In sandbox mode `getEvents` always returns `PRJ000001`. +A SUUS error (e.g. `PRJ000001`, unknown shipment) raises `SuusApiException` rather than +returning an empty event list. + +### `fetchStatusByReference(string $reference): StatusResult` + +Same call keyed by your own order reference - SUUS treats `shipmentNo` and `reference` +as interchangeable and resolves a reference to the most recently added order carrying it. -> [full example](examples/03_fetch_status.php) @@ -165,7 +171,7 @@ In sandbox mode `getEvents` always returns `PRJ000001`. | `ANUL` | `Cancelled` | | `ZWRON`, `ZTF` | `Failed` | -### `fetchDocument(string $shipmentNo, DocumentType $type): string` +### `fetchDocument(string $shipmentNo, DocumentType $type, array $colliNumbers = []): string` Downloads a document as raw PDF bytes via SUUS `getDocument`. @@ -174,18 +180,37 @@ Downloads a document as raw PDF bytes via SUUS `getDocument`. | `Label` | Standard A4 shipping label | | `LabelA6` | Thermal printer label (A6) | | `ShippingOrder` | Shipping order document | -| `LoadingList` | Loading list | +| `LoadingList` | Loading list (see below) | + +`$colliNumbers` narrows `Label` / `LabelA6` to individual packages - pass numbers from +`getColliNumbers()`. Left empty, SUUS returns every label the shipment has. + +### `fetchDocumentByReference(string $reference, DocumentType $type, array $colliNumbers = []): string` + +Same call keyed by your own order reference instead of the waybill number. ### `fetchLabel(string $shipmentNo): string` Convenience shortcut for `fetchDocument(..., DocumentType::Label)`. +### `fetchLoadingList(string $masterNo): string` + +The collective loading list - the one document keyed by the **master** waybill number +rather than by shipment. + -> [full example](examples/04_fetch_document.php) ### `getColliNumbers(string $shipmentNo): array` Returns per-package (colli) tracking numbers for multi-package shipments. +SUUS does not return them in a stable order between calls, so treat the result as a set +- never match a colli number to a package by index. + +### `getColliNumbersByReference(string $reference): array` + +Same call keyed by your own order reference. + --- ## Package Types @@ -431,7 +456,7 @@ All exceptions extend `VeryCodeCom\Suus\Exception\SuusException`. | `SuusValidationException` | Local validation failed - carries typed `getValidationErrors(): ValidationError[]` (code + field + message) and `getErrors(): string[]` (plain messages) | | `SuusAuthException` | SUUS rejects credentials (`DRG00001`) | | `SuusDuplicateReferenceException` | Reference already exists (`PRJ00310`) | -| `SuusApiException` | Other SUUS API errors - carries `returnCode` + `errorCodes`; the message also includes SUUS's `returnDesc` for bare codes (e.g. `BTN0001` = service temporarily unavailable) | +| `SuusApiException` | Other SUUS API errors - carries `returnCode` + `errorCodes`; the message also includes SUUS's `returnDesc` for bare codes (e.g. `BTN0001` = service temporarily unavailable). Every read method raises this on `success=false`, including `PRJ000001`; none of them report a rejected request as an empty result | | `SuusTransportException` | Network error or non-200 HTTP response | | `SuusResponseParseException` | SUUS returned unparseable XML | @@ -465,9 +490,10 @@ new SuusClient( 1. **`lenghtCm` typo** - SUUS uses `` (missing one `t`). Preserved intentionally. 2. **PHP's `SoapClient` is incompatible** - SUUS uses RPC/encoded SOAP 1.1. This library uses raw cURL with manually constructed XML. 3. **Response namespace quirk** - SUUS SOAP responses swap `xmlns:cw` and `xmlns:ns1`. Child elements carry no namespace prefix. -4. **`getEvents` / `getDocument` always fail in sandbox** - Only `addOrder` returns real data in the test environment. -5. **Loading date minimum** - SUUS requires +2 Polish business days advance notice. -6. **`` in every body** - Unlike most SOAP services, SUUS embeds the auth block inside every operation's body, not in the SOAP header. +4. **`PRJ000001` often means a malformed request, not a missing order** - `getEvents`, `getColliNo` and `getDocument` all answer "order not found" when the envelope is wrong. `getEvents` / `getColliNo` need the `` wrapper (never a bare ``), and `getDocument` names the document symbol ``, not ``. +5. **Colli numbers nest twice** - the `` element in a `getColliNo` response is an `ArrayOfColli` wrapper holding `` leaves. +6. **Loading date minimum** - SUUS requires +2 Polish business days advance notice. +7. **`` in every body** - Unlike most SOAP services, SUUS embeds the auth block inside every operation's body, not in the SOAP header. --- diff --git a/examples/03_fetch_status.php b/examples/03_fetch_status.php index f9df2ca..f89cf12 100644 --- a/examples/03_fetch_status.php +++ b/examples/03_fetch_status.php @@ -15,8 +15,11 @@ * Cancelled <- ANUL * Failed <- ZWRON, ZTF * - * In the sandbox getEvents always returns PRJ000001, so run this against - * production with a real shipment number to see actual events. + * A SUUS-side failure (PRJ000001 for an unknown shipment, for instance) raises + * SuusApiException; it is never reported as an empty event list. + * + * `fetchStatusByReference()` is the same call keyed by your own order reference + * instead of the SUUS waybill number. * * Run: * SUUS_LOGIN=ws_xxx SUUS_PASSWORD=xxx php examples/03_fetch_status.php OPLKRI2600895 @@ -30,7 +33,7 @@ use VeryCodeCom\Suus\Enum\ShipmentStatus; use VeryCodeCom\Suus\Exception\SuusException; -// getEvents returns real data only on production. +// Track against the environment the shipment was created in. $client = SuusClient::production( login: getenv('SUUS_LOGIN') ?: 'ws_yourlogin', password: getenv('SUUS_PASSWORD') ?: 'your_password', diff --git a/examples/04_fetch_document.php b/examples/04_fetch_document.php index 46507af..ef1a1cb 100644 --- a/examples/04_fetch_document.php +++ b/examples/04_fetch_document.php @@ -9,14 +9,15 @@ * DocumentType::Label -> standard A4 shipping label * DocumentType::LabelA6 -> A6 thermal-printer label (Zebra etc.) * DocumentType::ShippingOrder -> shipping order (list przewozowy) - * DocumentType::LoadingList -> consolidated loading list + * DocumentType::LoadingList -> consolidated loading list, keyed by the master + * waybill number: use fetchLoadingList($masterNo) * * `fetchLabel()` is a shortcut for fetchDocument(..., DocumentType::Label). - * `getColliNumbers()` returns the per-package tracking numbers you need to - * request individual colli labels. + * `getColliNumbers()` returns the per-package tracking numbers, which you can hand + * back to `fetchDocument()` to print the label for one specific package. * - * In the sandbox getDocument and getColliNo always fail with PRJ000001, so run - * this against production with a real shipment number. + * Every call here also has a `...ByReference()` twin, if you hold your own order + * reference rather than the SUUS waybill number. * * Run: * SUUS_LOGIN=ws_xxx SUUS_PASSWORD=xxx php examples/04_fetch_document.php OPLKRI2600895 @@ -57,10 +58,9 @@ file_put_contents($labelPath, $label); printf(" %-14s -> %s (%d bytes)\n", 'label', $labelPath, strlen($label)); - // ... and the other document types explicitly. + // ... and the other shipment-level document types explicitly. $download($client, $shipmentNo, DocumentType::LabelA6, $outDir); $download($client, $shipmentNo, DocumentType::ShippingOrder, $outDir); - $download($client, $shipmentNo, DocumentType::LoadingList, $outDir); // Per-package tracking numbers (colli). echo "\nColli (per-package) numbers:\n"; @@ -71,6 +71,17 @@ foreach ($colli as $i => $number) { printf(" #%d %s\n", $i + 1, $number); } + + // A label for one specific package rather than the whole set. + if ($colli !== []) { + $single = $client->fetchDocument($shipmentNo, DocumentType::Label, [$colli[0]]); + $path = "{$outDir}/label_{$colli[0]}.pdf"; + file_put_contents($path, $single); + printf("\n single label -> %s (%d bytes)\n", $path, strlen($single)); + } + + // The loading list is keyed by the master waybill number, not the shipment. + // $loadingList = $client->fetchLoadingList('PKRM150000096'); } catch (SuusException $e) { echo "SUUS error: {$e->getMessage()}\n"; } diff --git a/examples/README.md b/examples/README.md index d228286..2458433 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,8 +18,8 @@ the offline ones (06, 07, 08) run with no configuration at all. |---|--------|---------------|----------------| | 01 | [`01_create_shipment.php`](01_create_shipment.php) | Domestic PL->PL order with fully-populated addresses, mixed package types, auto-computed dates, and handling of every exception type | Yes (sandbox) | | 02 | [`02_international_shipment.php`](02_international_shipment.php) | International DE->PL order: incoterms, B2B rule, category, `freight`/`currency`, `costGroup`, B2B-only services | Yes (sandbox) | -| 03 | [`03_fetch_status.php`](03_fetch_status.php) | Tracking via `getEvents`: normalized `ShipmentStatus`, event history, exhaustive `match` on status | Yes (**production** - sandbox returns `PRJ000001`) | -| 04 | [`04_fetch_document.php`](04_fetch_document.php) | Downloading labels / shipping order / loading list as PDF, plus per-package colli numbers | Yes (**production** - sandbox returns `PRJ000001`) | +| 03 | [`03_fetch_status.php`](03_fetch_status.php) | Tracking via `getEvents`: normalized `ShipmentStatus`, event history, exhaustive `match` on status | Yes | +| 04 | [`04_fetch_document.php`](04_fetch_document.php) | Downloading labels / shipping order as PDF, a single-package label, plus per-package colli numbers | Yes | | 05 | [`05_additional_services.php`](05_additional_services.php) | The full additional-services catalogue (COD, insurance, e-mail/SMS pre-advice, lift, pallet truck, inside delivery, domestic document-return) on a domestic B2C order | Yes (sandbox) | | 06 | [`06_calendar.php`](06_calendar.php) | Business-day calendars for all 9 countries, holiday comparison, Orthodox Easter (RO), `minLoadingDate`, standalone scheduling helpers | No | | 07 | [`07_di_and_testing.php`](07_di_and_testing.php) | Dependency injection: stub `TransportInterface` (no network), PSR-3 logger, calendar override - the pattern used by the unit tests | No | @@ -28,9 +28,12 @@ the offline ones (06, 07, 08) run with no configuration at all. ## Notes -- **Sandbox vs production** - `getEvents` / `getDocument` / `getColliNo` only - return real data on production; in the sandbox they always answer `PRJ000001`. - Only `addOrder` (create shipment) returns usable data in the sandbox. +- **Sandbox vs production** - the read methods (`getEvents` / `getDocument` / + `getColliNo`) can only see orders that exist in the environment you are calling, + so pass a shipment number created in that same environment. +- **`PRJ000001` is ambiguous** - SUUS returns "order not found" both for a genuinely + unknown shipment and for a request it could not read. If an order is visible in the + portal but the API answers `PRJ000001`, suspect the request, not the order. - **Unique references** - the create examples derive their `reference` from the current timestamp so you can re-run them without hitting `PRJ00310` (duplicate reference). diff --git a/src/Enum/DocumentType.php b/src/Enum/DocumentType.php index 8b2b2b8..9591964 100644 --- a/src/Enum/DocumentType.php +++ b/src/Enum/DocumentType.php @@ -6,11 +6,19 @@ /** * SUUS document types for the getDocument API call. + * + * Sent as the element (spec 5.3). Label and LabelA6 can be narrowed to + * individual packages via colli numbers; LoadingList is keyed by the master waybill + * number instead of a shipment, so it goes through SuusClient::fetchLoadingList(). */ enum DocumentType: string { + /** Standard A4 shipping label. */ case Label = 'label'; + /** A6 label for thermal printers (Zebra etc.). */ case LabelA6 = 'labelA6'; + /** Shipping order (list przewozowy). */ case ShippingOrder = 'shippingOrder'; + /** Collective loading list (zbiorczy list przewozowy) - requires a master number. */ case LoadingList = 'loadingList'; } diff --git a/src/Internal/Soap/ResponseParser.php b/src/Internal/Soap/ResponseParser.php index 6925909..9b6539e 100644 --- a/src/Internal/Soap/ResponseParser.php +++ b/src/Internal/Soap/ResponseParser.php @@ -128,11 +128,17 @@ public function documentBase64(\DOMXPath $xpath): string return $this->textOf($xpath, '//document'); } - /** @return string[] */ + /** + * Colli numbers live one level deeper than the wrapper: the wrapper is + * an ArrayOfColli holding leaves (spec 5.4). Reading the wrapper + * itself concatenates every child into one run-together string. + * + * @return string[] + */ public function colliNumbers(\DOMXPath $xpath): array { $numbers = []; - $nodes = $xpath->query('//shipments/shipment/colliNo'); + $nodes = $xpath->query('//shipments/shipment/colliNo/colli/colliNo'); if ($nodes === false) { return []; } diff --git a/src/Internal/Soap/SoapEnvelopeBuilder.php b/src/Internal/Soap/SoapEnvelopeBuilder.php index 25f4ecd..1715068 100644 --- a/src/Internal/Soap/SoapEnvelopeBuilder.php +++ b/src/Internal/Soap/SoapEnvelopeBuilder.php @@ -26,6 +26,9 @@ * - Packages array requires SOAP-ENC:arrayType attribute * - International orders require and in addition to * and + * - getEvents / getColliNo take an list, not a bare + * (spec 5.2 / 5.4) + * - getDocument names the document symbol , not (spec 5.3) * * @internal This class is not part of the public API and may change without notice. */ @@ -68,23 +71,59 @@ public function buildAddOrder(ShipmentOrder $order, string $loadingDate, string return $this->envelope('addOrder', $body); } - public function buildGetEvents(string $shipmentNo): string + /** + * Spec 5.2: shipmentNo and reference are interchangeable - pass either. + */ + public function buildGetEvents(string $shipmentNo, string $reference = ''): string { - $body = '' . self::xe($shipmentNo) . ''; - return $this->envelope('getEvents', $body); + return $this->envelope('getEvents', $this->shipmentsXml($shipmentNo, $reference)); } - public function buildGetDocument(string $shipmentNo, DocumentType $type): string - { - $body = '' . self::xe($shipmentNo) . '' - . '' . self::xe($type->value) . ''; + /** + * Spec 5.3: the document symbol element is , and shipmentNo / + * reference are interchangeable. masterNo is required for loadingList; + * colliNo selects individual packages for label / labelA6. + * + * @param string[] $colliNumbers Per-package labels; empty = the whole set. + */ + public function buildGetDocument( + string $shipmentNo, + DocumentType $type, + array $colliNumbers = [], + string $reference = '', + string $masterNo = '', + ): string { + $body = '' . self::xe($type->value) . ''; + + if ($shipmentNo !== '') { + $body .= '' . self::xe($shipmentNo) . ''; + } + if ($reference !== '') { + $body .= '' . self::xe($reference) . ''; + } + if ($masterNo !== '') { + $body .= '' . self::xe($masterNo) . ''; + } + + if ($colliNumbers !== []) { + $body .= ''; + foreach ($colliNumbers as $colli) { + $body .= '' + . '' . self::xe($colli) . '' + . ''; + } + $body .= ''; + } + return $this->envelope('getDocument', $body); } - public function buildGetColliNo(string $shipmentNo): string + /** + * Spec 5.4: shipmentNo and reference are interchangeable - pass either. + */ + public function buildGetColliNo(string $shipmentNo, string $reference = ''): string { - $body = '' . self::xe($shipmentNo) . ''; - return $this->envelope('getColliNo', $body); + return $this->envelope('getColliNo', $this->shipmentsXml($shipmentNo, $reference)); } public function buildGetDeliveryPoints(): string @@ -124,6 +163,27 @@ private function authXml(): string . ''; } + /** + * ArrayOfShipments wrapper shared by getEvents (spec 5.2) and getColliNo (spec 5.4). + * + * Both methods take a shipment list, never a bare : sent flat, SUUS + * answers PRJ000001 ("order not found") for orders that exist. + */ + private function shipmentsXml(string $shipmentNo, string $reference = ''): string + { + $inner = ''; + if ($shipmentNo !== '') { + $inner .= '' . self::xe($shipmentNo) . ''; + } + if ($reference !== '') { + $inner .= '' . self::xe($reference) . ''; + } + + return '' + . '' . $inner . '' + . ''; + } + private function buildHeaderXml(ShipmentOrder $order, string $loadingDate, string $unloadingDate): string { $desc = $order->descriptionOfGoods !== '' diff --git a/src/SuusClient.php b/src/SuusClient.php index 46f1826..bd816cb 100644 --- a/src/SuusClient.php +++ b/src/SuusClient.php @@ -51,11 +51,16 @@ * * echo $result->shipmentNo; // e.g. OPLKRI2600895 * - * All four SUUS API methods are supported: - * createShipment() -> addOrder - * fetchStatus() -> getEvents - * fetchDocument() -> getDocument (returns raw PDF bytes) - * getColliNumbers() -> getColliNo + * All SUUS API methods are supported: + * createShipment() -> addOrder + * fetchStatus() -> getEvents + * fetchDocument() -> getDocument (returns raw PDF bytes) + * fetchLoadingList() -> getDocument, keyed by master waybill number + * getColliNumbers() -> getColliNo + * getDeliveryPoints() -> getDeliveryPoints + * + * SUUS treats the waybill number and your own order reference as interchangeable, + * so every read method has a ...ByReference() twin. * * @see https://github.com/very-code-com/suus-php */ @@ -234,19 +239,42 @@ public function createShipment(ShipmentOrder $order): ShipmentResult /** * Poll shipment status and events (SUUS method: getEvents). * - * In the SUUS sandbox getEvents always answers PRJ000001 (order not found), - * regardless of the shipment number; only production returns real events. + * @throws SuusApiException for SUUS API errors (e.g. PRJ000001, unknown shipment). + * @throws SuusTransportException on network errors. + * @throws SuusResponseParseException on malformed XML. + */ + public function fetchStatus(string $shipmentNo): StatusResult + { + return $this->fetchEvents($shipmentNo, ''); + } + + /** + * Poll shipment status by your own order reference instead of the waybill number. + * + * Per spec 5.2 shipmentNo and reference are interchangeable; a reference resolves + * to the most recently added order carrying it. * * @throws SuusApiException for SUUS API errors. * @throws SuusTransportException on network errors. * @throws SuusResponseParseException on malformed XML. */ - public function fetchStatus(string $shipmentNo): StatusResult + public function fetchStatusByReference(string $reference): StatusResult { - $this->logger->debug('SUUS: fetching status', ['shipmentNo' => $shipmentNo]); + return $this->fetchEvents('', $reference); + } + + private function fetchEvents(string $shipmentNo, string $reference): StatusResult + { + $this->logger->debug('SUUS: fetching status', [ + 'shipmentNo' => $shipmentNo, + 'reference' => $reference, + ]); - $envelope = $this->builder->buildGetEvents($shipmentNo); - $xpath = $this->call('getEvents', $envelope)->xpath; + $envelope = $this->builder->buildGetEvents($shipmentNo, $reference); + $parsed = $this->call('getEvents', $envelope); + $xpath = $parsed->xpath; + + $this->assertSuccess($parsed, 'getEvents', $shipmentNo !== '' ? $shipmentNo : $reference); $rawEvents = $this->parser->events($xpath); $latestCode = ''; @@ -281,7 +309,11 @@ public function fetchStatus(string $shipmentNo): StatusResult /** * Download a shipping document as raw PDF bytes (SUUS method: getDocument). * - * Available document types: Label (A4), LabelA6, ShippingOrder, LoadingList. + * Available document types: Label (A4), LabelA6, ShippingOrder. LoadingList is + * keyed by master waybill number - use fetchLoadingList() for it. + * + * @param string[] $colliNumbers Individual packages for label / labelA6 (spec 5.3); + * empty = every label the shipment has. * * @return string Raw PDF bytes - write to file or send with Content-Type: application/pdf. * @@ -289,35 +321,82 @@ public function fetchStatus(string $shipmentNo): StatusResult * @throws SuusResponseParseException if the returned Base64 is empty or invalid. * @throws SuusTransportException on network errors. */ - public function fetchDocument(string $shipmentNo, DocumentType $type = DocumentType::Label): string + public function fetchDocument( + string $shipmentNo, + DocumentType $type = DocumentType::Label, + array $colliNumbers = [], + ): string { + return $this->requestDocument($type, $shipmentNo, '', '', $colliNumbers); + } + + /** + * Download a document by your own order reference instead of the waybill number + * (spec 5.3: shipmentNo and reference are interchangeable). + * + * @param string[] $colliNumbers Individual packages for label / labelA6; empty = the whole set. + * + * @return string Raw PDF bytes. + * + * @throws SuusApiException if SUUS rejects the request. + * @throws SuusResponseParseException if the returned Base64 is empty or invalid. + * @throws SuusTransportException on network errors. + */ + public function fetchDocumentByReference( + string $reference, + DocumentType $type = DocumentType::Label, + array $colliNumbers = [], + ): string { + return $this->requestDocument($type, '', $reference, '', $colliNumbers); + } + + /** + * Download the collective loading list (SUUS document: loadingList). + * + * This is the one document keyed by the master waybill number rather than by + * shipment (spec 5.3: masterNo is required for loadingList). + * + * @return string Raw PDF bytes. + * + * @throws SuusApiException if SUUS rejects the request. + * @throws SuusResponseParseException if the returned Base64 is empty or invalid. + * @throws SuusTransportException on network errors. + */ + public function fetchLoadingList(string $masterNo): string { + return $this->requestDocument(DocumentType::LoadingList, '', '', $masterNo); + } + + /** + * @param string[] $colliNumbers + */ + private function requestDocument( + DocumentType $type, + string $shipmentNo, + string $reference, + string $masterNo, + array $colliNumbers = [], + ): string { + $subject = $shipmentNo !== '' ? $shipmentNo : ($reference !== '' ? $reference : $masterNo); + $this->logger->debug('SUUS: fetching document', [ 'shipmentNo' => $shipmentNo, + 'reference' => $reference, + 'masterNo' => $masterNo, 'documentType' => $type->value, + 'colliNumbers' => $colliNumbers, ]); - $envelope = $this->builder->buildGetDocument($shipmentNo, $type); + $envelope = $this->builder->buildGetDocument($shipmentNo, $type, $colliNumbers, $reference, $masterNo); $parsed = $this->call('getDocument', $envelope); $xpath = $parsed->xpath; - if (!$this->parser->isSuccess($xpath)) { - $returnCode = $this->parser->returnCode($xpath); - $returnDesc = $this->parser->returnDesc($xpath); - $this->fail( - new SuusApiException( - trim("SUUS getDocument failed [{$returnCode}] for shipment {$shipmentNo}. {$returnDesc}"), - $returnCode, - $this->parser->errorCodes($xpath), - ), - $parsed->raw, - ); - } + $this->assertSuccess($parsed, 'getDocument', $subject); $base64 = $this->parser->documentBase64($xpath); if ($base64 === '') { $this->fail( new SuusResponseParseException( - "SUUS getDocument returned an empty document for shipment {$shipmentNo}." + "SUUS getDocument returned an empty document for {$subject}." ), $parsed->raw, ); @@ -327,7 +406,7 @@ public function fetchDocument(string $shipmentNo, DocumentType $type = DocumentT if ($pdf === false) { $this->fail( new SuusResponseParseException( - "SUUS getDocument returned an invalid Base64 payload for shipment {$shipmentNo}." + "SUUS getDocument returned an invalid Base64 payload for {$subject}." ), $parsed->raw, ); @@ -356,12 +435,38 @@ public function fetchLabel(string $shipmentNo): string */ public function getColliNumbers(string $shipmentNo): array { - $this->logger->debug('SUUS: fetching colli numbers', ['shipmentNo' => $shipmentNo]); + return $this->fetchColliNumbers($shipmentNo, ''); + } + + /** + * Colli numbers by your own order reference instead of the waybill number + * (spec 5.4: shipmentNo and reference are interchangeable). + * + * @return string[] List of colli numbers. + * + * @throws SuusApiException for SUUS API errors. + * @throws SuusTransportException on network errors. + * @throws SuusResponseParseException on malformed XML. + */ + public function getColliNumbersByReference(string $reference): array + { + return $this->fetchColliNumbers('', $reference); + } - $envelope = $this->builder->buildGetColliNo($shipmentNo); - $xpath = $this->call('getColliNo', $envelope)->xpath; + /** @return string[] */ + private function fetchColliNumbers(string $shipmentNo, string $reference): array + { + $this->logger->debug('SUUS: fetching colli numbers', [ + 'shipmentNo' => $shipmentNo, + 'reference' => $reference, + ]); + + $envelope = $this->builder->buildGetColliNo($shipmentNo, $reference); + $parsed = $this->call('getColliNo', $envelope); - return $this->parser->colliNumbers($xpath); + $this->assertSuccess($parsed, 'getColliNo', $shipmentNo !== '' ? $shipmentNo : $reference); + + return $this->parser->colliNumbers($parsed->xpath); } /** @@ -449,6 +554,34 @@ private function call(string $method, string $envelope): ParsedResponse return new ParsedResponse($xpath, $response->body); } + /** + * Throw a SuusApiException when SUUS answered success=false. + * + * Every read method routes through here, so a rejected request never reaches the + * caller as an empty result - which would be indistinguishable from a shipment + * that genuinely has no events, documents or colli yet. + * + * @throws SuusApiException + */ + private function assertSuccess(ParsedResponse $parsed, string $method, string $subject): void + { + if ($this->parser->isSuccess($parsed->xpath)) { + return; + } + + $returnCode = $this->parser->returnCode($parsed->xpath); + $returnDesc = $this->parser->returnDesc($parsed->xpath); + + $this->fail( + new SuusApiException( + trim("SUUS {$method} failed [{$returnCode}] for {$subject}. {$returnDesc}"), + $returnCode, + $this->parser->errorCodes($parsed->xpath), + ), + $parsed->raw, + ); + } + /** * Attach the raw SUUS response to an exception and, when debugging is * enabled, log a full debug report (message + raw response + stack trace) diff --git a/tests/Fixtures/get_colli_numbers_response.xml b/tests/Fixtures/get_colli_numbers_response.xml index 81efff8..7bfd4b8 100644 --- a/tests/Fixtures/get_colli_numbers_response.xml +++ b/tests/Fixtures/get_colli_numbers_response.xml @@ -2,19 +2,27 @@ + xmlns:xsd="http://www.w3.org/2001/XMLSchema" + xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/"> true CWS0001 - + OPLKRI2600895 - KRKRI2600895-1 - KRKRI2600895-2 - KRKRI2600895-3 + + + true + CWS0001 + + + KRKRI2600895-1 + KRKRI2600895-2 + KRKRI2600895-3 + diff --git a/tests/Fixtures/get_events_not_found.xml b/tests/Fixtures/get_events_not_found.xml new file mode 100644 index 0000000..dae2d6c --- /dev/null +++ b/tests/Fixtures/get_events_not_found.xml @@ -0,0 +1,15 @@ + + + + + + false + PRJ000001 + Nie odnaleziono zlecenia o podanym numerze listu przewozowego lub o podanej referencji + + + + diff --git a/tests/Integration/SuusClientIntegrationTest.php b/tests/Integration/SuusClientIntegrationTest.php index 036b81f..7d94b3a 100644 --- a/tests/Integration/SuusClientIntegrationTest.php +++ b/tests/Integration/SuusClientIntegrationTest.php @@ -8,10 +8,12 @@ use VeryCodeCom\Suus\Dto\Address; use VeryCodeCom\Suus\Dto\Package; use VeryCodeCom\Suus\Dto\ShipmentOrder; +use VeryCodeCom\Suus\Enum\DocumentType; use VeryCodeCom\Suus\Enum\Incoterm; use VeryCodeCom\Suus\Enum\OrderType; use VeryCodeCom\Suus\Enum\PackageSymbol; use VeryCodeCom\Suus\Enum\ShipmentCategory; +use VeryCodeCom\Suus\Exception\SuusApiException; use VeryCodeCom\Suus\Service\CodService; use VeryCodeCom\Suus\Service\EmailNotificationService; use VeryCodeCom\Suus\Service\InsideDeliveryService; @@ -41,8 +43,11 @@ * and the B2B service set (COD, insurance, e-mail, lift, pallet truck). Per * docs, returnable/stackable and SMS/inside-delivery are not allowed here. * - * getEvents, getDocument and getColliNo always return PRJ000001 in the sandbox; - * only addOrder returns real data, so these tests only verify addOrder. + * The read methods are covered too, against an order the suite creates itself: that is + * the only way to tell "SUUS cannot find this order" apart from "SUUS could not read the + * request", since both answer PRJ000001. getEvents is the exception - SUUS registers the + * first event asynchronously, so that test needs SUUS_EVENTS_SHIPMENT pointing at an + * order registered a few minutes earlier. */ final class SuusClientIntegrationTest extends TestCase { @@ -184,6 +189,119 @@ public function testRealInternationalAddOrderReturnsShipmentNumber(): void echo "Tracking URL: {$result->trackingUrl}\n"; } + /** + * Read-back round trip: create an order, then fetch its colli numbers and every + * shipment-level document for it. + * + * This is the test whose absence let three malformed request envelopes ship. Each + * of the read methods answers PRJ000001 ("order not found") when the request is + * shaped wrong, which is indistinguishable from a genuinely unknown shipment + * unless the order is one this test just created. + */ + public function testReadBackColliNumbersAndDocumentsForAFreshOrder(): void + { + $this->skipUnlessOrdersAllowed(); + + $ref = 'RT-' . date('YmdHis') . '-' . random_int(100, 999); + $order = new ShipmentOrder( + reference: $ref, + sender: new Address( + name: 'Testowy Nadawca Sp. z o.o.', street: 'Przemysłowa', streetNo: '12', + postcode: '30-701', city: 'Kraków', countryCode: 'PL', + phone: '+48123456789', contactPerson: 'Jan Kowalski', email: 'nadawca@example.pl', + ), + receiver: new Address( + name: 'Testowy Odbiorca Sp. z o.o.', street: 'Marszałkowska', streetNo: '100', + postcode: '00-026', city: 'Warszawa', countryCode: 'PL', + phone: '+48987654321', contactPerson: 'Anna Nowak', email: 'odbiorca@example.pl', + ), + // Three packages, so a wrapper-vs-leaf colli parsing bug cannot hide behind + // a single-package shipment that happens to come out correct either way. + packages: [ + new Package(PackageSymbol::KAR, weightKg: 10.0, lengthCm: 40.0, widthCm: 30.0, heightCm: 20.0), + new Package(PackageSymbol::KAR, weightKg: 12.0, lengthCm: 50.0, widthCm: 40.0, heightCm: 30.0), + new Package(PackageSymbol::KAR, weightKg: 8.0, lengthCm: 30.0, widthCm: 20.0, heightCm: 15.0), + ], + loadingDate: $this->loadingDate('PL'), + unloadingDate: $this->unloadingDate('PL'), + orderType: OrderType::B2C, + descriptionOfGoods: 'Artykuły przemysłowe', + ); + + $shipmentNo = $this->client->createShipment($order)->shipmentNo; + echo "\n[SUUS Integration] Read-back shipment: {$shipmentNo} (ref {$ref})\n"; + + // getColliNo: one number per package, each a distinct leaf value. + $colli = $this->client->getColliNumbers($shipmentNo); + $this->assertCount(3, $colli); + $this->assertSame($colli, array_unique($colli), 'colli numbers must be distinct leaves'); + foreach ($colli as $number) { + $this->assertMatchesRegularExpression('/^[A-Z0-9]+$/', $number); + } + // Same set by reference - but SUUS does not guarantee the order between calls, + // so compare as sets. Never match colli to packages by index. + $byRef = $this->client->getColliNumbersByReference($ref); + sort($colli); + sort($byRef); + $this->assertSame($colli, $byRef); + + // getDocument: real PDFs, not an error page or an empty payload. + foreach ([DocumentType::Label, DocumentType::LabelA6, DocumentType::ShippingOrder] as $type) { + $pdf = $this->client->fetchDocument($shipmentNo, $type); + $this->assertStringStartsWith('%PDF', $pdf, $type->value); + $this->assertGreaterThan(1000, strlen($pdf), $type->value); + } + + // colliNo narrows the label set: one package must be materially smaller + // than all three, which a request that silently ignored colliNo would not be. + $allLabels = $this->client->fetchDocument($shipmentNo, DocumentType::Label); + $oneLabel = $this->client->fetchDocument($shipmentNo, DocumentType::Label, [$colli[0]]); + $this->assertStringStartsWith('%PDF', $oneLabel); + $this->assertLessThan(strlen($allLabels), strlen($oneLabel)); + } + + /** + * getEvents against an order this test just created. + * + * SUUS registers the first event (J_CR) asynchronously, a few minutes after + * addOrder, so a fresh order legitimately answers PRJ000001 for a while. The + * point of this test is that a rejected read raises rather than quietly + * returning an empty event list. + */ + public function testReadBackEventsForAFreshOrder(): void + { + $this->skipUnlessOrdersAllowed(); + + $shipmentNo = getenv('SUUS_EVENTS_SHIPMENT') ?: ''; + if ($shipmentNo === '') { + $this->markTestSkipped( + 'Set SUUS_EVENTS_SHIPMENT to a shipment number registered at least a few ' + . 'minutes ago; SUUS records the first event asynchronously.' + ); + } + + $status = $this->client->fetchStatus($shipmentNo); + + $this->assertNotSame('', $status->rawLatestCode); + $this->assertNotEmpty($status->events); + $this->assertNotSame('', $status->events[0]->description); + } + + /** A read that SUUS rejects must raise, never come back as an empty result. */ + public function testRejectedReadRaisesInsteadOfReturningEmpty(): void + { + $unknown = 'OPLKRI9999999'; + + foreach (['fetchStatus', 'getColliNumbers', 'fetchDocument'] as $method) { + try { + $this->client->{$method}($unknown); + $this->fail("{$method}() returned normally for an unknown shipment."); + } catch (SuusApiException $e) { + $this->assertSame('PRJ000001', $e->returnCode, $method); + } + } + } + public function testTrackingUrlFormat(): void { $this->assertSame( diff --git a/tests/Unit/ResponseParserTest.php b/tests/Unit/ResponseParserTest.php index 31ba5cd..0b98c10 100644 --- a/tests/Unit/ResponseParserTest.php +++ b/tests/Unit/ResponseParserTest.php @@ -121,6 +121,21 @@ public function testExtractColliNumbers(): void $this->assertSame('KRKRI2600895-3', $numbers[2]); } + /** + * Regression: the ArrayOfColli wrapper was read instead of the + * leaves, so a multi-package shipment came back as one run-together string. + */ + public function testColliNumbersReadsLeavesNotTheArrayWrapper(): void + { + $xpath = $this->parser->parse($this->fixture('get_colli_numbers_response')); + $numbers = $this->parser->colliNumbers($xpath); + + foreach ($numbers as $number) { + $this->assertMatchesRegularExpression('/^[A-Z0-9-]+$/', $number); + $this->assertSame(14, strlen($number)); + } + } + public function testColliNumbersReturnsEmptyArrayWhenNonePresent(): void { $xml = << */ final class SoapEnvelopeBuilderTest extends TestCase { @@ -160,15 +160,49 @@ public function testGetDocumentContainsShipmentNo(): void $this->assertStringContainsString('OPLKRI2600895', $xml); } - public function testGetDocumentContainsDocumentTypeElement(): void + /** Spec 5.3 names the element ; makes SUUS answer PRJ000001. */ + public function testGetDocumentUsesDocumentElementNotDocumentType(): void { $xml = $this->builder->buildGetDocument('OPLKRI2600895', DocumentType::Label); - $this->assertStringContainsString('assertStringContainsString('>label<', $xml); + $this->assertStringContainsString('label', $xml); + $this->assertStringNotContainsString('builder->buildGetDocument('OPLKRI2600895', DocumentType::Label); + $this->assertStringNotContainsString('builder->buildGetDocument( + 'OPLKRI2600895', + DocumentType::LabelA6, + ['WEB1705000047', 'WEB1705000048'], + ); + + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString( + 'WEB1705000047', + $xml, + ); + $this->assertStringContainsString('WEB1705000048', $xml); + } + + public function testGetDocumentSupportsReferenceAndMasterNo(): void + { + $xml = $this->builder->buildGetDocument('', DocumentType::LoadingList, [], '', 'PKRM150000096'); + + $this->assertStringNotContainsString('assertStringContainsString('PKRM150000096', $xml); + + $byRef = $this->builder->buildGetDocument('', DocumentType::Label, [], 'ORDER-123'); + $this->assertStringContainsString('ORDER-123', $byRef); } // ---------------------------------------------- - // getEvents envelope + // getEvents / getColliNo envelopes // ---------------------------------------------- public function testGetEventsContainsShipmentNo(): void @@ -176,4 +210,39 @@ public function testGetEventsContainsShipmentNo(): void $xml = $this->builder->buildGetEvents('OPLKRI2600895'); $this->assertStringContainsString('OPLKRI2600895', $xml); } + + /** + * Spec 5.2 / 5.4: both methods take an ArrayOfShipments. A bare + * leaves the shipment list empty and SUUS answers PRJ000001 for real orders. + */ + public function testGetEventsAndGetColliNoWrapShipmentInArrayOfShipments(): void + { + $envelopes = [ + 'getEvents' => $this->builder->buildGetEvents('OPLKRI2600895'), + 'getColliNo' => $this->builder->buildGetColliNo('OPLKRI2600895'), + ]; + + foreach ($envelopes as $method => $xml) { + $this->assertStringContainsString('', $xml, $method); + $this->assertStringContainsString('', $xml, $method); + $this->assertStringContainsString( + 'OPLKRI2600895', + $xml, + $method, + ); + } + } + + public function testGetEventsAndGetColliNoAcceptReferenceInsteadOfShipmentNo(): void + { + $envelopes = [ + 'getEvents' => $this->builder->buildGetEvents('', 'ORDER-123'), + 'getColliNo' => $this->builder->buildGetColliNo('', 'ORDER-123'), + ]; + + foreach ($envelopes as $method => $xml) { + $this->assertStringContainsString('ORDER-123', $xml, $method); + $this->assertStringNotContainsString('assertSame('Berlin', $result->events[3]->location); } + /** Spec 5.2: a bare leaves the list empty and SUUS answers PRJ000001. */ + public function testFetchStatusSendsShipmentsWrapper(): void + { + $capturedRequest = null; + $transport = $this->createMock(TransportInterface::class); + $transport->method('send') + ->willReturnCallback(function (TransportRequest $req) use (&$capturedRequest) { + $capturedRequest = $req; + return new TransportResponse(200, $this->fixture('get_events_response')); + }); + + $this->makeClient($transport)->fetchStatus('OPLKRI2600895'); + + $this->assertNotNull($capturedRequest); + $this->assertSame('getEvents', $capturedRequest->soapAction); + $this->assertStringContainsString('', $capturedRequest->body); + $this->assertStringContainsString('', $capturedRequest->body); + } + + public function testFetchStatusByReferenceSendsReference(): void + { + $capturedRequest = null; + $transport = $this->createMock(TransportInterface::class); + $transport->method('send') + ->willReturnCallback(function (TransportRequest $req) use (&$capturedRequest) { + $capturedRequest = $req; + return new TransportResponse(200, $this->fixture('get_events_response')); + }); + + $this->makeClient($transport)->fetchStatusByReference('ORDER-123'); + + $this->assertNotNull($capturedRequest); + $this->assertStringContainsString('ORDER-123', $capturedRequest->body); + $this->assertStringNotContainsString('body); + } + + /** A rejected getEvents must not look like "no events yet" - that is silent tracking blindness. */ + public function testFetchStatusThrowsApiExceptionWhenSuccessFalse(): void + { + $this->expectException(\VeryCodeCom\Suus\Exception\SuusApiException::class); + $this->expectExceptionMessageMatches('/PRJ000001/'); + + $this->makeClient($this->mockTransport($this->fixture('get_events_not_found'))) + ->fetchStatus('OPLKRI2600895'); + } + // ---------------------------------------------- // fetchDocument // ---------------------------------------------- @@ -217,8 +264,63 @@ public function testFetchLabelCallsFetchDocument(): void $this->assertNotNull($capturedRequest); $this->assertStringContainsString('getDocument', $capturedRequest->soapAction); - $this->assertStringContainsString('body); - $this->assertStringContainsString('label<', $capturedRequest->body); + $this->assertStringContainsString('label', $capturedRequest->body); + $this->assertStringNotContainsString('body); + } + + public function testFetchDocumentSendsRequestedColliNumbers(): void + { + $capturedRequest = null; + $transport = $this->createMock(TransportInterface::class); + $transport->method('send') + ->willReturnCallback(function (TransportRequest $req) use (&$capturedRequest) { + $capturedRequest = $req; + return new TransportResponse(200, $this->fixture('get_document_response')); + }); + + $this->makeClient($transport)->fetchDocument( + 'OPLKRI2600895', + DocumentType::LabelA6, + ['WEB1705000047'], + ); + + $this->assertNotNull($capturedRequest); + $this->assertStringContainsString('', $capturedRequest->body); + $this->assertStringContainsString('WEB1705000047', $capturedRequest->body); + } + + public function testFetchDocumentByReferenceSendsReference(): void + { + $capturedRequest = null; + $transport = $this->createMock(TransportInterface::class); + $transport->method('send') + ->willReturnCallback(function (TransportRequest $req) use (&$capturedRequest) { + $capturedRequest = $req; + return new TransportResponse(200, $this->fixture('get_document_response')); + }); + + $this->makeClient($transport)->fetchDocumentByReference('ORDER-123'); + + $this->assertNotNull($capturedRequest); + $this->assertStringContainsString('ORDER-123', $capturedRequest->body); + $this->assertStringNotContainsString('body); + } + + public function testFetchLoadingListSendsMasterNo(): void + { + $capturedRequest = null; + $transport = $this->createMock(TransportInterface::class); + $transport->method('send') + ->willReturnCallback(function (TransportRequest $req) use (&$capturedRequest) { + $capturedRequest = $req; + return new TransportResponse(200, $this->fixture('get_document_response')); + }); + + $this->makeClient($transport)->fetchLoadingList('PKRM150000096'); + + $this->assertNotNull($capturedRequest); + $this->assertStringContainsString('loadingList', $capturedRequest->body); + $this->assertStringContainsString('PKRM150000096', $capturedRequest->body); } // ---------------------------------------------- @@ -566,6 +668,39 @@ public function testGetColliNumbersReturnsListOfNumbers(): void $this->assertSame('KRKRI2600895-3', $numbers[2]); } + public function testGetColliNumbersSendsShipmentsWrapper(): void + { + $capturedRequest = null; + $transport = $this->createMock(TransportInterface::class); + $transport->method('send') + ->willReturnCallback(function (TransportRequest $req) use (&$capturedRequest) { + $capturedRequest = $req; + return new TransportResponse(200, $this->fixture('get_colli_numbers_response')); + }); + + $this->makeClient($transport)->getColliNumbers('OPLKRI2600895'); + + $this->assertNotNull($capturedRequest); + $this->assertStringContainsString('', $capturedRequest->body); + $this->assertStringContainsString('', $capturedRequest->body); + } + + public function testGetColliNumbersByReferenceSendsReference(): void + { + $capturedRequest = null; + $transport = $this->createMock(TransportInterface::class); + $transport->method('send') + ->willReturnCallback(function (TransportRequest $req) use (&$capturedRequest) { + $capturedRequest = $req; + return new TransportResponse(200, $this->fixture('get_colli_numbers_response')); + }); + + $this->makeClient($transport)->getColliNumbersByReference('ORDER-123'); + + $this->assertNotNull($capturedRequest); + $this->assertStringContainsString('ORDER-123', $capturedRequest->body); + } + public function testGetColliNumbersSendsCorrectSoapAction(): void { $capturedRequest = null; @@ -583,6 +718,15 @@ public function testGetColliNumbersSendsCorrectSoapAction(): void $this->assertStringContainsString('OPLKRI2600895', $capturedRequest->body); } + public function testGetColliNumbersThrowsApiExceptionWhenSuccessFalse(): void + { + $xml = str_replace('getEventsResponse', 'getColliNoResponse', $this->fixture('get_events_not_found')); + + $this->expectException(\VeryCodeCom\Suus\Exception\SuusApiException::class); + + $this->makeClient($this->mockTransport($xml))->getColliNumbers('OPLKRI2600895'); + } + // ---------------------------------------------- // trackingUrl // ---------------------------------------------- From 0e688fdd8b368bd7b07d05a87024cf558ed2bd1c Mon Sep 17 00:00:00 2001 From: Emilian Scibisz Date: Wed, 19 Aug 2026 10:35:10 +0200 Subject: [PATCH 2/2] docs: release the read-side envelope fixes as 1.0.1 The additions (fetchLoadingList, the colli-number argument on fetchDocument and the reference-keyed read variants) are all optional and appended, so nothing downstream has to change to take this. --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf277f2..84c853b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm --- -## [1.1.0] - 2026-08-19 +## [1.0.1] - 2026-08-19 Fixes the read side of the API. `getEvents`, `getColliNo` and `getDocument` were all built with an envelope SUUS does not accept, so every one of them answered @@ -197,6 +197,6 @@ Initial public release. - `BTN*` codes are SUUS system errors (service temporarily unavailable), not validation failures; data-validation failures use the `DRG*` / `PRJ*` families. -[Unreleased]: https://github.com/very-code-com/suus-php/compare/v1.1.0...HEAD -[1.1.0]: https://github.com/very-code-com/suus-php/compare/v1.0.0...v1.1.0 +[Unreleased]: https://github.com/very-code-com/suus-php/compare/v1.0.1...HEAD +[1.0.1]: https://github.com/very-code-com/suus-php/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/very-code-com/suus-php/releases/tag/v1.0.0