settings = Settings::get_instance(); $this->meta_name = '_' . $this->prefix . 'order_metadata'; $this->is_return_activated_meta = '_' . $this->prefix . 'return_activated'; $this->init_hooks(); } /** * Abstract function for collection of hooks when initiation. */ abstract public function init_hooks(); /** * Return a lazy-initialised Service_Factory instance. * * @return Service_Factory */ protected function service_factory(): Service_Factory { if ( null === $this->service_factory_instance ) { $this->service_factory_instance = new Service_Factory( $this->settings ); } return $this->service_factory_instance; } /** * Get nonce field. * * @return array */ public function get_nonce_fields() { // Resolve from the 'save' field set: the nonce is a structural field the save/verify // callers always need, so it must never be subject to the bulk-modal display trim. return array_filter( $this->meta_box_fields( false, 'save' ), function ( $field ) { return ( ! empty( $field['nonce'] ) && true === $field['nonce'] ); } ); } /** * Get shipping options from the PostNL meta, if those no-exists then form the plugin settings. * * @param \WC_Order $order * * @return array * * @internal */ public function get_shipping_options( $order ) { if ( ! is_a( $order, 'WC_Order' ) ) { return array(); } $default_options = $this->resolve_default_shipping_options( $order ); // The default options only carry the generic 'letterbox' feature; surface the // resolved 24h/48h variant so the matching option is pre-selected and a re-save // preserves the existing choice instead of silently reverting it to 24h. return $this->apply_letterbox_display_variant( $default_options, $order ); } /** * Swap the generic 'letterbox' feature for the resolved 24h/48h display variant. * * The stored backend selection and the plugin defaults only ever carry the * generic 'letterbox' feature; the concrete 24h/48h variant is recorded * separately in the _postnl_letterbox_type meta. Surfacing the resolved variant * keeps the two mutually-exclusive admin checkboxes in step with the label * engine so exactly one of them is pre-selected, instead of the generic 24h * feature re-checking the 24h box on top of the pre-selected 48h box. * * @since 5.9.8 * * @param array $options Option map ( feature => 'yes' ). * @param \WC_Order $order Order object. * * @return array */ protected function apply_letterbox_display_variant( $options, $order ) { if ( ! is_array( $options ) ) { return $options; } if ( 'yes' === ( $options['letterbox'] ?? '' ) && 'letterbox_48' === $this->resolve_letterbox_variant( $order ) ) { unset( $options['letterbox'] ); $options['letterbox_48'] = 'yes'; } return $options; } /** * Resolve the pre-selected shipping options from the order meta or the plugin settings. * * @since 5.9.8 * * @param \WC_Order $order Order object. * * @return array */ protected function resolve_default_shipping_options( $order ) { // Return shipping options already selected by the user. $default_options = $this->get_backend_data( $order->get_id() ); if ( ! empty( $default_options ) ) { return $default_options; } if ( Utils::is_adults_only_order( $order ) ) { return array( 'id_check' => 'yes' ); } // Get from the plugin settings. $shipping_zone = Utils::get_shipping_zone( $order->get_shipping_country(), $order->get_shipping_state() ); $frontend_data = $this->get_frontend_data( $order->get_id() ); if ( ! empty( $frontend_data['dropoff_points'] ) ) { $shipping_zone = 'PICKUP'; } if ( 'NL' === $shipping_zone && Utils::is_order_eligible_auto_letterbox( $order ) ) { return array( 'letterbox' => 'yes' ); } return $this->settings->get_default_shipping_options( $shipping_zone ); } /** * Resolve the letterbox variant for display, mirroring the label engine. * * Kept in sync with Rest_API\Shipping\Item_Info::get_letterbox_type(): * the recorded choice on the order wins, otherwise the merchant default * setting, otherwise the 24h variant. * * @since 5.9.8 * * @param \WC_Order $order Order object. * * @return string 'letterbox' (24h) or 'letterbox_48' (48h). */ protected function resolve_letterbox_variant( $order ) { $stored_type = $order->get_meta( '_postnl_letterbox_type' ); if ( in_array( $stored_type, array( 'letterbox', 'letterbox_48' ), true ) ) { return $stored_type; } return ( 'letterbox_48' === $this->settings->get_default_automatic_letterboxparcel_product() ) ? 'letterbox_48' : 'letterbox'; } /** * List of meta box fields. * * @param \WC_Order $order WooCommerce order ID. * @param string $context 'display' (default) when rendering the admin UI, 'save' when persisting. */ public function meta_box_fields( $order = false, $context = 'display' ) { $default_options = $this->get_shipping_options( $order ); $fields = array( array( 'id' => $this->prefix . 'id_check', 'type' => 'checkbox', 'label' => __( 'ID Check (18+): ', 'postnl-for-woocommerce' ), 'placeholder' => '', 'description' => '', 'value' => $default_options['id_check'] ?? '', 'show_in_bulk' => false, 'standard_feat' => false, 'const_field' => false, 'container' => true, ), array( 'id' => $this->prefix . 'insured_shipping', 'type' => 'checkbox', 'label' => __( 'Insured Shipping: ', 'postnl-for-woocommerce' ), 'placeholder' => '', 'description' => '', 'value' => $default_options['insured_shipping'] ?? '', 'show_in_bulk' => true, 'standard_feat' => false, 'const_field' => false, 'container' => true, ), array( 'id' => $this->prefix . 'insured_plus', 'type' => 'checkbox', 'label' => __( 'Insured Plus: ', 'postnl-for-woocommerce' ), 'placeholder' => '', 'description' => '', 'value' => '', 'show_in_bulk' => true, 'standard_feat' => false, 'const_field' => false, 'container' => true, ), array( 'id' => $this->prefix . 'return_no_answer', 'type' => 'checkbox', 'label' => __( 'Return if no answer: ', 'postnl-for-woocommerce' ), 'placeholder' => '', 'description' => '', 'value' => $default_options['return_no_answer'] ?? '', 'show_in_bulk' => true, 'standard_feat' => false, 'const_field' => false, 'container' => true, ), array( 'id' => $this->prefix . 'signature_on_delivery', 'type' => 'checkbox', 'label' => __( 'Signature on Delivery: ', 'postnl-for-woocommerce' ), 'placeholder' => '', 'description' => '', 'value' => $default_options['signature_on_delivery'] ?? '', 'show_in_bulk' => true, 'standard_feat' => false, 'const_field' => false, 'container' => true, ), array( 'id' => $this->prefix . 'only_home_address', 'type' => 'checkbox', 'label' => __( 'Only Home Address: ', 'postnl-for-woocommerce' ), 'placeholder' => '', 'description' => '', 'value' => $default_options['only_home_address'] ?? '', 'show_in_bulk' => true, 'standard_feat' => false, 'const_field' => false, 'container' => true, ), array( 'id' => $this->prefix . 'letterbox', 'type' => 'checkbox', 'label' => __( 'Letterboxparcel Standard (24 hours)', 'postnl-for-woocommerce' ) . ': ', 'placeholder' => '', 'description' => '', 'value' => $default_options['letterbox'] ?? '', 'show_in_bulk' => true, 'standard_feat' => false, 'const_field' => false, 'container' => true, ), array( 'id' => $this->prefix . 'letterbox_48', 'type' => 'checkbox', 'label' => __( 'Letterboxparcel 48 hours', 'postnl-for-woocommerce' ) . ': ', 'placeholder' => '', 'description' => '', 'value' => $default_options['letterbox_48'] ?? '', 'show_in_bulk' => false, 'standard_feat' => false, 'const_field' => false, 'container' => true, ), array( 'id' => $this->prefix . 'packets', 'type' => 'checkbox', 'label' => __( 'Packets: ', 'postnl-for-woocommerce' ), 'placeholder' => '', 'description' => '', 'value' => '', 'show_in_bulk' => true, 'standard_feat' => false, 'const_field' => false, 'container' => true, ), array( 'id' => $this->prefix . 'mailboxpacket', 'type' => 'checkbox', 'label' => __( 'Mailbox Packet (International): ', 'postnl-for-woocommerce' ), 'placeholder' => '', 'description' => '', 'value' => '', 'show_in_bulk' => true, 'standard_feat' => false, 'const_field' => false, 'container' => true, ), array( 'id' => $this->prefix . 'track_and_trace', 'type' => 'checkbox', 'label' => __( 'Track & Trace: ', 'postnl-for-woocommerce' ), 'placeholder' => '', 'description' => '', 'value' => '', 'show_in_bulk' => true, 'standard_feat' => false, 'const_field' => false, 'container' => true, ), array( 'id' => $this->prefix . 'delivery_code_at_door', 'type' => 'checkbox', 'label' => esc_html__( 'Delivery code at the door: ', 'postnl-for-woocommerce' ), 'placeholder' => '', 'description' => '', 'value' => $default_options['delivery_code_at_door'] ?? '', 'show_in_bulk' => true, 'standard_feat' => false, 'const_field' => false, 'container' => true, ), array( 'id' => $this->prefix . 'break_2', 'standard_feat' => false, 'const_field' => true, 'type' => 'break', ), array( 'id' => $this->prefix . 'num_labels', 'type' => 'number', 'label' => __( 'Number of Labels: ', 'postnl-for-woocommerce' ), 'placeholder' => '', 'description' => '', 'class' => 'short', 'value' => '', 'custom_attributes' => array( 'step' => 'any', 'min' => '0', ), 'show_in_bulk' => true, 'standard_feat' => true, 'const_field' => false, 'container' => true, ), array( 'id' => $this->prefix . 'label_nonce', 'type' => 'hidden', 'nonce' => true, 'value' => wp_create_nonce( $this->nonce_key ), 'show_in_bulk' => true, 'standard_feat' => false, 'const_field' => true, 'container' => true, ), ); if ( 'in_box' === $this->settings->get_return_shipment_and_labels() ) { $fields[] = array( 'id' => $this->prefix . 'create_return_label', 'type' => 'checkbox', 'label' => __( 'Create Return Label: ', 'postnl-for-woocommerce' ), 'placeholder' => '', 'description' => '', 'value' => 'yes', 'show_in_bulk' => true, 'standard_feat' => true, 'const_field' => false, 'container' => true, ); } if ( 'A6' !== $this->settings->get_label_format() ) { $fields[] = array( 'id' => $this->prefix . 'position_printing_labels', 'type' => 'select', 'label' => __( 'Start position printing label: ', 'postnl-for-woocommerce' ), 'placeholder' => '', 'description' => '', 'options' => array( 'top-left' => __( 'Top Left', 'postnl-for-woocommerce' ), 'top-right' => __( 'Top Right', 'postnl-for-woocommerce' ), 'bottom-left' => __( 'Bottom Left', 'postnl-for-woocommerce' ), 'bottom-right' => __( 'Bottom Right', 'postnl-for-woocommerce' ), ), 'value' => '', 'show_in_bulk' => true, 'standard_feat' => false, 'const_field' => true, 'container' => true, ); } return apply_filters( 'postnl_order_meta_box_fields', $fields, $context ); } /** * Get available option based on the countries and chosen option in the frontend checkout. * * @param WC_Order $order Order object. * * @return array. */ public function get_available_options( $order ) { if ( ! is_a( $order, 'WC_Order' ) ) { return array(); } $product_map = Mapping::products_data(); $from_country = Utils::get_base_country(); $to_country = Utils::get_shipping_zone( $order->get_shipping_country(), $order->get_shipping_state() ); $saved_data = $this->get_data( $order->get_id() ); if ( empty( $saved_data['frontend'] ) ) { $saved_data['frontend'] = array(); } $selected_option = 'delivery_day'; $available_options = array(); foreach ( $saved_data['frontend'] as $key => $value ) { $converted_key = Utils::convert_data_key( $key ); if ( ! empty( $product_map[ $from_country ][ $to_country ][ $converted_key ] ) ) { $selected_option = $converted_key; break; } } foreach ( $product_map[ $from_country ][ $to_country ][ $selected_option ] as $product ) { $available_options = array_merge( $available_options, $product['combination'] ); } return $available_options; } /** * Get saved data from Order object. * * @param int $order_id ID of the order. * * @return array. */ public function get_data( $order_id ) { $order = wc_get_order( $order_id ); if ( ! is_a( $order, 'WC_Order' ) ) { return array(); } $data = $order->get_meta( $this->meta_name ); return ! empty( $data ) && is_array( $data ) ? $data : array(); } /** * Init order object for meta box. * * @param WP_POST|WC_Order $metabox_object Either WP_Post or WC_Order object. */ public function init_order_object( $metabox_object ) { if ( is_a( $metabox_object, 'WP_Post' ) ) { return wc_get_order( $metabox_object->ID ); } if ( is_a( $metabox_object, 'WC_Order' ) ) { return $metabox_object; } return false; } /** * Check if the current order is using PostNL shipping method. * * @param WC_Order $order Order object. */ public function is_postnl_shipping_method( $order ) { if ( ! is_a( $order, 'WC_Order' ) ) { return false; } $shipping_methods = $order->get_shipping_methods(); if ( empty( $shipping_methods ) ) { return false; } foreach ( $shipping_methods as $shipping_item ) { if ( in_array( $shipping_item->get_method_id(), $this->settings->get_supported_shipping_methods() ) ) { return true; } } return false; } /** * Saving meta box in order admin page. * * @param int $order_id Order post ID. * @param array $meta_values PostNL meta values. * * @throws \Exception When the order is invalid, or the V4 label response carries no barcode. */ public function save_meta_value( $order_id, $meta_values ) { $order = wc_get_order( $order_id ); if ( ! is_a( $order, 'WC_Order' ) ) { throw new \Exception( esc_html__( 'Order does not exist!', 'postnl-for-woocommerce' ) ); } $saved_data = $this->get_data( $order_id ); // Check if label is already created. if ( isset( $saved_data['labels']['label'] ) ) { return array( 'saved_data' => $saved_data, 'labels' => $saved_data['labels'], ); } // Get array of nonce fields. $nonce_fields = array_values( $this->get_nonce_fields() ); // Loop through inputs within id 'shipment-postnl-label-form'. // Use the 'save' context so the bulk-modal display trim cannot drop a persisted // field (e.g. Letterbox 48 / ID Check) when generating a label from the legacy // orders list bulk action. foreach ( $this->meta_box_fields( $order_id, 'save' ) as $field ) { // Don't save nonce field. if ( $nonce_fields[0]['id'] === $field['id'] ) { continue; } $post_value = ! empty( $meta_values[ $field['id'] ] ) ? sanitize_text_field( wp_unslash( $meta_values[ $field['id'] ] ) ) : ''; $post_field = Utils::remove_prefix_field( $this->prefix, $field['id'] ); $saved_data['backend'][ $post_field ] = $post_value; } // Collapse an explicit 24h/48h letterbox choice onto the generic 'letterbox' // feature and record the variant as the authoritative merchant choice, which // Item_Info::get_letterbox_type() reads to pick product 2928 vs 2948. The meta // must be persisted here: Item_Info (constructed below) re-reads the order via // wc_get_order(), which returns a fresh instance that would not see an unsaved value. $letterbox_selection = Utils::normalize_letterbox_options( $saved_data['backend'] ); $saved_data['backend'] = $letterbox_selection['options']; if ( '' !== $letterbox_selection['type'] ) { $order->update_meta_data( '_postnl_letterbox_type', $letterbox_selection['type'] ); $order->save_meta_data(); } $label_post_data = array( 'order' => $order, 'saved_data' => $saved_data, ); if ( $this->service_factory()->barcode_from_label() ) { // V4: the label call issues the barcode(s); there is no standalone barcode // request. Generate the label first, then harvest the barcode(s) out of the // label response into the same barcodes[] shape the prefetch path produces. $label_post_data['is_return_activated'] = $this->is_return_function_activated( $order ); $labels = $this->create_label( $label_post_data ); $barcodes = $this->harvest_barcodes_or_fail( $labels ); } else { $barcodes = $this->maybe_create_multi_barcodes( $label_post_data ); $label_post_data['main_barcode'] = $barcodes[0]; // for MainBarcode. $label_post_data['barcodes'] = $barcodes; // Need to be refactored. $shipping_item_info = new Shipping\Item_Info( $label_post_data ); $label_post_data['return_barcode'] = $this->maybe_create_return_barcode( $label_post_data, $shipping_item_info ); $label_post_data['shipping_return_barcode'] = $this->maybe_create_shipping_return_barcode( $label_post_data, $shipping_item_info ); $label_post_data['is_return_activated'] = $this->is_return_function_activated( $order ); $labels = $this->create_label( $label_post_data ); } /* Temporarily commented. $return_post_data = $label_post_data; $return_post_data['barcode'] = $this->create_barcode( $order ); $return_labels = $this->maybe_create_return_label( $label_post_data ); $saved_data['labels'] = array_merge( $labels, $return_labels ); */ $saved_data['barcodes'] = array_map( function ( $barc ) { return array( 'value' => $barc, 'created_at' => current_time( 'timestamp' ), ); }, $barcodes ); $saved_data['labels'] = $labels; if ( $this->settings->is_auto_complete_order_enabled() ) { // Updating the order status to completed. $order->update_status( 'completed' ); } $order->update_meta_data( $this->meta_name, $saved_data ); $order->save(); // Need to add labels in array to remove the merged files later. return array( 'saved_data' => $saved_data, 'labels' => $labels, ); } /** * Get frontend data from Order object. * * @param int $order_id ID of the order. * * @return array. */ public function get_frontend_data( $order_id ) { $saved_data = $this->get_data( $order_id ); return ! empty( $saved_data['frontend'] ) ? $saved_data['frontend'] : array(); } /** * Get backend data from Order object. * * @param int $order_id ID of the order. * * @return array. */ public function get_backend_data( $order_id ) { $saved_data = $this->get_data( $order_id ); return ! empty( $saved_data['backend'] ) ? $saved_data['backend'] : array(); } /** * Get order information from frontend data. * * @param WC_Order $order Order object. * @param String $needle String that will be used to search the frontend value. * * @return array. */ public function get_order_frontend_info( $order, $needle ) { if ( ! is_a( $order, 'WC_Order' ) ) { return array(); } $order_data = $order->get_meta( $this->meta_name ); if ( ! empty( $order_data['frontend'] ) ) { $info_value = array(); foreach ( $order_data['frontend'] as $key => $value ) { if ( false !== strpos( $key, $needle ) ) { $info_value[ $key ] = $value; } } return $info_value; } return array(); } /** * Get delivery type string. * * @param WC_Order $order Order object. * * @return String. */ public function get_delivery_type( $order ) { // A letterbox order has no delivery-day or pickup type, so the frontend // mapping below would fall through to the generic "Standard Shipment" // label. Surface the resolved 24h/48h variant instead, mirroring the // selected shipping option so the summary matches the checkbox. $shipping_options = $this->get_shipping_options( $order ); if ( 'yes' === ( $shipping_options['letterbox_48'] ?? '' ) ) { return Utils::get_letterbox_label_48h(); } if ( 'yes' === ( $shipping_options['letterbox'] ?? '' ) ) { return Utils::get_letterbox_label_24h(); } $from_country = Utils::get_base_country(); $to_country = $order->get_shipping_country(); $to_state = $order->get_shipping_state(); $delivery_type_map = Mapping::delivery_type(); $filtered_frontend = $this->get_order_frontend_info( $order, '_type' ); $destination = Utils::get_shipping_zone( $to_country, $to_state ); if ( ! is_array( $delivery_type_map[ $from_country ][ $destination ] ) ) { return ! empty( $delivery_type_map[ $from_country ][ $destination ] ) ? $delivery_type_map[ $from_country ][ $destination ] : ''; } if ( empty( $filtered_frontend ) ) { return ''; } foreach ( $filtered_frontend as $frontend_key => $frontend_value ) { if ( ! empty( $delivery_type_map[ $from_country ][ $destination ][ $frontend_key ][ $frontend_value ] ) ) { return $delivery_type_map[ $from_country ][ $destination ][ $frontend_key ][ $frontend_value ]; } } return ''; } /** * Delete meta data in order admin page. * * @param int $order_id Order post ID. * * @throws \Exception Throw error for invalid order. */ public function delete_meta_value( $order_id ) { $order = wc_get_order( $order_id ); if ( ! is_a( $order, 'WC_Order' ) ) { throw new \Exception( esc_html__( 'Order does not exist!', 'postnl-for-woocommerce' ) ); } $saved_data = $this->get_data( $order_id ); // Delete label file. $this->delete_label( $saved_data ); unset( $saved_data['backend'] ); unset( $saved_data['labels'] ); unset( $saved_data['barcodes'] ); $order->update_meta_data( $this->meta_name, $saved_data ); $order->save(); return $saved_data; } /** * Put the label content into PDF files. * * @param array $response Response from PostNL API. * @param WC_Order $order Order object. * @param String $parent_barcode Generated barcode string. * @param String $parent_label_type Type of label. * * @return array */ public function put_label_content( $response, $order, $parent_barcode, $parent_label_type ) { $message_types = Utils::get_label_response_type(); $labels = array(); foreach ( $message_types as $type => $content_type ) { if ( empty( $response[ $type ] ) ) { continue; } foreach ( $response[ $type ] as $shipment_idx => $shipment_contents ) { if ( empty( $shipment_contents['Labels'] ) ) { continue 2; } foreach ( $shipment_contents['Labels'] as $label_idx => $label_contents ) { if ( empty( $label_contents['Content'] ) ) { continue 3; } $label_type = ! empty( $label_contents['Labeltype'] ) ? sanitize_title( $label_contents['Labeltype'] ) : 'unknown-type'; $label_extension = ! empty( $label_contents['OutputType'] ) ? sanitize_title( $label_contents['OutputType'] ) : 'pdf'; $barcode = $response[ $type ][ $shipment_idx ][ $content_type['barcode_key'] ]; $barcode = is_array( $barcode ) ? array_shift( $barcode ) : $barcode; $filename = Utils::generate_label_name( $order->get_id(), $label_type, $barcode, 'A6', $label_extension ); $filepath = trailingslashit( POSTNL_UPLOADS_DIR ) . $filename; if ( wp_mkdir_p( POSTNL_UPLOADS_DIR ) && ! file_exists( $filepath ) ) { $content = base64_decode( $label_contents['Content'] ); $file_ret = file_put_contents( $filepath, $content ); } $labels[] = array( 'type' => $label_type, 'barcode' => $barcode, 'created_at' => current_time( 'timestamp' ), 'filepath' => $filepath, ); } } } // Legacy passes the prefetched barcode through untouched; V4 has none, so fall // back to the barcode the response already carried into the raw records. $parent_barcode = self::resolve_parent_barcode( $labels, (string) $parent_barcode ); // if ( 'PDF' === $label_contents['OutputType'] ) { $labels = $this->maybe_merge_labels( $labels, $order, $parent_barcode, $parent_label_type ); // } return $labels; } /** * Create PostNL barcode for current order * * @param array $label_post_data . * * @return array * * @throws \Exception Error when response does not have Barcode value. */ public function create_barcode( $label_post_data ) { $data = array( 'order' => $label_post_data['order'], 'saved_data' => $label_post_data['saved_data'], ); $response = $this->service_factory()->barcode_service()->generate( $data ); if ( empty( $response['Barcode'] ) ) { throw new \Exception( esc_html__( 'Cannot create the barcode.', 'postnl-for-woocommerce' ) ); } return $response['Barcode']; } /** * Get multi barcodes from cacne or create new barcodes for current order. * * @param array $post_data Order post data. * * @return array * * @throws \Exception Error when response has an error. */ public function maybe_create_multi_barcodes( $post_data ) { // Minimum number of labels is 1 so it will create at least 1 barcode. $num_labels = 1; $barcodes = array(); $saved_data = $post_data['saved_data']; if ( isset( $saved_data['backend']['num_labels'] ) && 1 < intval( $saved_data['backend']['num_labels'] ) ) { $num_labels = intval( $saved_data['backend']['num_labels'] ); } for ( $i = 0; $i < $num_labels; $i++ ) { // Check if barcode has been created on the last 7 days before creating a new one. if ( ! empty( $saved_data['barcodes'][ $i ]['created_at'] ) && ! empty( $saved_data['barcodes'][ $i ]['value'] ) ) { $time_deviation = current_time( 'timestamp' ) - intval( $saved_data['barcodes'][ $i ]['created_at'] ); if ( $time_deviation <= 7 * DAY_IN_SECONDS ) { $barcodes[ $i ] = $saved_data['barcodes'][ $i ]['value']; continue; } } $barcodes[ $i ] = $this->create_barcode( $post_data ); } return $barcodes; } /** * Harvest the barcode(s) out of a label-generation response. * * On V4 the label call issues the barcode, so it arrives under the same 'barcode' * key put_label_content() already writes. Returned in the indexed shape * maybe_create_multi_barcodes() produces. * * Multi-collo parity is deferred to the V4 multi-collo label work: the merge * collapses N collo into one record, so this returns a single barcode where the * Legacy prefetch records N. * * @param mixed $labels Label records returned by create_label(). * * @return array List of barcode strings. * * @since 6.0.0 */ protected static function get_barcodes_from_labels( $labels ) { $barcodes = array(); if ( ! is_array( $labels ) ) { return $barcodes; } foreach ( $labels as $label ) { if ( ! empty( $label['barcode'] ) && ! in_array( $label['barcode'], $barcodes, true ) ) { $barcodes[] = $label['barcode']; } } return $barcodes; } /** * Harvest the barcodes from a fresh label response, discarding the files on failure. * * On the V4 path the label call has already written PDFs to disk before the * harvest runs. Throwing while keeping them would orphan those files behind a * retry that generates a fresh label, so they are removed before failing. * * @param array $labels Label records returned by create_label(). * * @return array List of barcode strings. * * @throws \Exception When the label response carries no barcode. * * @since 6.0.0 */ protected function harvest_barcodes_or_fail( $labels ) { $barcodes = self::get_barcodes_from_labels( $labels ); if ( empty( $barcodes ) ) { $this->delete_label( array( 'labels' => $labels ) ); throw new \Exception( esc_html__( 'PostNL returned a label without a barcode. Please try generating the label again.', 'postnl-for-woocommerce' ) ); } return $barcodes; } /** * Resolve the parent barcode used for the merged label record. * * Legacy prefetches it, so the passed-in value wins and the merge is unchanged. * V4 has no prefetch: without a fallback maybe_merge_labels() would stamp an * empty barcode onto the merged record for every non-A6 or multi-collo order, * leaving the harvest empty and the tracking URL blank. * * @param mixed $labels Raw label records built from the API response. * @param string $parent_barcode Prefetched parent barcode, empty on the V4 path. * * @return string * * @since 6.0.0 */ protected static function resolve_parent_barcode( $labels, string $parent_barcode ) { if ( '' !== $parent_barcode ) { return $parent_barcode; } if ( ! is_array( $labels ) ) { return ''; } foreach ( $labels as $label ) { if ( ! empty( $label['barcode'] ) ) { return (string) $label['barcode']; } } return ''; } /** * Create PostNL return barcode for current order * * @param array $post_data Order post data. * * @return array|Boolean * * @throws \Exception Error when response has an error. */ public function maybe_create_shipping_return_barcode( $label_post_data, $shipping_item_info ) { $shipment_return_type = $this->settings->get_return_shipment_and_labels(); if ( 'none' === $shipment_return_type ) { return ''; } if ( 'shipping_return' !== $shipment_return_type ) { return ''; } if ( 'NL' !== $shipping_item_info->receiver['country'] ) { return ''; } if ( '2928' === $shipping_item_info->shipment['shipping_product']['code'] || '2948' === $shipping_item_info->shipment['shipping_product']['code'] ) { return ''; } return $label_post_data['main_barcode']; } /** * Create PostNL return barcode for current order * * @param array $post_data Order post data. * * @return array|Boolean * * @throws \Exception Error when response has an error. */ public function maybe_create_return_barcode( $post_data, $shipping_item_info ) { $shipment_return_type = $this->settings->get_return_shipment_and_labels(); if ( 'none' === $shipment_return_type ) { return ''; } if ( ! in_array( $shipping_item_info->receiver['country'], array( 'BE', 'NL' ) ) ) { return ''; } if ( 'shipping_return' === $shipment_return_type && 'BE' !== $shipping_item_info->receiver['country'] ) { return ''; } if ( 'in_box' === $shipment_return_type && ( ! isset( $post_data['saved_data']['backend']['create_return_label'] ) || 'yes' !== $post_data['saved_data']['backend']['create_return_label'] ) ) { return ''; } $not_allowed = array( '6440', '6972', '6405', '6350', '6906' ); if ( 'BE' === $shipping_item_info->receiver['country'] && in_array( $shipping_item_info->shipment['shipping_product']['code'], $not_allowed ) ) { return ''; } $return_code = $this->settings->get_return_customer_code(); $data = array( 'order' => $post_data['order'], 'customer_code' => $return_code, ); $response = $this->service_factory()->barcode_service()->generate( $data ); if ( empty( $response['Barcode'] ) ) { throw new \Exception( esc_html__( 'Cannot create return barcode.', 'postnl-for-woocommerce' ) ); } return $response['Barcode']; } /** * Merging the label. * * @param Array $labels List of labels. * @param WC_Order $order Order object. * @param String $barcode Generated barcode string. * @param String $label_type Type of label. * * @return Array. */ public function maybe_merge_labels( $labels, $order, $barcode, $label_type ) { $label_format = $this->settings->get_label_format(); $merged_labels = array(); if ( ! is_array( $labels ) ) { return $merged_labels; } if ( 1 === count( $labels ) && 'A6' === $label_format ) { return array( $label_type => array_shift( $labels ), ); } $from_country = Utils::get_base_country(); $to_country = $order->get_shipping_country(); $to_state = $order->get_shipping_state(); $destination = Utils::get_shipping_zone( $to_country, $to_state ); $label_type_list = Mapping::label_type_list(); $available_type = ( ! empty( $label_type_list[ $from_country ][ $destination ] ) ) ? $label_type_list[ $from_country ][ $destination ] : array( 'label' ); $file_paths = array(); foreach ( $labels as $label ) { if ( ! in_array( $label['type'], $available_type, true ) ) { continue; } $file_paths[] = $label['filepath']; } $extension = pathinfo( $file_paths[0], PATHINFO_EXTENSION ); $filename = Utils::generate_label_name( $order->get_id(), $label_type, $barcode, $label_format . '-merged', $extension ); $merged_info = $this->merge_labels( $file_paths, $filename ); $merged_labels[ $label_type ] = array( 'type' => $label_type, 'barcode' => $barcode, 'created_at' => current_time( 'timestamp' ), 'filepath' => $merged_info['filepath'], 'merged_files' => $file_paths, ); return $merged_labels; } /** * Merge given files into the single one. * * @param array $label_paths Array of files to be merged. * @param string $merge_filename The final/merged filename with extension. * @param string $start_position Start position for the pdf file only. * * @return array */ protected function merge_labels( $label_paths, $merge_filename, $start_position = 'top-left' ) { $extension = pathinfo( $label_paths[0], PATHINFO_EXTENSION ); try { switch ( $extension ) { case 'pdf': return $this->merge_pdf_labels( $label_paths, $merge_filename, $start_position ); case 'jpg': return $this->merge_jpg_files( $label_paths, $merge_filename, 'horizontal' ); case 'gif': return $this->merge_graphic_labels( $label_paths, $merge_filename ); // Two spellings of the same Zebra label stream: Legacy names the file // from the V1 response's OutputType, the V4 path from the SDK's // LabelOutputType enum, whose Zebra case is the bare string 'zpl'. // Both are plain text, so both concatenate. case 'zpl': case 'zpl_rle': return $this->merge_text_files( $label_paths, $merge_filename ); } } catch ( \Exception $e ) { } return array(); } /** * Merge PDF Labels. * * @param array $label_paths List of label path. * @param String $merge_filename Name of the file after the merge process. * * @return array List of filepath that has been merged. */ protected function merge_pdf_labels( $label_paths, $merge_filename, $start_position = 'top-left' ) { $pdf = new CustomizedPDFMerger(); $merged_paths = array(); foreach ( $label_paths as $path ) { $pdf->addPDF( $path, 'all' ); $merged_paths[] = $path; } $filepath = trailingslashit( POSTNL_UPLOADS_DIR ) . $merge_filename; if ( isset( $_POST['postnl_position_printing_labels'] ) ) { $start_position = sanitize_text_field( $_POST['postnl_position_printing_labels'] ); } $pdf->merge( 'file', $filepath, 'A', $start_position ); return array( 'merged_filepaths' => $merged_paths, 'filepath' => $filepath, ); } /** * Merge JPG Labels. * * @param array $image_paths List of label path. * @param String $merge_filename Name of the file after the merge process. * * @return array List of filepath that has been merged. */ protected function merge_jpg_files( $image_paths, $merge_filename, $direction = 'vertical' ) { $images = array(); $width = 0; $height = 0; // Load images and calculate dimensions foreach ( $image_paths as $path ) { $img = imagecreatefromjpeg( $path ); $images[] = $img; $width = max( $width, imagesx( $img ) ); $height += imagesy( $img ); } // Create a blank canvas for the merged image if ( $direction == 'horizontal' ) { $canvas = imagecreatetruecolor( $width * count( $images ), $height ); } else { $canvas = imagecreatetruecolor( $width, $height ); } // Set white background $white = imagecolorallocate( $canvas, 255, 255, 255 ); imagefill( $canvas, 0, 0, $white ); // Copy each image onto the canvas $offset = 0; foreach ( $images as $img ) { if ( $direction == 'horizontal' ) { imagecopy( $canvas, $img, $offset, 0, 0, 0, imagesx( $img ), imagesy( $img ) ); $offset += imagesx( $img ); } else { imagecopy( $canvas, $img, 0, $offset, 0, 0, imagesx( $img ), imagesy( $img ) ); $offset += imagesy( $img ); } imagedestroy( $img ); } // Set the output file path $filepath = trailingslashit( POSTNL_UPLOADS_DIR ) . $merge_filename; // Save the merged image imagejpeg( $canvas, $filepath ); imagedestroy( $canvas ); return array( 'merged_filepaths' => $image_paths, 'filepath' => $filepath, ); } /** * Merge graphic labels. * * @param Array $label_paths List of label path. * @param String $merge_filename Name of the file after the merge process. * * @return array * @throws \ImagickException */ protected function merge_graphic_labels( $label_paths, $merge_filename ) { if ( empty( $label_paths ) ) { throw new Exception( __( 'There are no files to merge.', 'postnl-for-woocommerce' ) ); } if ( ! class_exists( 'Imagick' ) ) { throw new Exception( __( '"Imagick" must be installed on the server to merge png files.', 'postnl-for-woocommerce' ) ); } $merged_paths = array(); $final_label_path = trailingslashit( POSTNL_UPLOADS_DIR ) . $merge_filename; $final_label = new \Imagick(); foreach ( $label_paths as $path ) { $final_label->addImage( new \Imagick( $path ) ); $merged_paths[] = $path; } /* Append the images into one */ $final_label->resetIterator(); $combined = $final_label->appendImages( true ); $combined->setImageFormat( pathinfo( $final_label_path, PATHINFO_EXTENSION ) ); $combined->writeimage( $final_label_path ); return array( 'merged_filepaths' => $merged_paths, 'filepath' => $final_label_path, ); } /** * Merge text files, for the ZEBRA printer. * * @param array $label_paths List of label path. * @param String $merge_filename Name of the file after the merge process. * * @return array */ protected function merge_text_files( $label_paths, $merge_filename ) { $merged_paths = array(); $filepath = trailingslashit( POSTNL_UPLOADS_DIR ) . $merge_filename; $output = fopen( $filepath, 'w' ); foreach ( $label_paths as $path ) { if ( ! file_exists( $path ) ) { continue; // Skip if the file does not exist } $input = fopen( $path, 'r' ); if ( ! $input ) { continue; // Skip if unable to open the file } // Read each line and write it to the output file while ( ( $line = fgets( $input ) ) !== false ) { fwrite( $output, $line ); } fclose( $input ); // Close each input file after reading $merged_paths[] = $path; } fclose( $output ); // Close the output file after writing return array( 'merged_filepaths' => $merged_paths, 'filepath' => $filepath, ); } /** * Create PostNL label for current order * * @param array $post_data Order post data. * * @return array * * @throws \Exception Error when response has an error. */ public function create_label( $post_data ) { return $this->service_factory()->label_service()->create( $post_data ); } /** * Legacy pipeline for outbound shipping labels. * * Called exclusively by Legacy\Label_Service::create() to avoid recursion * (Label_Service extends Order_Base and inherits create_label(), so * Label_Service cannot safely call create_label() once it routes through * the factory). * * @param array $post_data Order post data. * * @return array * * @throws \Exception Error when response has an error. */ protected function create_label_pipeline( $post_data ) { $order = $post_data['order']; $shipping_item_info = new Shipping\Item_Info( $post_data ); $shipping = new Shipping\Client( $shipping_item_info ); $response = $shipping->send_request(); // Check any errors. $this->check_label_and_barcode( $response ); $labels = $this->put_label_content( $response, $order, $post_data['main_barcode'], 'label' ); if ( empty( $labels ) ) { throw new \Exception( esc_html__( 'Cannot create the label. Label content is missing', 'postnl-for-woocommerce' ) ); } return $labels; } /** * Create PostNL return label for current order * * @param array $post_data Order post data. * * @return array * * @throws \Exception Error when response has an error. */ public function maybe_create_return_label( $post_data ) { return $this->service_factory()->return_label_service()->create( $post_data ); } /** * Legacy pipeline for return labels. * * Called exclusively by Legacy\Return_Label_Service::create() to avoid * recursion (Return_Label_Service extends Order_Base). * * @param array $post_data Order post data. * * @return array * * @throws \Exception Error when response has an error. */ protected function maybe_create_return_label_pipeline( $post_data ) { if ( 'yes' !== $post_data['saved_data']['backend']['create_return_label'] ) { return array(); } $order = $post_data['order']; $item_info = new Return_Label\Item_Info( $post_data ); $return_label = new Return_Label\Client( $item_info ); $response = $return_label->send_request(); $labels = $this->put_label_content( $response, $order, $post_data['main_barcode'], 'return-label' ); if ( empty( $labels ) ) { throw new \Exception( esc_html__( 'Cannot create the return label. Label content is missing', 'postnl-for-woocommerce' ) ); } return $labels; } /** * Create PostNL letterbox label for current order * * @param array $post_data Order post data. * * @return array * * @throws \Exception Error when response has an error. */ public function maybe_create_letterbox( $post_data ) { return $this->service_factory()->letterbox_service()->create( $post_data ); } /** * Legacy pipeline for letterbox labels. * * Called exclusively by Legacy\Letterbox_Service::create() to avoid * recursion (Letterbox_Service extends Order_Base). * * @param array $post_data Order post data. * * @return array * * @throws \Exception Error when response has an error. */ protected function maybe_create_letterbox_pipeline( $post_data ) { if ( 'yes' !== $post_data['saved_data']['backend']['letterbox'] ) { return array(); } $order = $post_data['order']; $item_info = new Letterbox\Item_Info( $post_data ); $return_label = new Letterbox\Client( $item_info ); $response = $return_label->send_request(); $labels = $this->put_label_content( $response, $order, $post_data['main_barcode'], 'letterbox' ); if ( empty( $labels ) ) { throw new \Exception( esc_html__( 'Cannot create the letterbox. Label content is missing', 'postnl-for-woocommerce' ) ); } return $labels; } /** * Make sure the barcode and label content is exists before printing. * * @param Array $response Response from API Call. * * @throws \Exception Error when barcode or label content is missing. */ public function check_label_and_barcode( $response ) { $message_types = Utils::get_label_response_type(); $has_barcode = false; $has_content = false; foreach ( $message_types as $type => $content_type ) { if ( ! empty( $response[ $type ][0]['Barcode'] ) ) { $has_barcode = true; } if ( ! empty( $response[ $type ][0]['Labels'][0]['Content'] ) ) { $has_content = true; } } if ( ! $has_barcode ) { throw new \Exception( esc_html__( 'Cannot create the label. Barcode data is missing', 'postnl-for-woocommerce' ) ); } if ( ! $has_content ) { throw new \Exception( esc_html__( 'Cannot create the label. Label content is missing', 'postnl-for-woocommerce' ) ); } } /** * Delete PostNL label for current order * * @param array $saved_data Order saved meta data. * * @return bool */ public function delete_label( $saved_data ) { if ( empty( $saved_data['labels']['label']['filepath'] ) ) { return false; } if ( isset( $saved_data['labels']['label']['merged_files'] ) ) { foreach ( $saved_data['labels']['label']['merged_files'] as $label_path ) { unlink( $label_path ); } } return unlink( $saved_data['labels']['label']['filepath'] ); } /** * Generate download label url * * @param int $order_id ID of the order post. * @param String $label_type Type of the label. Possible options : 'label', 'return-label'. * * @return String. */ public function get_download_label_url( $order_id, $label_type = 'label' ) { $download_url = add_query_arg( array( 'postnl_label_order_id' => $order_id, 'label_type' => $label_type, 'postnl_label_nonce' => wp_create_nonce( 'postnl_download_label_nonce' ), ), home_url() ); return $download_url; } /** * Get label file. * * @return void * @since 1.0.0 */ public function get_label_file() { if ( empty( $_GET['postnl_label_nonce'] ) ) { return; } if ( empty( $_GET['label_type'] ) ) { return; } // Check nonce before proceed. $nonce_result = check_ajax_referer( 'postnl_download_label_nonce', sanitize_text_field( wp_unslash( $_GET['postnl_label_nonce'] ) ), false ); if ( empty( $_GET['postnl_label_order_id'] ) ) { return; } $order_id = sanitize_text_field( wp_unslash( $_GET['postnl_label_order_id'] ) ); $label_type = sanitize_text_field( wp_unslash( $_GET['label_type'] ) ); if ( ! ( current_user_can( 'manage_woocommerce' ) || current_user_can( 'view_order', $order_id ) ) ) { return; } $saved_data = $this->get_data( $order_id ); if ( empty( $saved_data['labels'][ $label_type ]['filepath'] ) ) { return; } $this->download_label( $saved_data['labels'][ $label_type ]['filepath'] ); } /** * Downloads the generated label file * * @param string $file_path File path to the label. * * @return boolean|void */ protected function download_label( $file_path ) { if ( ! empty( $file_path ) && is_string( $file_path ) && file_exists( $file_path ) ) { // Check if buffer exists, then flush any buffered output to prevent it from being included in the file's content. if ( ob_get_contents() ) { ob_clean(); } $filename = basename( $file_path ); header( 'Content-Description: File Transfer' ); header( 'Content-Type: application/octet-stream' ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); header( 'Expires: 0' ); header( 'Cache-Control: must-revalidate' ); header( 'Pragma: public' ); header( 'Content-Length: ' . filesize( $file_path ) ); readfile( $file_path ); exit; } else { return false; } } /** * Get tracking note for the order. * * @param Int $order_id ID of the order object. * * @return String */ protected function get_tracking_note( $order_id ) { if ( ! empty( $this->settings->get_woocommerce_email_text() ) ) { $tracking_note = $this->settings->get_woocommerce_email_text(); } else { // translators: %s the current service. $tracking_note = sprintf( __( '%s Tracking Number: {tracking-link}', 'postnl-for-woocommerce' ), $this->service ); } $tracking_link = $this->get_tracking_link( $order_id ); if ( empty( $tracking_link ) ) { return ''; } $tracking_note_new = str_replace( '{tracking-link}', $tracking_link, $tracking_note, $count ); if ( 0 === $count ) { $tracking_note_new = $tracking_note . ' ' . $tracking_link; } return $tracking_note_new; } /** * Get tracking url for the order. * * @param Int $order_id ID of the order object. */ protected function get_tracking_link( $order_id ) { $saved_data = $this->get_data( $order_id ); $order = wc_get_order( $order_id ); if ( empty( $saved_data['labels']['label']['barcode'] ) || ! is_a( $order, 'WC_Order' ) ) { return ''; } $tracking_url = Utils::generate_tracking_url( $saved_data['labels']['label']['barcode'], $order->get_shipping_country(), $order->get_shipping_postcode() ); return sprintf( '%2$s', esc_url( $tracking_url ), $saved_data['labels']['label']['barcode'] ); } /** * Check if the order have the label data. * * @param WC_Order $order current order object. * @param String $field Backend field name. * * @return boolean */ public function have_backend_data( $order, $field = '' ) { $order_data = $order->get_meta( $this->meta_name ); if ( ! empty( $field ) ) { return ! empty( $order_data['backend'][ $field ] ); } return ! empty( $order_data['backend'] ); } /** * Check if the order have the label file. * * @param \WC_Order $order current order object. * * @return boolean. */ public function have_label_file( $order ) { $order_data = $order->get_meta( $this->meta_name ); return ! empty( $order_data['labels']['label']['filepath'] ); } /** * Check if the return function is activated for the order. * * @param int|\WC_Order $order . * * @return bool */ public function is_return_function_activated( $order ) { $order = ( $order instanceof \WC_Order ) ? $order : wc_get_order( $order ); if ( ! $order instanceof \WC_Order ) { return false; } return 'yes' === $order->get_meta( $this->is_return_activated_meta ); } }