Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<shipments><shipment>`
wrapper.** Both take an `ArrayOfShipments`, never a bare `<shipmentNo>` (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 `<document>`, not `<documentType>`**
(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 `<colliNo>`
element in a `getColliNo` response is an `ArrayOfColli` wrapper holding
`<colli><colliNo>` 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.
Expand Down Expand Up @@ -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
40 changes: 33 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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`.

Expand All @@ -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
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -465,9 +490,10 @@ new SuusClient(
1. **`lenghtCm` typo** - SUUS uses `<lenghtCm>` (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. **`<auth>` 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 `<shipments><shipment>` wrapper (never a bare `<shipmentNo>`), and `getDocument` names the document symbol `<document>`, not `<documentType>`.
5. **Colli numbers nest twice** - the `<colliNo>` element in a `getColliNo` response is an `ArrayOfColli` wrapper holding `<colli><colliNo>` leaves.
6. **Loading date minimum** - SUUS requires +2 Polish business days advance notice.
7. **`<auth>` in every body** - Unlike most SOAP services, SUUS embeds the auth block inside every operation's body, not in the SOAP header.

---

Expand Down
9 changes: 6 additions & 3 deletions examples/03_fetch_status.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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',
Expand Down
25 changes: 18 additions & 7 deletions examples/04_fetch_document.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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";
Expand All @@ -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";
}
13 changes: 8 additions & 5 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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).
Expand Down
8 changes: 8 additions & 0 deletions src/Enum/DocumentType.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,19 @@

/**
* SUUS document types for the getDocument API call.
*
* Sent as the <document> 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';
}
10 changes: 8 additions & 2 deletions src/Internal/Soap/ResponseParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <colliNo> wrapper: the wrapper is
* an ArrayOfColli holding <colli><colliNo> 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 [];
}
Expand Down
Loading