diff --git a/CHANGELOG.md b/CHANGELOG.md index 8495658..84c853b 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.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 +`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.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 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 // ----------------------------------------------