| 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 |
|