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 / Service.php

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

735 lines 30.0 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\Service 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\Client\PostnlClientInterface;
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\RequestData\V4\ShipmentDelivery\ShipmentDeliveryRequest;
17 use Postnl\Sdk\Service\ShipmentDelivery\V4\Response\LabelConfirmResponseInterface;
18 use PostNLWooCommerce\Order\Base as Order_Base;
19 use PostNLWooCommerce\Rest_API\Contracts\Label_Service_Interface;
20 use PostNLWooCommerce\Rest_API\SDK\Client_Factory;
21 use PostNLWooCommerce\Rest_API\SDK\Exception_Converter;
22 use PostNLWooCommerce\Rest_API\Shipping;
23 use PostNLWooCommerce\Utils;
24 use Psr\Log\LoggerInterface;
25
26 if ( ! defined( 'ABSPATH' ) ) {
27 exit;
28 }
29
30 /**
31 * Class Service
32 *
33 * V4 SDK implementation of the outbound shipping-label flow. It confirms a
34 * label through the /shipment/delivery/v4/labelconfirm endpoint and stores the
35 * result in the same _postnl_order_metadata['labels'] shape as the legacy path,
36 * tagged api_version = v4.
37 *
38 * Scope: a parcel (single- or multi-collo) with any delivery Service the mapper
39 * confirms a V4 equivalent for — insurance, signature/delivery-code
40 * confirmation, stated-address-only, return-when-not-home and their
41 * combinations — for domestic NL shipments, plus EU/ROW international parcels
42 * (4907/4909) carrying an InternationalShipmentData bundle and customs
43 * declaration. The domestic NL 24h letterbox (mailbox parcel 2928) also falls
44 * out here as a ShipmentType::LetterBox variant. Everything else — pickup
45 * (DeliveryLocation), the 48h letterbox (2948), packet/mailbox international
46 * products, returns, delivery-day/evening selection — falls back to the
47 * untouched legacy pipeline until those flows are migrated. Because both
48 * gates (a validated V4 key and the per-flow flag) default off, merging this
49 * changes nothing for merchants.
50 *
51 * Barcode note: the barcode is generated by the (still-legacy) barcode flow in
52 * Order\Base::save_meta_value() and supplied on the request so the persisted
53 * barcodes[] and the label's barcode stay in sync. The barcode returned by
54 * labelconfirm is captured and used for the label record. When the barcode flow
55 * itself migrates (task 14) the pre-issue step can be dropped and the
56 * auto-issued barcode used directly.
57 *
58 * Extends Order\Base to reuse put/merge helpers and the legacy pipeline for the
59 * fallback, mirroring Legacy\Label_Service.
60 *
61 * The PSR-3 logger is required, not optional, exactly as in V4\Timeframe\Service
62 * and V4\Pickup_Location\Service: it is where a failed label call's real cause
63 * survives (Exception_Converter hands the merchant a safe message and keeps the
64 * SDK's own only as the previous exception). Legacy label generation writes every
65 * request and response to the WooCommerce log via Rest_API\Base::send_request(),
66 * so without this the V4 path would be the only label flow that fails with no
67 * trail at all. Wiring passes a Logger_Adapter, so V4 entries land in the same
68 * WooCommerce log as the legacy path and honour the same "enable logging"
69 * setting.
70 *
71 * @since 6.0.0
72 * @package PostNLWooCommerce\Rest_API\V4\Label
73 */
74 class Service extends Order_Base implements Label_Service_Interface {
75
76 /**
77 * SDK client factory.
78 *
79 * @var Client_Factory
80 */
81 private $client_factory;
82
83 /**
84 * PostNL V4 API key used to authenticate SDK requests.
85 *
86 * @var string
87 */
88 private $v4_key;
89
90 /**
91 * PSR-3 logger the failure path reports through.
92 *
93 * @var LoggerInterface
94 */
95 private $logger;
96
97 /**
98 * Service constructor.
99 *
100 * The API key and the logger are both required rather than defaulted, matching
101 * V4\Timeframe\Service and V4\Pickup_Location\Service: the key is resolved and
102 * validated by the caller that already decides whether V4 may run at all
103 * (Service_Factory::has_v4_key()), so re-deriving it here would duplicate that
104 * decision behind a fallback that silently sends an empty key; and a defaulted
105 * NullLogger would let a caller wire the service up with logging switched off —
106 * the exact gap this parameter closes.
107 *
108 * The key is marked SensitiveParameter so PHP redacts it from stack traces,
109 * matching Client_Factory::build().
110 *
111 * @since 6.0.0 Requires the resolved API key and a PSR-3 logger.
112 *
113 * @param Client_Factory $client_factory SDK client factory.
114 * @param string $v4_key PostNL V4 API key.
115 * @param LoggerInterface $logger PSR-3 logger; wiring passes a Logger_Adapter.
116 */
117 public function __construct(
118 Client_Factory $client_factory,
119 #[\SensitiveParameter]
120 string $v4_key,
121 LoggerInterface $logger
122 ) {
123 parent::__construct();
124 $this->client_factory = $client_factory;
125 $this->v4_key = $v4_key;
126 $this->logger = $logger;
127 }
128
129 /**
130 * No WP hooks are registered by this service class.
131 *
132 * Required by Order\Base but intentionally a no-op here.
133 */
134 public function init_hooks() {
135 // Intentionally empty — this service does not register WordPress hooks.
136 }
137
138 /**
139 * Create a shipping label and return the normalized label record.
140 *
141 * Routes the happy-path domestic parcel to the V4 SDK; anything else runs
142 * the untouched legacy pipeline.
143 *
144 * @param array $post_data Context needed to build and send the label request.
145 *
146 * @return array Normalized label record keyed by label-type string.
147 *
148 * @throws \Exception If the SDK request fails (converted to the legacy error shape).
149 */
150 public function create( array $post_data ): array {
151 $item_info = new Shipping\Item_Info( $post_data );
152 $signals = $this->gather_signals( $item_info, $post_data );
153
154 if ( ! Eligibility::is_eligible( $signals ) ) {
155 return $this->create_label_pipeline( $post_data );
156 }
157
158 $fields = $this->extract_fields( $item_info, $signals['mapped'], $post_data );
159 $request = Request_Builder::build( $fields );
160 $response = $this->confirm_label( $request, $fields );
161
162 $barcodes = ! empty( $fields['barcodes'] ) ? $fields['barcodes'] : array( (string) $fields['barcode'] );
163
164 return $this->store_labels( $response, $post_data['order'], $barcodes );
165 }
166
167 /**
168 * Send the labelconfirm request, converting and logging any failure.
169 *
170 * @param ShipmentDeliveryRequest $request Built labelconfirm request.
171 * @param array $fields Flattened field set the request was built from.
172 *
173 * @return LabelConfirmResponseInterface
174 *
175 * @throws \Exception Converted SDK error when the request fails.
176 */
177 protected function confirm_label( ShipmentDeliveryRequest $request, array $fields ): LabelConfirmResponseInterface {
178 try {
179 return $this->build_client()->shipmentDelivery()->labelConfirm( $request );
180 } catch ( \Throwable $exception ) {
181 // Exception_Converter returns a plugin-shaped \Exception; its message can
182 // carry raw API text (field errors, upstream messages) — escape on output.
183 $error = Exception_Converter::convert( $exception );
184
185 // The converted message is deliberately merchant-safe, and one of its
186 // variants tells the reader to check these very logs, so the original SDK
187 // failure has to be written here — nothing else reads getPrevious().
188 //
189 // The shipment reference is the merchant's own order number: it is what
190 // makes the entry traceable back to a shipment, and it is store-internal
191 // rather than customer-identifying, so it is kept where the address parts
192 // are not.
193 $this->logger->error(
194 sprintf(
195 'V4 label creation failed for order "%1$s" to "%2$s": %3$s (cause: %4$s: %5$s)',
196 (string) ( $fields['reference'] ?? '' ),
197 $this->describe_destination( $fields ),
198 $error->getMessage(),
199 get_class( $exception ),
200 $exception->getMessage()
201 )
202 );
203
204 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception_Converter returns an already-escaped, translated message.
205 throw $error;
206 }
207 }
208
209 /**
210 * Short, log-safe description of the destination a failed label was for.
211 *
212 * Mirrors V4\Timeframe\Service::describe_destination(): legacy
213 * Rest_API\Base::send_request() writes the entire request body — recipient name,
214 * street, house number, city, email and phone included — to the same WooCommerce
215 * log, whereas this keeps only the country and the postcode's leading area
216 * digits. An error line is written whether or not anyone is debugging, while the
217 * SDK's own request logging (which does carry the address, PII-redacted) is what
218 * a merchant turns on deliberately.
219 *
220 * @param array $fields Flattened field set.
221 *
222 * @return string e.g. 'NL 1234'; empty when the payload carried no address.
223 */
224 private function describe_destination( array $fields ): string {
225 $country = (string) ( $fields['receiver']['country'] ?? '' );
226 $postcode = str_replace( ' ', '', (string) ( $fields['receiver']['postcode'] ?? '' ) );
227
228 return trim( $country . ' ' . substr( $postcode, 0, 4 ) );
229 }
230
231 /**
232 * Collect the eligibility signals for an order from the legacy parser.
233 *
234 * The selected backend features are read from the raw post data (string
235 * 'yes' values), not the parsed booleans on Item_Info, because
236 * Utils::get_selected_label_features() matches on 'yes'. Passing the real
237 * options into the mapper is what lets a service-bearing combination that
238 * keeps product 3085 (e.g. insured) resolve to a services row and be
239 * rejected below, rather than silently masquerading as the base parcel.
240 *
241 * @param Shipping\Item_Info $item_info Parsed legacy item info.
242 * @param array $post_data Original label post data.
243 * @return array Signal set consumed by is_eligible().
244 */
245 private function gather_signals( Shipping\Item_Info $item_info, array $post_data ): array {
246 $backend_raw = $post_data['saved_data']['backend'] ?? array();
247
248 $origin = (string) ( $item_info->shipper['country'] ?? '' );
249 $destination = Utils::get_shipping_zone(
250 (string) ( $item_info->receiver['country'] ?? '' ),
251 (string) ( $item_info->receiver['state'] ?? '' )
252 );
253
254 $has_return = ! empty( $item_info->shipment['return_barcode'] )
255 || ! empty( $item_info->shipment['shipping_return_barcode'] )
256 || ! empty( $item_info->shipment['return_options'] )
257 || 'yes' === ( $backend_raw['create_return_label'] ?? '' )
258 || 'yes' === ( $backend_raw['create_shipment_return_label'] ?? '' );
259
260 return array(
261 'num_labels' => (int) ( $item_info->backend_data['num_labels'] ?? 1 ),
262 'is_delivery_day' => $item_info->is_delivery_day(),
263 'is_pickup' => $item_info->is_pickup_points(),
264 'has_return' => $has_return,
265 'delivery_type' => (string) ( $item_info->backend_data['delivery_type'] ?? 'Standard' ),
266 'origin' => $origin,
267 'destination' => $destination,
268 'mapped' => Eligibility::resolve_mapped(
269 $origin,
270 $destination,
271 $item_info->is_pickup_points(),
272 $backend_raw,
273 (string) $item_info->get_product_code()
274 ),
275 );
276 }
277
278 /**
279 * Flatten the parsed item info into the field array Request_Builder consumes.
280 *
281 * @param Shipping\Item_Info $item_info Parsed legacy item info.
282 * @param array $mapped V4_Mapper::map() result.
283 * @param array $post_data Original label post data.
284 * @return array
285 */
286 private function extract_fields( Shipping\Item_Info $item_info, array $mapped, array $post_data ): array {
287 $num_labels = (int) ( $item_info->backend_data['num_labels'] ?? 1 );
288
289 // Legacy caps the collo count it puts on the wire through the num_labels
290 // sanitizer (1-10), which the V1 request loop iterates; the barcode prefetch
291 // in Order\Base::maybe_create_multi_barcodes() reads the raw backend value and
292 // applies no cap, and the meta-box field has a min but no max. Slicing to the
293 // parsed count keeps V4 shipping the same colli V1 would -- surplus barcodes
294 // go unused, exactly as on V1.
295 $barcodes = array_values( array_filter( (array) ( $post_data['barcodes'] ?? array() ), 'is_scalar' ) );
296 $barcodes = array_slice( $barcodes, 0, $num_labels );
297
298 return array(
299 'sender' => array(
300 'company' => $item_info->shipper['company'] ?? '',
301 'street' => $item_info->shipper['address_1'] ?? '',
302 'house_number' => $item_info->shipper['address_2'] ?? '',
303 'house_number_ext' => '',
304 'postcode' => $item_info->shipper['postcode'] ?? '',
305 'city' => $item_info->shipper['city'] ?? '',
306 'country' => $item_info->shipper['country'] ?? '',
307 ),
308 'receiver' => array(
309 'company' => $item_info->receiver['company'] ?? '',
310 'first_name' => $item_info->receiver['first_name'] ?? '',
311 'last_name' => $item_info->receiver['last_name'] ?? '',
312 'street' => $item_info->receiver['address_1'] ?? '',
313 'house_number' => $item_info->receiver['house_number'] ?? '',
314 'house_number_ext' => $item_info->receiver['address_2'] ?? '',
315 'postcode' => $item_info->receiver['postcode'] ?? '',
316 'city' => $item_info->receiver['city'] ?? '',
317 'country' => $item_info->receiver['country'] ?? '',
318 'email' => $item_info->shipment['email'] ?? '',
319 'phone' => $item_info->shipment['phone'] ?? '',
320 ),
321 'shipment_type' => $mapped['shipmentType'] ?? 'parcel',
322 'weight_gr' => (int) ( $item_info->shipment['total_weight'] ?? 0 ),
323 'reference' => (string) ( $item_info->shipment['order_number'] ?? '' ),
324 'barcode' => (string) ( $post_data['main_barcode'] ?? '' ),
325 'barcodes' => $barcodes,
326 // The collo count is carried even when barcodes are pre-issued: it is the
327 // only record of it when the label call issues the barcodes instead, and
328 // Request_Builder ignores it whenever a barcode is supplied.
329 'num_labels' => $num_labels,
330 'services' => Eligibility::resolve_services(
331 $mapped['services'] ?? array(),
332 (float) ( $item_info->shipment['subtotal'] ?? 0 )
333 ),
334 'international' => $this->extract_international( $item_info, $mapped ),
335 'label' => Request_Builder::printer_type_to_label_settings(
336 (string) ( $item_info->shipment['printer_type'] ?? '' )
337 ),
338 );
339 }
340
341 /**
342 * Flatten the international shipment data (bundle + customs) for an EU/ROW order.
343 *
344 * Returns an empty array for a domestic shipment — the mapper only carries an
345 * internationalShipmentData hint (the service bundle) for EU/ROW parcels, so
346 * its presence is what distinguishes the two.
347 *
348 * @param Shipping\Item_Info $item_info Parsed legacy item info.
349 * @param array $mapped V4_Mapper::map() result.
350 * @return array
351 */
352 private function extract_international( Shipping\Item_Info $item_info, array $mapped ): array {
353 $international = $mapped['internationalShipmentData'] ?? array();
354
355 if ( empty( $international ) ) {
356 return array();
357 }
358
359 $fields = array(
360 'bundle' => (string) ( $international['bundle'] ?? '' ),
361 'customs' => $this->extract_customs( $item_info ),
362 );
363
364 $this->warn_unmappable_international( $fields, (string) ( $item_info->shipment['order_number'] ?? '' ) );
365
366 return $fields;
367 }
368
369 /**
370 * Report the international values Request_Builder will silently drop.
371 *
372 * The log lives here rather than in the builder because the builder is a pure
373 * static translator — a flat array in, a DTO out, no collaborators — so giving it
374 * a logger would mean threading one through every call site just to translate a
375 * field. This is the last point where the logger, the raw values and the order
376 * reference all exist together.
377 *
378 * Each check mirrors the builder's own normalization exactly: Bundle::tryFrom on
379 * the raw string (the builder folds no case there), Currency::tryFrom and
380 * Country::tryFrom on the upper-cased value. Anything looser would warn about
381 * values that reach PostNL perfectly well — a lowercase 'eur' — and train
382 * merchants to ignore the log. Empty values are omitted by the builder by design
383 * and are not reported.
384 *
385 * Without this, an enum miss is invisible: the SDK Currency enum carries only
386 * thirteen currencies, so a store selling in HUF sends a customs declaration with
387 * no currency, and a mistyped mapper bundle ships an uninsured parcel. The order
388 * number is the merchant's own reference, and currency/country codes carry no
389 * personal data, so the whole line is safe to write unconditionally.
390 *
391 * @param array $fields Flattened international field set.
392 * @param string $order_reference Merchant order number the shipment belongs to.
393 * @return void
394 */
395 private function warn_unmappable_international( array $fields, string $order_reference ): void {
396 $bundle = (string) ( $fields['bundle'] ?? '' );
397
398 if ( '' !== $bundle && null === Bundle::tryFrom( $bundle ) ) {
399 $this->logger->warning(
400 sprintf(
401 'V4 label for order "%1$s": unknown international service bundle "%2$s"; the shipment is sent without a bundle.',
402 $order_reference,
403 $bundle
404 )
405 );
406 }
407
408 $currency = (string) ( $fields['customs']['currency'] ?? '' );
409
410 if ( '' !== $currency && null === Currency::tryFrom( strtoupper( $currency ) ) ) {
411 $this->logger->warning(
412 sprintf(
413 'V4 label for order "%1$s": unsupported customs currency "%2$s"; the customs declaration is sent without a currency.',
414 $order_reference,
415 $currency
416 )
417 );
418 }
419
420 foreach ( (array) ( $fields['customs']['content'] ?? array() ) as $item ) {
421 $origin = (string) ( $item['country_of_origin'] ?? '' );
422
423 if ( '' !== $origin && null === Country::tryFrom( strtoupper( $origin ) ) ) {
424 $this->logger->warning(
425 sprintf(
426 'V4 label for order "%1$s": unknown customs country of origin "%2$s"; that item is declared without a country of origin.',
427 $order_reference,
428 $origin
429 )
430 );
431 }
432 }
433 }
434
435 /**
436 * Build the customs declaration fields from the parsed order line items.
437 *
438 * Mirrors the legacy Customs block: transactionCode 11 with an invoice
439 * associatedDocument, the order currency, a trusted-shipper
440 * senderIdentification from the merchant code (empty for EU destinations),
441 * and one content entry per non-virtual line item.
442 *
443 * @param Shipping\Item_Info $item_info Parsed legacy item info.
444 * @return array
445 */
446 private function extract_customs( Shipping\Item_Info $item_info ): array {
447 $content = array();
448
449 foreach ( (array) $item_info->contents as $item ) {
450 $content[] = array(
451 'description' => (string) ( $item['description'] ?? '' ),
452 'quantity' => (int) ( $item['qty'] ?? 1 ),
453 'weight' => (int) ( $item['weight'] ?? 0 ),
454 'value' => (float) ( $item['value'] ?? 0 ),
455 'country_of_origin' => (string) ( $item['origin'] ?? '' ),
456 'hs_code' => (string) ( $item['hs_code'] ?? '' ),
457 );
458 }
459
460 return array(
461 'currency' => (string) ( $item_info->shipment['currency'] ?? '' ),
462 'transaction_code' => '11',
463 'associated_document' => array(
464 'type' => 'invoice',
465 // Mirrors the legacy Customs InvoiceNr, which uses the order id (not the display order number).
466 'number' => (string) ( $item_info->shipment['order_id'] ?? '' ),
467 ),
468 'sender_identification' => (string) ( $item_info->shipment['merchant_code'] ?? '' ),
469 'content' => $content,
470 );
471 }
472
473 /**
474 * Build the configured SDK client for the current environment.
475 *
476 * @return PostnlClientInterface
477 */
478 private function build_client(): PostnlClientInterface {
479 return $this->client_factory->build( $this->v4_key, (bool) $this->settings->is_sandbox() );
480 }
481
482 /**
483 * Write the labelconfirm response labels to disk and normalize them.
484 *
485 * Handles single- and multi-collo shipments: each returned shipment item is
486 * one collo carrying its own barcode and label document(s). Every collo's
487 * labels are written and then merged through Order\Base::maybe_merge_labels()
488 * — which, for more than one collo, combines them into a single sheet keyed by
489 * the parent barcode, matching the legacy path — then handed to
490 * finalize_label_records() to restore the V4-only keys the merge drops. The
491 * parent barcode is the pre-issued one when the caller had it, and otherwise
492 * the one the response issued for the first collo.
493 *
494 * @param LabelConfirmResponseInterface $response labelconfirm response.
495 * @param \WC_Order $order WooCommerce order.
496 * @param array $fallbacks Pre-issued barcodes per collo; [0] is the parent/main barcode.
497 * @return array
498 * @throws \Exception When the response carries no shipment or no label content.
499 */
500 private function store_labels( LabelConfirmResponseInterface $response, $order, array $fallbacks ): array {
501 $items = Response_Mapper::all_shipment_items( $response );
502
503 if ( empty( $items ) ) {
504 throw new \Exception(
505 esc_html__( 'Cannot create the label. No shipment was returned by PostNL.', 'postnl-for-woocommerce' )
506 );
507 }
508
509 $this->warn_collo_count_mismatch( $fallbacks, count( $items ), $order );
510
511 $parent_barcode = (string) ( $fallbacks[0] ?? '' );
512 $records = array();
513 $partner_references = array();
514
515 foreach ( $items as $index => $item ) {
516 $fallback = (string) ( $fallbacks[ $index ] ?? $parent_barcode );
517 $item_barcode = Response_Mapper::get_barcode( $item, $fallback );
518
519 $partner_barcode = Response_Mapper::get_partner_barcode( $item );
520 $partner_id = Response_Mapper::get_partner_id( $item );
521
522 // One entry per collo that has partner data, so an international
523 // multi-collo order keeps every collo's partner tracking number, not
524 // just the parent's flat keys below.
525 if ( '' !== $partner_barcode || '' !== $partner_id ) {
526 $partner_references[] = array(
527 'barcode' => $item_barcode,
528 'partner_barcode' => $partner_barcode,
529 'partner_id' => $partner_id,
530 );
531 }
532
533 $records = array_merge(
534 $records,
535 $this->item_label_records( $item, $order, $item_barcode )
536 );
537 }
538
539 if ( empty( $records ) ) {
540 throw new \Exception(
541 esc_html__( 'Cannot create the label. Label content is missing', 'postnl-for-woocommerce' )
542 );
543 }
544
545 // Legacy pre-issues the parent barcode and it wins untouched; the harvest path
546 // supplies an empty one, and maybe_merge_labels() stamps whatever it is handed
547 // onto the record it rebuilds, so fall back to the barcode the response issued.
548 // Keying the merge on '' leaves the merged record barcode-less, which
549 // Order\Base::harvest_barcodes_or_fail() answers by deleting the labels just
550 // written and aborting the save.
551 $parent_barcode = self::resolve_parent_barcode( $records, $parent_barcode );
552
553 // The merge helper rebuilds a fresh record for a multi-collo shipment, so
554 // re-attach the international partner refs (all empty for a domestic
555 // shipment) — the single-collo record already carries the flat keys.
556 //
557 // The flat partner_barcode/partner_id keys hold the parent collo's refs,
558 // matching the single barcode the merged sheet is keyed on. Every collo's
559 // own refs are kept under partner_references, collected above — the merge
560 // collapses the per-collo records into one holding just
561 // type/barcode/created_at/filepath/merged_files, so anything not put back
562 // here never reaches order meta. No code reads partner_references yet; it
563 // is stored so a later per-collo partner Track & Trace feature has the data.
564 return $this->finalize_label_records(
565 $this->maybe_merge_labels( $records, $order, $parent_barcode, 'label' ),
566 Response_Mapper::get_partner_barcode( $items[0] ),
567 Response_Mapper::get_partner_id( $items[0] ),
568 $partner_references
569 );
570 }
571
572 /**
573 * Report a labelconfirm response that answered with a different number of colli
574 * than the caller pre-issued barcodes for.
575 *
576 * The mismatch is otherwise entirely silent, and it is silent on both sides of
577 * the boundary: this method stores one label per returned item, while
578 * Order\Base::save_meta_value() persists every prefetched barcode whatever the
579 * response said. A short response therefore leaves the merchant reading three
580 * tracking numbers off an order whose label sheet holds two parcels, with the
581 * third barcode never confirmed with PostNL; a long one writes and merges a label
582 * for a collo no persisted barcode covers. Neither raises an error anywhere.
583 *
584 * Storing what came back is still right — a partial sheet beats none, and the
585 * merchant can reprint — so this only reports; it does not abort.
586 *
587 * Only non-empty barcodes count. create() supplies array( '' ) on the harvest
588 * path, where nothing was pre-issued and the label call issues one barcode per
589 * collo, so a three-item response against that single placeholder is correct and
590 * must not be reported. Comparing the raw list length would warn on every
591 * multi-collo harvest and train merchants to ignore the log.
592 *
593 * The order number is the merchant's own reference and the counts carry no
594 * personal data, so the whole line is safe to write unconditionally.
595 *
596 * @param array $fallbacks Pre-issued barcodes per collo, as create() built them.
597 * @param int $item_count Number of shipment items the response returned.
598 * @param \WC_Order $order WooCommerce order the label belongs to.
599 * @return void
600 */
601 private function warn_collo_count_mismatch( array $fallbacks, int $item_count, $order ): void {
602 $expected = count(
603 array_filter(
604 $fallbacks,
605 static function ( $barcode ): bool {
606 return is_string( $barcode ) && '' !== $barcode;
607 }
608 )
609 );
610
611 if ( 0 === $expected || $expected === $item_count ) {
612 return;
613 }
614
615 $this->logger->warning(
616 sprintf(
617 'V4 label for order "%1$s": %2$d collo barcodes were pre-issued but the labelconfirm response returned %3$d shipment item(s); one label is stored per returned item, so the persisted barcodes and the label sheet do not line up.',
618 (string) $order->get_order_number(),
619 $expected,
620 $item_count
621 )
622 );
623 }
624
625 /**
626 * Write one collo's label document(s) to disk and return their meta records.
627 *
628 * @param \Postnl\Sdk\ResponseData\V4\ShipmentShippingItem $item Shipment item (one collo).
629 * @param \WC_Order $order WooCommerce order.
630 * @param string $item_barcode Barcode for this collo.
631 * @return array
632 */
633 private function item_label_records( $item, $order, string $item_barcode ): array {
634 $partner_barcode = Response_Mapper::get_partner_barcode( $item );
635 $partner_id = Response_Mapper::get_partner_id( $item );
636 $records = array();
637
638 foreach ( Response_Mapper::get_labels( $item ) as $label ) {
639 $content = Response_Mapper::decode_content( $label );
640
641 if ( '' === $content ) {
642 continue;
643 }
644
645 // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Third-party SDK DTO properties.
646 $output_type = null !== $label->outputType ? $label->outputType->value : 'pdf';
647 $label_type = ( null !== $label->labelType && '' !== $label->labelType )
648 ? sanitize_title( $label->labelType )
649 : 'label';
650 // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
651
652 $filename = Utils::generate_label_name( $order->get_id(), $label_type, $item_barcode, 'A6', $output_type );
653 $filepath = trailingslashit( POSTNL_UPLOADS_DIR ) . $filename;
654
655 $this->write_label_file( $filepath, $content );
656
657 // Only record a label that actually exists on disk, so a failed write
658 // never persists a meta entry pointing at a missing file.
659 if ( ! is_file( $filepath ) ) {
660 continue;
661 }
662
663 $records[] = Response_Mapper::to_label_record( $label_type, $item_barcode, $filepath, $partner_barcode, $partner_id );
664 }
665
666 return $records;
667 }
668
669 /**
670 * Restore the V4-only keys the merge drops from every label record.
671 *
672 * Order\Base::maybe_merge_labels() returns the mapper's record untouched only
673 * when there is exactly one label and the format is A6; on every other path it
674 * discards the record and builds a fresh one carrying just type, barcode,
675 * created_at, filepath and merged_files. A ROW response always takes that path,
676 * since it returns four documents, and a multi-collo shipment always does too,
677 * since every collo contributes at least one label — so the api_version tag and
678 * the partner references captured off the labelconfirm response have to be put
679 * back here or they never reach the order meta.
680 *
681 * Empty partner references add no keys at all, matching
682 * Response_Mapper::to_label_record(): a domestic record carries neither the
683 * flat keys nor a partner_references list.
684 *
685 * @param array $labels Merged label records, keyed by label type.
686 * @param string $partner_barcode Parent collo's partner barcode, or an empty string.
687 * @param string $partner_id Parent collo's partner id, or an empty string.
688 * @param array $partner_references Per-collo partner data: barcode, partner_barcode, partner_id per entry.
689 * @return array
690 */
691 private function finalize_label_records( array $labels, string $partner_barcode, string $partner_id, array $partner_references = array() ): array {
692 foreach ( array_keys( $labels ) as $key ) {
693 $labels[ $key ]['api_version'] = 'v4';
694
695 if ( '' !== $partner_barcode ) {
696 $labels[ $key ]['partner_barcode'] = $partner_barcode;
697 }
698
699 if ( '' !== $partner_id ) {
700 $labels[ $key ]['partner_id'] = $partner_id;
701 }
702
703 if ( array() !== $partner_references ) {
704 $labels[ $key ]['partner_references'] = $partner_references;
705 }
706 }
707
708 return $labels;
709 }
710
711 /**
712 * Write a decoded label document to disk, creating the uploads dir as needed.
713 *
714 * A pre-existing file is left untouched. Failure is intentionally silent —
715 * store_labels() verifies the file exists afterwards and skips the record if
716 * it does not.
717 *
718 * @param string $filepath Absolute destination path.
719 * @param string $content Raw (decoded) label bytes.
720 * @return void
721 */
722 private function write_label_file( string $filepath, string $content ): void {
723 if ( is_file( $filepath ) ) {
724 return;
725 }
726
727 if ( ! wp_mkdir_p( POSTNL_UPLOADS_DIR ) ) {
728 return;
729 }
730
731 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Binary label bytes; mirrors Order\Base::put_label_content().
732 file_put_contents( $filepath, $content );
733 }
734 }
735