# woo-postnl/5.9.12/src/Rest_API/V4/Label/Service.php

PostNL for WooCommerce, version 5.9.12. 735 lines.

- Page: https://pluginprobe.com/plugins/woo-postnl/5.9.12/code/src/Rest_API/V4/Label/Service.php
- Raw: https://pluginprobe.com/plugins/woo-postnl/5.9.12/raw/src/Rest_API/V4/Label/Service.php
- Modified: 2026-08-31T09:58:16+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/woo-postnl/5.9.12/code/src/Rest_API/V4/Label/Service.php#L10-L20`.

```php
<?php
/**
 * Class Rest_API\V4\Label\Service file.
 *
 * @package PostNLWooCommerce\Rest_API\V4\Label
 */

declare( strict_types = 1 );

namespace PostNLWooCommerce\Rest_API\V4\Label;

use Postnl\Sdk\Client\PostnlClientInterface;
use Postnl\Sdk\Enums\Payload\Bundle;
use Postnl\Sdk\Enums\Payload\Country;
use Postnl\Sdk\Enums\Payload\Currency;
use Postnl\Sdk\RequestData\V4\ShipmentDelivery\ShipmentDeliveryRequest;
use Postnl\Sdk\Service\ShipmentDelivery\V4\Response\LabelConfirmResponseInterface;
use PostNLWooCommerce\Order\Base as Order_Base;
use PostNLWooCommerce\Rest_API\Contracts\Label_Service_Interface;
use PostNLWooCommerce\Rest_API\SDK\Client_Factory;
use PostNLWooCommerce\Rest_API\SDK\Exception_Converter;
use PostNLWooCommerce\Rest_API\Shipping;
use PostNLWooCommerce\Utils;
use Psr\Log\LoggerInterface;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class Service
 *
 * V4 SDK implementation of the outbound shipping-label flow. It confirms a
 * label through the /shipment/delivery/v4/labelconfirm endpoint and stores the
 * result in the same _postnl_order_metadata['labels'] shape as the legacy path,
 * tagged api_version = v4.
 *
 * Scope: a parcel (single- or multi-collo) with any delivery Service the mapper
 * confirms a V4 equivalent for — insurance, signature/delivery-code
 * confirmation, stated-address-only, return-when-not-home and their
 * combinations — for domestic NL shipments, plus EU/ROW international parcels
 * (4907/4909) carrying an InternationalShipmentData bundle and customs
 * declaration. The domestic NL 24h letterbox (mailbox parcel 2928) also falls
 * out here as a ShipmentType::LetterBox variant. Everything else — pickup
 * (DeliveryLocation), the 48h letterbox (2948), packet/mailbox international
 * products, returns, delivery-day/evening selection — falls back to the
 * untouched legacy pipeline until those flows are migrated. Because both
 * gates (a validated V4 key and the per-flow flag) default off, merging this
 * changes nothing for merchants.
 *
 * Barcode note: the barcode is generated by the (still-legacy) barcode flow in
 * Order\Base::save_meta_value() and supplied on the request so the persisted
 * barcodes[] and the label's barcode stay in sync. The barcode returned by
 * labelconfirm is captured and used for the label record. When the barcode flow
 * itself migrates (task 14) the pre-issue step can be dropped and the
 * auto-issued barcode used directly.
 *
 * Extends Order\Base to reuse put/merge helpers and the legacy pipeline for the
 * fallback, mirroring Legacy\Label_Service.
 *
 * The PSR-3 logger is required, not optional, exactly as in V4\Timeframe\Service
 * and V4\Pickup_Location\Service: it is where a failed label call's real cause
 * survives (Exception_Converter hands the merchant a safe message and keeps the
 * SDK's own only as the previous exception). Legacy label generation writes every
 * request and response to the WooCommerce log via Rest_API\Base::send_request(),
 * so without this the V4 path would be the only label flow that fails with no
 * trail at all. Wiring passes a Logger_Adapter, so V4 entries land in the same
 * WooCommerce log as the legacy path and honour the same "enable logging"
 * setting.
 *
 * @since   6.0.0
 * @package PostNLWooCommerce\Rest_API\V4\Label
 */
class Service extends Order_Base implements Label_Service_Interface {

	/**
	 * SDK client factory.
	 *
	 * @var Client_Factory
	 */
	private $client_factory;

	/**
	 * PostNL V4 API key used to authenticate SDK requests.
	 *
	 * @var string
	 */
	private $v4_key;

	/**
	 * PSR-3 logger the failure path reports through.
	 *
	 * @var LoggerInterface
	 */
	private $logger;

	/**
	 * Service constructor.
	 *
	 * The API key and the logger are both required rather than defaulted, matching
	 * V4\Timeframe\Service and V4\Pickup_Location\Service: the key is resolved and
	 * validated by the caller that already decides whether V4 may run at all
	 * (Service_Factory::has_v4_key()), so re-deriving it here would duplicate that
	 * decision behind a fallback that silently sends an empty key; and a defaulted
	 * NullLogger would let a caller wire the service up with logging switched off —
	 * the exact gap this parameter closes.
	 *
	 * The key is marked SensitiveParameter so PHP redacts it from stack traces,
	 * matching Client_Factory::build().
	 *
	 * @since 6.0.0 Requires the resolved API key and a PSR-3 logger.
	 *
	 * @param Client_Factory  $client_factory SDK client factory.
	 * @param string          $v4_key         PostNL V4 API key.
	 * @param LoggerInterface $logger         PSR-3 logger; wiring passes a Logger_Adapter.
	 */
	public function __construct(
		Client_Factory $client_factory,
		#[\SensitiveParameter]
		string $v4_key,
		LoggerInterface $logger
	) {
		parent::__construct();
		$this->client_factory = $client_factory;
		$this->v4_key         = $v4_key;
		$this->logger         = $logger;
	}

	/**
	 * No WP hooks are registered by this service class.
	 *
	 * Required by Order\Base but intentionally a no-op here.
	 */
	public function init_hooks() {
		// Intentionally empty — this service does not register WordPress hooks.
	}

	/**
	 * Create a shipping label and return the normalized label record.
	 *
	 * Routes the happy-path domestic parcel to the V4 SDK; anything else runs
	 * the untouched legacy pipeline.
	 *
	 * @param array $post_data Context needed to build and send the label request.
	 *
	 * @return array Normalized label record keyed by label-type string.
	 *
	 * @throws \Exception If the SDK request fails (converted to the legacy error shape).
	 */
	public function create( array $post_data ): array {
		$item_info = new Shipping\Item_Info( $post_data );
		$signals   = $this->gather_signals( $item_info, $post_data );

		if ( ! Eligibility::is_eligible( $signals ) ) {
			return $this->create_label_pipeline( $post_data );
		}

		$fields   = $this->extract_fields( $item_info, $signals['mapped'], $post_data );
		$request  = Request_Builder::build( $fields );
		$response = $this->confirm_label( $request, $fields );

		$barcodes = ! empty( $fields['barcodes'] ) ? $fields['barcodes'] : array( (string) $fields['barcode'] );

		return $this->store_labels( $response, $post_data['order'], $barcodes );
	}

	/**
	 * Send the labelconfirm request, converting and logging any failure.
	 *
	 * @param ShipmentDeliveryRequest $request Built labelconfirm request.
	 * @param array                   $fields  Flattened field set the request was built from.
	 *
	 * @return LabelConfirmResponseInterface
	 *
	 * @throws \Exception Converted SDK error when the request fails.
	 */
	protected function confirm_label( ShipmentDeliveryRequest $request, array $fields ): LabelConfirmResponseInterface {
		try {
			return $this->build_client()->shipmentDelivery()->labelConfirm( $request );
		} catch ( \Throwable $exception ) {
			// Exception_Converter returns a plugin-shaped \Exception; its message can
			// carry raw API text (field errors, upstream messages) — escape on output.
			$error = Exception_Converter::convert( $exception );

			// The converted message is deliberately merchant-safe, and one of its
			// variants tells the reader to check these very logs, so the original SDK
			// failure has to be written here — nothing else reads getPrevious().
			//
			// The shipment reference is the merchant's own order number: it is what
			// makes the entry traceable back to a shipment, and it is store-internal
			// rather than customer-identifying, so it is kept where the address parts
			// are not.
			$this->logger->error(
				sprintf(
					'V4 label creation failed for order "%1$s" to "%2$s": %3$s (cause: %4$s: %5$s)',
					(string) ( $fields['reference'] ?? '' ),
					$this->describe_destination( $fields ),
					$error->getMessage(),
					get_class( $exception ),
					$exception->getMessage()
				)
			);

			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception_Converter returns an already-escaped, translated message.
			throw $error;
		}
	}

	/**
	 * Short, log-safe description of the destination a failed label was for.
	 *
	 * Mirrors V4\Timeframe\Service::describe_destination(): legacy
	 * Rest_API\Base::send_request() writes the entire request body — recipient name,
	 * street, house number, city, email and phone included — to the same WooCommerce
	 * log, whereas this keeps only the country and the postcode's leading area
	 * digits. An error line is written whether or not anyone is debugging, while the
	 * SDK's own request logging (which does carry the address, PII-redacted) is what
	 * a merchant turns on deliberately.
	 *
	 * @param array $fields Flattened field set.
	 *
	 * @return string e.g. 'NL 1234'; empty when the payload carried no address.
	 */
	private function describe_destination( array $fields ): string {
		$country  = (string) ( $fields['receiver']['country'] ?? '' );
		$postcode = str_replace( ' ', '', (string) ( $fields['receiver']['postcode'] ?? '' ) );

		return trim( $country . ' ' . substr( $postcode, 0, 4 ) );
	}

	/**
	 * Collect the eligibility signals for an order from the legacy parser.
	 *
	 * The selected backend features are read from the raw post data (string
	 * 'yes' values), not the parsed booleans on Item_Info, because
	 * Utils::get_selected_label_features() matches on 'yes'. Passing the real
	 * options into the mapper is what lets a service-bearing combination that
	 * keeps product 3085 (e.g. insured) resolve to a services row and be
	 * rejected below, rather than silently masquerading as the base parcel.
	 *
	 * @param Shipping\Item_Info $item_info Parsed legacy item info.
	 * @param array              $post_data Original label post data.
	 * @return array Signal set consumed by is_eligible().
	 */
	private function gather_signals( Shipping\Item_Info $item_info, array $post_data ): array {
		$backend_raw = $post_data['saved_data']['backend'] ?? array();

		$origin      = (string) ( $item_info->shipper['country'] ?? '' );
		$destination = Utils::get_shipping_zone(
			(string) ( $item_info->receiver['country'] ?? '' ),
			(string) ( $item_info->receiver['state'] ?? '' )
		);

		$has_return = ! empty( $item_info->shipment['return_barcode'] )
			|| ! empty( $item_info->shipment['shipping_return_barcode'] )
			|| ! empty( $item_info->shipment['return_options'] )
			|| 'yes' === ( $backend_raw['create_return_label'] ?? '' )
			|| 'yes' === ( $backend_raw['create_shipment_return_label'] ?? '' );

		return array(
			'num_labels'      => (int) ( $item_info->backend_data['num_labels'] ?? 1 ),
			'is_delivery_day' => $item_info->is_delivery_day(),
			'is_pickup'       => $item_info->is_pickup_points(),
			'has_return'      => $has_return,
			'delivery_type'   => (string) ( $item_info->backend_data['delivery_type'] ?? 'Standard' ),
			'origin'          => $origin,
			'destination'     => $destination,
			'mapped'          => Eligibility::resolve_mapped(
				$origin,
				$destination,
				$item_info->is_pickup_points(),
				$backend_raw,
				(string) $item_info->get_product_code()
			),
		);
	}

	/**
	 * Flatten the parsed item info into the field array Request_Builder consumes.
	 *
	 * @param Shipping\Item_Info $item_info Parsed legacy item info.
	 * @param array              $mapped    V4_Mapper::map() result.
	 * @param array              $post_data Original label post data.
	 * @return array
	 */
	private function extract_fields( Shipping\Item_Info $item_info, array $mapped, array $post_data ): array {
		$num_labels = (int) ( $item_info->backend_data['num_labels'] ?? 1 );

		// Legacy caps the collo count it puts on the wire through the num_labels
		// sanitizer (1-10), which the V1 request loop iterates; the barcode prefetch
		// in Order\Base::maybe_create_multi_barcodes() reads the raw backend value and
		// applies no cap, and the meta-box field has a min but no max. Slicing to the
		// parsed count keeps V4 shipping the same colli V1 would -- surplus barcodes
		// go unused, exactly as on V1.
		$barcodes = array_values( array_filter( (array) ( $post_data['barcodes'] ?? array() ), 'is_scalar' ) );
		$barcodes = array_slice( $barcodes, 0, $num_labels );

		return array(
			'sender'        => array(
				'company'          => $item_info->shipper['company'] ?? '',
				'street'           => $item_info->shipper['address_1'] ?? '',
				'house_number'     => $item_info->shipper['address_2'] ?? '',
				'house_number_ext' => '',
				'postcode'         => $item_info->shipper['postcode'] ?? '',
				'city'             => $item_info->shipper['city'] ?? '',
				'country'          => $item_info->shipper['country'] ?? '',
			),
			'receiver'      => array(
				'company'          => $item_info->receiver['company'] ?? '',
				'first_name'       => $item_info->receiver['first_name'] ?? '',
				'last_name'        => $item_info->receiver['last_name'] ?? '',
				'street'           => $item_info->receiver['address_1'] ?? '',
				'house_number'     => $item_info->receiver['house_number'] ?? '',
				'house_number_ext' => $item_info->receiver['address_2'] ?? '',
				'postcode'         => $item_info->receiver['postcode'] ?? '',
				'city'             => $item_info->receiver['city'] ?? '',
				'country'          => $item_info->receiver['country'] ?? '',
				'email'            => $item_info->shipment['email'] ?? '',
				'phone'            => $item_info->shipment['phone'] ?? '',
			),
			'shipment_type' => $mapped['shipmentType'] ?? 'parcel',
			'weight_gr'     => (int) ( $item_info->shipment['total_weight'] ?? 0 ),
			'reference'     => (string) ( $item_info->shipment['order_number'] ?? '' ),
			'barcode'       => (string) ( $post_data['main_barcode'] ?? '' ),
			'barcodes'      => $barcodes,
			// The collo count is carried even when barcodes are pre-issued: it is the
			// only record of it when the label call issues the barcodes instead, and
			// Request_Builder ignores it whenever a barcode is supplied.
			'num_labels'    => $num_labels,
			'services'      => Eligibility::resolve_services(
				$mapped['services'] ?? array(),
				(float) ( $item_info->shipment['subtotal'] ?? 0 )
			),
			'international' => $this->extract_international( $item_info, $mapped ),
			'label'         => Request_Builder::printer_type_to_label_settings(
				(string) ( $item_info->shipment['printer_type'] ?? '' )
			),
		);
	}

	/**
	 * Flatten the international shipment data (bundle + customs) for an EU/ROW order.
	 *
	 * Returns an empty array for a domestic shipment — the mapper only carries an
	 * internationalShipmentData hint (the service bundle) for EU/ROW parcels, so
	 * its presence is what distinguishes the two.
	 *
	 * @param Shipping\Item_Info $item_info Parsed legacy item info.
	 * @param array              $mapped    V4_Mapper::map() result.
	 * @return array
	 */
	private function extract_international( Shipping\Item_Info $item_info, array $mapped ): array {
		$international = $mapped['internationalShipmentData'] ?? array();

		if ( empty( $international ) ) {
			return array();
		}

		$fields = array(
			'bundle'  => (string) ( $international['bundle'] ?? '' ),
			'customs' => $this->extract_customs( $item_info ),
		);

		$this->warn_unmappable_international( $fields, (string) ( $item_info->shipment['order_number'] ?? '' ) );

		return $fields;
	}

	/**
	 * Report the international values Request_Builder will silently drop.
	 *
	 * The log lives here rather than in the builder because the builder is a pure
	 * static translator — a flat array in, a DTO out, no collaborators — so giving it
	 * a logger would mean threading one through every call site just to translate a
	 * field. This is the last point where the logger, the raw values and the order
	 * reference all exist together.
	 *
	 * Each check mirrors the builder's own normalization exactly: Bundle::tryFrom on
	 * the raw string (the builder folds no case there), Currency::tryFrom and
	 * Country::tryFrom on the upper-cased value. Anything looser would warn about
	 * values that reach PostNL perfectly well — a lowercase 'eur' — and train
	 * merchants to ignore the log. Empty values are omitted by the builder by design
	 * and are not reported.
	 *
	 * Without this, an enum miss is invisible: the SDK Currency enum carries only
	 * thirteen currencies, so a store selling in HUF sends a customs declaration with
	 * no currency, and a mistyped mapper bundle ships an uninsured parcel. The order
	 * number is the merchant's own reference, and currency/country codes carry no
	 * personal data, so the whole line is safe to write unconditionally.
	 *
	 * @param array  $fields          Flattened international field set.
	 * @param string $order_reference Merchant order number the shipment belongs to.
	 * @return void
	 */
	private function warn_unmappable_international( array $fields, string $order_reference ): void {
		$bundle = (string) ( $fields['bundle'] ?? '' );

		if ( '' !== $bundle && null === Bundle::tryFrom( $bundle ) ) {
			$this->logger->warning(
				sprintf(
					'V4 label for order "%1$s": unknown international service bundle "%2$s"; the shipment is sent without a bundle.',
					$order_reference,
					$bundle
				)
			);
		}

		$currency = (string) ( $fields['customs']['currency'] ?? '' );

		if ( '' !== $currency && null === Currency::tryFrom( strtoupper( $currency ) ) ) {
			$this->logger->warning(
				sprintf(
					'V4 label for order "%1$s": unsupported customs currency "%2$s"; the customs declaration is sent without a currency.',
					$order_reference,
					$currency
				)
			);
		}

		foreach ( (array) ( $fields['customs']['content'] ?? array() ) as $item ) {
			$origin = (string) ( $item['country_of_origin'] ?? '' );

			if ( '' !== $origin && null === Country::tryFrom( strtoupper( $origin ) ) ) {
				$this->logger->warning(
					sprintf(
						'V4 label for order "%1$s": unknown customs country of origin "%2$s"; that item is declared without a country of origin.',
						$order_reference,
						$origin
					)
				);
			}
		}
	}

	/**
	 * Build the customs declaration fields from the parsed order line items.
	 *
	 * Mirrors the legacy Customs block: transactionCode 11 with an invoice
	 * associatedDocument, the order currency, a trusted-shipper
	 * senderIdentification from the merchant code (empty for EU destinations),
	 * and one content entry per non-virtual line item.
	 *
	 * @param Shipping\Item_Info $item_info Parsed legacy item info.
	 * @return array
	 */
	private function extract_customs( Shipping\Item_Info $item_info ): array {
		$content = array();

		foreach ( (array) $item_info->contents as $item ) {
			$content[] = array(
				'description'       => (string) ( $item['description'] ?? '' ),
				'quantity'          => (int) ( $item['qty'] ?? 1 ),
				'weight'            => (int) ( $item['weight'] ?? 0 ),
				'value'             => (float) ( $item['value'] ?? 0 ),
				'country_of_origin' => (string) ( $item['origin'] ?? '' ),
				'hs_code'           => (string) ( $item['hs_code'] ?? '' ),
			);
		}

		return array(
			'currency'              => (string) ( $item_info->shipment['currency'] ?? '' ),
			'transaction_code'      => '11',
			'associated_document'   => array(
				'type'   => 'invoice',
				// Mirrors the legacy Customs InvoiceNr, which uses the order id (not the display order number).
				'number' => (string) ( $item_info->shipment['order_id'] ?? '' ),
			),
			'sender_identification' => (string) ( $item_info->shipment['merchant_code'] ?? '' ),
			'content'               => $content,
		);
	}

	/**
	 * Build the configured SDK client for the current environment.
	 *
	 * @return PostnlClientInterface
	 */
	private function build_client(): PostnlClientInterface {
		return $this->client_factory->build( $this->v4_key, (bool) $this->settings->is_sandbox() );
	}

	/**
	 * Write the labelconfirm response labels to disk and normalize them.
	 *
	 * Handles single- and multi-collo shipments: each returned shipment item is
	 * one collo carrying its own barcode and label document(s). Every collo's
	 * labels are written and then merged through Order\Base::maybe_merge_labels()
	 * — which, for more than one collo, combines them into a single sheet keyed by
	 * the parent barcode, matching the legacy path — then handed to
	 * finalize_label_records() to restore the V4-only keys the merge drops. The
	 * parent barcode is the pre-issued one when the caller had it, and otherwise
	 * the one the response issued for the first collo.
	 *
	 * @param LabelConfirmResponseInterface $response  labelconfirm response.
	 * @param \WC_Order                     $order     WooCommerce order.
	 * @param array                         $fallbacks Pre-issued barcodes per collo; [0] is the parent/main barcode.
	 * @return array
	 * @throws \Exception When the response carries no shipment or no label content.
	 */
	private function store_labels( LabelConfirmResponseInterface $response, $order, array $fallbacks ): array {
		$items = Response_Mapper::all_shipment_items( $response );

		if ( empty( $items ) ) {
			throw new \Exception(
				esc_html__( 'Cannot create the label. No shipment was returned by PostNL.', 'postnl-for-woocommerce' )
			);
		}

		$this->warn_collo_count_mismatch( $fallbacks, count( $items ), $order );

		$parent_barcode     = (string) ( $fallbacks[0] ?? '' );
		$records            = array();
		$partner_references = array();

		foreach ( $items as $index => $item ) {
			$fallback     = (string) ( $fallbacks[ $index ] ?? $parent_barcode );
			$item_barcode = Response_Mapper::get_barcode( $item, $fallback );

			$partner_barcode = Response_Mapper::get_partner_barcode( $item );
			$partner_id      = Response_Mapper::get_partner_id( $item );

			// One entry per collo that has partner data, so an international
			// multi-collo order keeps every collo's partner tracking number, not
			// just the parent's flat keys below.
			if ( '' !== $partner_barcode || '' !== $partner_id ) {
				$partner_references[] = array(
					'barcode'         => $item_barcode,
					'partner_barcode' => $partner_barcode,
					'partner_id'      => $partner_id,
				);
			}

			$records = array_merge(
				$records,
				$this->item_label_records( $item, $order, $item_barcode )
			);
		}

		if ( empty( $records ) ) {
			throw new \Exception(
				esc_html__( 'Cannot create the label. Label content is missing', 'postnl-for-woocommerce' )
			);
		}

		// Legacy pre-issues the parent barcode and it wins untouched; the harvest path
		// supplies an empty one, and maybe_merge_labels() stamps whatever it is handed
		// onto the record it rebuilds, so fall back to the barcode the response issued.
		// Keying the merge on '' leaves the merged record barcode-less, which
		// Order\Base::harvest_barcodes_or_fail() answers by deleting the labels just
		// written and aborting the save.
		$parent_barcode = self::resolve_parent_barcode( $records, $parent_barcode );

		// The merge helper rebuilds a fresh record for a multi-collo shipment, so
		// re-attach the international partner refs (all empty for a domestic
		// shipment) — the single-collo record already carries the flat keys.
		//
		// The flat partner_barcode/partner_id keys hold the parent collo's refs,
		// matching the single barcode the merged sheet is keyed on. Every collo's
		// own refs are kept under partner_references, collected above — the merge
		// collapses the per-collo records into one holding just
		// type/barcode/created_at/filepath/merged_files, so anything not put back
		// here never reaches order meta. No code reads partner_references yet; it
		// is stored so a later per-collo partner Track & Trace feature has the data.
		return $this->finalize_label_records(
			$this->maybe_merge_labels( $records, $order, $parent_barcode, 'label' ),
			Response_Mapper::get_partner_barcode( $items[0] ),
			Response_Mapper::get_partner_id( $items[0] ),
			$partner_references
		);
	}

	/**
	 * Report a labelconfirm response that answered with a different number of colli
	 * than the caller pre-issued barcodes for.
	 *
	 * The mismatch is otherwise entirely silent, and it is silent on both sides of
	 * the boundary: this method stores one label per returned item, while
	 * Order\Base::save_meta_value() persists every prefetched barcode whatever the
	 * response said. A short response therefore leaves the merchant reading three
	 * tracking numbers off an order whose label sheet holds two parcels, with the
	 * third barcode never confirmed with PostNL; a long one writes and merges a label
	 * for a collo no persisted barcode covers. Neither raises an error anywhere.
	 *
	 * Storing what came back is still right — a partial sheet beats none, and the
	 * merchant can reprint — so this only reports; it does not abort.
	 *
	 * Only non-empty barcodes count. create() supplies array( '' ) on the harvest
	 * path, where nothing was pre-issued and the label call issues one barcode per
	 * collo, so a three-item response against that single placeholder is correct and
	 * must not be reported. Comparing the raw list length would warn on every
	 * multi-collo harvest and train merchants to ignore the log.
	 *
	 * The order number is the merchant's own reference and the counts carry no
	 * personal data, so the whole line is safe to write unconditionally.
	 *
	 * @param array     $fallbacks  Pre-issued barcodes per collo, as create() built them.
	 * @param int       $item_count Number of shipment items the response returned.
	 * @param \WC_Order $order      WooCommerce order the label belongs to.
	 * @return void
	 */
	private function warn_collo_count_mismatch( array $fallbacks, int $item_count, $order ): void {
		$expected = count(
			array_filter(
				$fallbacks,
				static function ( $barcode ): bool {
					return is_string( $barcode ) && '' !== $barcode;
				}
			)
		);

		if ( 0 === $expected || $expected === $item_count ) {
			return;
		}

		$this->logger->warning(
			sprintf(
				'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.',
				(string) $order->get_order_number(),
				$expected,
				$item_count
			)
		);
	}

	/**
	 * Write one collo's label document(s) to disk and return their meta records.
	 *
	 * @param \Postnl\Sdk\ResponseData\V4\ShipmentShippingItem $item        Shipment item (one collo).
	 * @param \WC_Order                                        $order       WooCommerce order.
	 * @param string                                           $item_barcode Barcode for this collo.
	 * @return array
	 */
	private function item_label_records( $item, $order, string $item_barcode ): array {
		$partner_barcode = Response_Mapper::get_partner_barcode( $item );
		$partner_id      = Response_Mapper::get_partner_id( $item );
		$records         = array();

		foreach ( Response_Mapper::get_labels( $item ) as $label ) {
			$content = Response_Mapper::decode_content( $label );

			if ( '' === $content ) {
				continue;
			}

			// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Third-party SDK DTO properties.
			$output_type = null !== $label->outputType ? $label->outputType->value : 'pdf';
			$label_type  = ( null !== $label->labelType && '' !== $label->labelType )
				? sanitize_title( $label->labelType )
				: 'label';
			// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase

			$filename = Utils::generate_label_name( $order->get_id(), $label_type, $item_barcode, 'A6', $output_type );
			$filepath = trailingslashit( POSTNL_UPLOADS_DIR ) . $filename;

			$this->write_label_file( $filepath, $content );

			// Only record a label that actually exists on disk, so a failed write
			// never persists a meta entry pointing at a missing file.
			if ( ! is_file( $filepath ) ) {
				continue;
			}

			$records[] = Response_Mapper::to_label_record( $label_type, $item_barcode, $filepath, $partner_barcode, $partner_id );
		}

		return $records;
	}

	/**
	 * Restore the V4-only keys the merge drops from every label record.
	 *
	 * Order\Base::maybe_merge_labels() returns the mapper's record untouched only
	 * when there is exactly one label and the format is A6; on every other path it
	 * discards the record and builds a fresh one carrying just type, barcode,
	 * created_at, filepath and merged_files. A ROW response always takes that path,
	 * since it returns four documents, and a multi-collo shipment always does too,
	 * since every collo contributes at least one label — so the api_version tag and
	 * the partner references captured off the labelconfirm response have to be put
	 * back here or they never reach the order meta.
	 *
	 * Empty partner references add no keys at all, matching
	 * Response_Mapper::to_label_record(): a domestic record carries neither the
	 * flat keys nor a partner_references list.
	 *
	 * @param array  $labels             Merged label records, keyed by label type.
	 * @param string $partner_barcode    Parent collo's partner barcode, or an empty string.
	 * @param string $partner_id         Parent collo's partner id, or an empty string.
	 * @param array  $partner_references Per-collo partner data: barcode, partner_barcode, partner_id per entry.
	 * @return array
	 */
	private function finalize_label_records( array $labels, string $partner_barcode, string $partner_id, array $partner_references = array() ): array {
		foreach ( array_keys( $labels ) as $key ) {
			$labels[ $key ]['api_version'] = 'v4';

			if ( '' !== $partner_barcode ) {
				$labels[ $key ]['partner_barcode'] = $partner_barcode;
			}

			if ( '' !== $partner_id ) {
				$labels[ $key ]['partner_id'] = $partner_id;
			}

			if ( array() !== $partner_references ) {
				$labels[ $key ]['partner_references'] = $partner_references;
			}
		}

		return $labels;
	}

	/**
	 * Write a decoded label document to disk, creating the uploads dir as needed.
	 *
	 * A pre-existing file is left untouched. Failure is intentionally silent —
	 * store_labels() verifies the file exists afterwards and skips the record if
	 * it does not.
	 *
	 * @param string $filepath Absolute destination path.
	 * @param string $content  Raw (decoded) label bytes.
	 * @return void
	 */
	private function write_label_file( string $filepath, string $content ): void {
		if ( is_file( $filepath ) ) {
			return;
		}

		if ( ! wp_mkdir_p( POSTNL_UPLOADS_DIR ) ) {
			return;
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Binary label bytes; mirrors Order\Base::put_label_content().
		file_put_contents( $filepath, $content );
	}
}

```
