PluginProbe
PostNL for WooCommerce / trunk
PostNL for WooCommerce vtrunk
5.9.12 5.9.11 5.9.10 5.9.9 5.9.8 5.9.7 5.9.6 trunk 2.5.0 2.5.1 2.5.2 2.5.3 2.5.4 2.5.5 3.1.4 3.1.5 3.1.6 3.1.7 4.0.0 4.0.1 4.0.2 4.3.2 4.3.3 4.4.0 4.4.1 All 72 releases
woo-postnl / src / Rest_API / V4 / Label / Request_Builder.php

Request_Builder.php in PostNL for WooCommerce trunk, at src/Rest_API/V4/Label/Request_Builder.php

451 lines 16.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Class Rest_API\V4\Label\Request_Builder file.
4 *
5 * @package PostNLWooCommerce\Rest_API\V4\Label
6 */
7
8 declare( strict_types = 1 );
9
10 namespace PostNLWooCommerce\Rest_API\V4\Label;
11
12 use Postnl\Sdk\Enums\Payload\AssociatedDocumentType;
13 use Postnl\Sdk\Enums\Payload\Bundle;
14 use Postnl\Sdk\Enums\Payload\Country;
15 use Postnl\Sdk\Enums\Payload\Currency;
16 use Postnl\Sdk\Enums\Payload\DeliveryConfirmation;
17 use Postnl\Sdk\Enums\Payload\LabelOutputType;
18 use Postnl\Sdk\Enums\Payload\LabelResolution;
19 use Postnl\Sdk\Enums\Payload\MinimalAgeCheck;
20 use Postnl\Sdk\Enums\Payload\ReceiverType;
21 use Postnl\Sdk\Enums\Payload\ShipmentType;
22 use Postnl\Sdk\RequestData\V4\Address;
23 use Postnl\Sdk\RequestData\V4\Contact;
24 use Postnl\Sdk\RequestData\V4\CustomerReferences;
25 use Postnl\Sdk\RequestData\V4\Dimensions;
26 use Postnl\Sdk\RequestData\V4\InternationalShipment\AssociatedDocument;
27 use Postnl\Sdk\RequestData\V4\InternationalShipment\Content;
28 use Postnl\Sdk\RequestData\V4\InternationalShipment\Customs;
29 use Postnl\Sdk\RequestData\V4\InternationalShipment\InternationalShipmentData;
30 use Postnl\Sdk\RequestData\V4\LabelSettings;
31 use Postnl\Sdk\RequestData\V4\Services;
32 use Postnl\Sdk\RequestData\V4\ShipmentParty;
33 use Postnl\Sdk\RequestData\V4\ShipmentDelivery\ShipmentDeliveryRequest;
34 use Postnl\Sdk\ResponseData\V4\ShippingItem;
35
36 if ( ! defined( 'ABSPATH' ) ) {
37 exit;
38 }
39
40 /**
41 * Class Request_Builder
42 *
43 * Pure translator from a flat, already-parsed field array into a V4
44 * ShipmentDeliveryRequest DTO for the /shipment/delivery/v4/labelconfirm
45 * endpoint. It performs no WooCommerce or settings access, so the DTO shape
46 * can be asserted in isolation.
47 *
48 * Scope: single parcel, single collo, optional delivery Services
49 * (insurance, signature/delivery-code confirmation, stated-address-only,
50 * return-when-not-home and their combinations) for domestic shipments, plus
51 * EU/ROW international shipments carrying an InternationalShipmentData block
52 * (service bundle + customs declaration). The customer number/code are
53 * injected into the sender by the SDK client
54 * (ClientBuilder::withCustomerCredentials), so they are deliberately absent
55 * here. CollectionLocation and MessageID are V1-only and never emitted.
56 *
57 * @since 6.0.0
58 * @package PostNLWooCommerce\Rest_API\V4\Label
59 */
60 class Request_Builder {
61
62 /**
63 * Build the ShipmentDeliveryRequest from parsed order fields.
64 *
65 * @param array $fields {
66 * Flat, pre-parsed values sourced from the legacy Shipping\Item_Info.
67 *
68 * @type array $sender Store address: company, street, house_number,
69 * house_number_ext, postcode, city, country.
70 * @type array $receiver Recipient: company, first_name, last_name, street,
71 * house_number, house_number_ext, postcode, city,
72 * country, email, phone.
73 * @type string $shipment_type V4 ShipmentType value, e.g. 'parcel'.
74 * @type int $weight_gr Total shipment weight in grams.
75 * @type string $reference Merchant shipment reference (order number).
76 * @type string $barcode Pre-issued barcode to confirm; empty to let
77 * labelconfirm auto-issue one. Ignored when
78 * $barcodes is provided.
79 * @type array $barcodes Pre-issued barcodes, one per collo, for a
80 * multi-collo shipment. Falls back to a single
81 * item built from $barcode when absent. Assumed
82 * already filtered to scalars and capped to
83 * $num_labels by Service::extract_fields(), which
84 * is where untrusted post_data enters.
85 * @type int $num_labels Collo count (1-10). Governs the item count only
86 * when neither $barcodes nor $barcode carries a
87 * pre-issued barcode; the barcodes win otherwise.
88 * @type array $label Label output: output_type (pdf|zpl|jpg|gif|png)
89 * and resolution (200|300|600).
90 * @type array $services Optional resolved service flags: deliveryConfirmation
91 * ('signature'|'deliverycode'), insuredValue (float),
92 * statedAddressOnly (bool), returnWhenNotHome (bool).
93 * minimalAgeCheck ('16+'|'18+') is accepted but no
94 * V4_Mapper row emits it yet — every id_check
95 * combination is still Legacy-only, so ID Check
96 * orders do not reach V4 at all.
97 * @type array $international Optional EU/ROW data: bundle ('track_trace'|'insured'|
98 * 'insured_plus') and customs (currency, transaction_code,
99 * associated_document{type,number}, sender_identification,
100 * receiver_identification, content[]{description, quantity,
101 * weight, value, country_of_origin, hs_code}).
102 * }
103 * @return ShipmentDeliveryRequest
104 */
105 public static function build( array $fields ): ShipmentDeliveryRequest {
106 $sender_fields = $fields['sender'] ?? array();
107 $receiver_fields = $fields['receiver'] ?? array();
108 $label_fields = $fields['label'] ?? array();
109
110 $sender = ShipmentParty::asSender(
111 address: self::address( $sender_fields )
112 );
113
114 $receiver = ShipmentParty::asReceiver(
115 address: self::address( $receiver_fields ),
116 contact: self::contact( $receiver_fields ),
117 receiverType: ReceiverType::Consumer
118 );
119
120 $label_settings = new LabelSettings(
121 outputType: self::output_type( (string) ( $label_fields['output_type'] ?? 'pdf' ) ),
122 resolution: self::resolution( (int) ( $label_fields['resolution'] ?? 200 ) )
123 );
124
125 return new ShipmentDeliveryRequest(
126 sender: $sender,
127 receiver: $receiver,
128 labelSettings: $label_settings,
129 shipmentType: self::shipment_type( (string) ( $fields['shipment_type'] ?? 'parcel' ) ),
130 services: self::services( $fields['services'] ?? array() ),
131 internationalShipmentData: self::international( $fields['international'] ?? array() ),
132 items: self::items( $fields )
133 );
134 }
135
136 /**
137 * Build one ShippingItem per collo.
138 *
139 * A multi-collo shipment sends one item per barcode in a single request; the
140 * SDK derives itemCount from the item count. Each collo carries the same
141 * shipment reference and weight, matching the legacy per-shipment payload.
142 *
143 * When no barcode is pre-issued the count comes from num_labels instead, so an
144 * order that lets labelconfirm issue its barcodes is not collapsed into one
145 * collo. That labelconfirm auto-issues a barcode per barcode-less item is an
146 * API assumption the SDK docs do not cover (flip-checklist Q15c); if it is
147 * unsupported the request fails loudly, which is the point — the silent
148 * single-collo collapse is what this replaces.
149 *
150 * @param array $fields Builder input keyed as documented on build().
151 * @return ShippingItem[]
152 */
153 private static function items( array $fields ): array {
154 $barcodes = (array) ( $fields['barcodes'] ?? array() );
155
156 if ( empty( $barcodes ) ) {
157 $single = (string) ( $fields['barcode'] ?? '' );
158 $collo_count = ( '' === $single ) ? max( 1, (int) ( $fields['num_labels'] ?? 1 ) ) : 1;
159 $barcodes = array_fill( 0, $collo_count, $single );
160 }
161
162 $reference = self::maybe_null( (string) ( $fields['reference'] ?? '' ) );
163 $weight_gr = max( 1, (int) ( $fields['weight_gr'] ?? 0 ) );
164
165 $items = array();
166 foreach ( $barcodes as $barcode ) {
167 $items[] = new ShippingItem(
168 barcode: self::maybe_null( (string) $barcode ),
169 customerReferences: new CustomerReferences(
170 shipmentReference: $reference
171 ),
172 dimensions: new Dimensions(
173 weightGr: $weight_gr
174 )
175 );
176 }
177
178 return $items;
179 }
180
181 /**
182 * Build the InternationalShipmentData block for an EU/ROW shipment.
183 *
184 * Returns null for a domestic shipment (no international data supplied), so
185 * the request omits the block entirely. The service bundle is carried here —
186 * not on Services, which has no bundle field — and the customs declaration is
187 * attached when the shipment carries content items.
188 *
189 * @param array $data International data keyed as documented on build().
190 * @return InternationalShipmentData|null
191 */
192 private static function international( array $data ): ?InternationalShipmentData {
193 if ( empty( $data ) ) {
194 return null;
195 }
196
197 $bundle = Bundle::tryFrom( (string) ( $data['bundle'] ?? '' ) );
198 $customs = self::customs( $data['customs'] ?? array() );
199
200 if ( null === $bundle && null === $customs ) {
201 return null;
202 }
203
204 return new InternationalShipmentData(
205 customs: $customs,
206 bundle: $bundle
207 );
208 }
209
210 /**
211 * Build the Customs declaration from the shipment's customs fields.
212 *
213 * Returns null when there are no content items to declare, so a shipment
214 * that needs no customs block omits it. Mirrors the legacy Customs block:
215 * transactionCode 11 with an invoice associatedDocument, the order currency,
216 * a trusted-shipper senderIdentification when the merchant supplied one, and
217 * one Content entry per order line.
218 *
219 * @param array $data Customs fields keyed as documented on build().
220 * @return Customs|null
221 */
222 private static function customs( array $data ): ?Customs {
223 $content = self::customs_content( $data['content'] ?? array() );
224
225 if ( empty( $content ) ) {
226 return null;
227 }
228
229 return new Customs(
230 content: $content,
231 transactionCode: self::maybe_null( (string) ( $data['transaction_code'] ?? '' ) ),
232 currency: Currency::tryFrom( strtoupper( (string) ( $data['currency'] ?? '' ) ) ),
233 associatedDocument: self::associated_document( $data['associated_document'] ?? array() ),
234 senderIdentification: self::maybe_null( (string) ( $data['sender_identification'] ?? '' ) ),
235 receiverIdentification: self::maybe_null( (string) ( $data['receiver_identification'] ?? '' ) )
236 );
237 }
238
239 /**
240 * Translate the per-line customs items into Content DTOs.
241 *
242 * @param array $items Customs content items keyed as documented on build().
243 * @return Content[]
244 */
245 private static function customs_content( array $items ): array {
246 $content = array();
247
248 foreach ( $items as $item ) {
249 if ( ! is_array( $item ) ) {
250 continue;
251 }
252
253 $content[] = new Content(
254 description: self::maybe_null( self::truncate( (string) ( $item['description'] ?? '' ), 35 ) ),
255 quantity: max( 1, (int) ( $item['quantity'] ?? 1 ) ),
256 weight: max( 1, (int) ( $item['weight'] ?? 0 ) ),
257 value: (float) ( $item['value'] ?? 0 ),
258 countryOfOrigin: Country::tryFrom( strtoupper( (string) ( $item['country_of_origin'] ?? '' ) ) ),
259 hsTariffNumber: self::maybe_null( (string) ( $item['hs_code'] ?? '' ) )
260 );
261 }
262
263 return $content;
264 }
265
266 /**
267 * Build the customs associatedDocument, mandatory for transactionCode 11/21/32.
268 *
269 * @param array $data Associated-document fields: type and number.
270 * @return AssociatedDocument|null
271 */
272 private static function associated_document( array $data ): ?AssociatedDocument {
273 if ( empty( $data ) ) {
274 return null;
275 }
276
277 $type = AssociatedDocumentType::tryFrom( (string) ( $data['type'] ?? '' ) );
278 $number = self::maybe_null( (string) ( $data['number'] ?? '' ) );
279
280 if ( null === $type && null === $number ) {
281 return null;
282 }
283
284 return new AssociatedDocument(
285 type: $type,
286 number: $number
287 );
288 }
289
290 /**
291 * Translate resolved service flags into a V4 Services DTO.
292 *
293 * Returns null when no recognised service is present, so the request omits
294 * the Services block entirely for a plain parcel.
295 *
296 * @param array $flags Resolved service flags keyed as documented on build().
297 * @return Services|null
298 */
299 private static function services( array $flags ): ?Services {
300 $confirmation = DeliveryConfirmation::tryFrom( (string) ( $flags['deliveryConfirmation'] ?? '' ) );
301 $age_check = MinimalAgeCheck::tryFrom( (string) ( $flags['minimalAgeCheck'] ?? '' ) );
302 // isset (not ! empty) so a legitimately zero insured value is still sent, matching
303 // the legacy Amounts block, which emits Value 0 rather than omitting the block.
304 $insured = isset( $flags['insuredValue'] ) ? (float) $flags['insuredValue'] : null;
305 $stated_only = ! empty( $flags['statedAddressOnly'] ) ? true : null;
306 $return_home = ! empty( $flags['returnWhenNotHome'] ) ? true : null;
307
308 if ( null === $confirmation && null === $age_check && null === $insured
309 && null === $stated_only && null === $return_home ) {
310 return null;
311 }
312
313 return new Services(
314 statedAddressOnly: $stated_only,
315 returnWhenNotHome: $return_home,
316 minimalAgeCheck: $age_check,
317 deliveryConfirmation: $confirmation,
318 insuredValue: $insured
319 );
320 }
321
322 /**
323 * Translate an address field array into a V4 Address DTO.
324 *
325 * @param array $fields Address fields keyed as documented on build().
326 * @return Address
327 */
328 private static function address( array $fields ): Address {
329 return new Address(
330 countryIso: self::country( (string) ( $fields['country'] ?? '' ) ),
331 houseNumber: self::maybe_null( (string) ( $fields['house_number'] ?? '' ) ),
332 postalCode: self::maybe_null( (string) ( $fields['postcode'] ?? '' ) ),
333 companyName: self::maybe_null( (string) ( $fields['company'] ?? '' ) ),
334 street: self::maybe_null( (string) ( $fields['street'] ?? '' ) ),
335 houseNumberAddition: self::maybe_null( (string) ( $fields['house_number_ext'] ?? '' ) ),
336 city: self::maybe_null( (string) ( $fields['city'] ?? '' ) )
337 );
338 }
339
340 /**
341 * Translate recipient contact fields into a V4 Contact DTO.
342 *
343 * @param array $fields Receiver fields keyed as documented on build().
344 * @return Contact
345 */
346 private static function contact( array $fields ): Contact {
347 return new Contact(
348 email: self::maybe_null( (string) ( $fields['email'] ?? '' ) ),
349 firstName: self::maybe_null( (string) ( $fields['first_name'] ?? '' ) ),
350 lastName: self::maybe_null( (string) ( $fields['last_name'] ?? '' ) ),
351 mobileNumber: self::maybe_null( (string) ( $fields['phone'] ?? '' ) ),
352 companyName: self::maybe_null( (string) ( $fields['company'] ?? '' ) )
353 );
354 }
355
356 /**
357 * Resolve a country code into the SDK Country enum, defaulting to NL.
358 *
359 * @param string $code Two-letter ISO country code.
360 * @return Country
361 */
362 private static function country( string $code ): Country {
363 return Country::tryFrom( strtoupper( $code ) ) ?? Country::NL;
364 }
365
366 /**
367 * Resolve a shipment-type string into the SDK ShipmentType enum.
368 *
369 * @param string $type ShipmentType value, e.g. 'parcel'.
370 * @return ShipmentType
371 */
372 private static function shipment_type( string $type ): ShipmentType {
373 return ShipmentType::tryFrom( $type ) ?? ShipmentType::Parcel;
374 }
375
376 /**
377 * Resolve a label output-type string into the SDK LabelOutputType enum.
378 *
379 * @param string $output_type One of pdf|zpl|jpg|gif|png.
380 * @return LabelOutputType
381 */
382 private static function output_type( string $output_type ): LabelOutputType {
383 return LabelOutputType::tryFrom( strtolower( $output_type ) ) ?? LabelOutputType::PDF;
384 }
385
386 /**
387 * Resolve a resolution integer into the SDK LabelResolution enum.
388 *
389 * @param int $resolution One of 200|300|600.
390 * @return LabelResolution
391 */
392 private static function resolution( int $resolution ): LabelResolution {
393 return LabelResolution::tryFrom( $resolution ) ?? LabelResolution::DPI_200;
394 }
395
396 /**
397 * Map a legacy combined printer-type string to discrete V4 label settings.
398 *
399 * The legacy setting stores values such as 'GraphicFile|PDF' or
400 * 'Zebra|Generic ZPL II 600 dpi'; V4 wants a separate output type and
401 * resolution. PDF carries no dpi and falls back to 200.
402 *
403 * @param string $printer_type Legacy combined printer-type string.
404 * @return array{output_type:string,resolution:int}
405 */
406 public static function printer_type_to_label_settings( string $printer_type ): array {
407 $output_type = 'pdf';
408 foreach ( array( 'zpl', 'jpg', 'gif', 'png', 'pdf' ) as $candidate ) {
409 if ( false !== stripos( $printer_type, $candidate ) ) {
410 $output_type = $candidate;
411 break;
412 }
413 }
414
415 $resolution = 200;
416 if ( preg_match( '/(\d{3})\s*dpi/i', $printer_type, $matches ) ) {
417 $resolution = (int) $matches[1];
418 }
419
420 return array(
421 'output_type' => $output_type,
422 'resolution' => $resolution,
423 );
424 }
425
426 /**
427 * Return null for an empty string so the DTO omits the field entirely.
428 *
429 * @param string $value Candidate value.
430 * @return string|null
431 */
432 private static function maybe_null( string $value ): ?string {
433 return '' === $value ? null : $value;
434 }
435
436 /**
437 * Truncate a string to a maximum length, respecting multibyte characters.
438 *
439 * The customs Content description is capped at 35 characters by PostNL.
440 *
441 * @param string $value Candidate value.
442 * @param int $length Maximum length.
443 * @return string
444 */
445 private static function truncate( string $value, int $length ): string {
446 return function_exists( 'mb_substr' )
447 ? mb_substr( $value, 0, $length )
448 : substr( $value, 0, $length );
449 }
450 }
451