$column_info ) {
$new_columns[ $column_name ] = $column_info;
if ( 'order_status' === $column_name ) {
$new_columns[ $column_name ] = 'Order Status';
$new_columns['fulfillment_status'] = __( 'Fulfillment Status', 'woocommerce' );
$new_columns['shipment_tracking'] = __( 'Shipment Tracking', 'woocommerce' );
$new_columns['shipment_provider'] = __( 'Shipment Provider', 'woocommerce' );
}
}
return $new_columns;
}
/**
* Render the fulfillment column row data for legacy order list support.
*
* @param string $column_name The name of the column.
*/
public function render_fulfillment_column_row_data_legacy( string $column_name ) {
global $the_order;
// This method is kept for legacy support, but the main rendering logic is now in render_fulfillment_column_row_data.
return $this->render_fulfillment_column_row_data( $column_name, $the_order );
}
/**
* Render the fulfillment status column.
*
* @param string $column_name The name of the column.
* @param WC_Order $order The order object.
*/
public function render_fulfillment_column_row_data( string $column_name, WC_Order $order ) {
$fulfillments = $this->maybe_read_fulfillments( $order );
// Render the column data based on the column name.
switch ( $column_name ) {
case 'fulfillment_status':
$this->render_order_fulfillment_status_column_row_data( $order );
break;
case 'shipment_tracking':
$this->render_shipment_tracking_column_row_data( $order, $fulfillments );
break;
case 'shipment_provider':
$this->render_shipment_provider_column_row_data( $order, $fulfillments );
break;
}
}
/**
* Render the fulfillment status column row data.
*
* @param WC_Order $order The order object.
*/
private function render_order_fulfillment_status_column_row_data( WC_Order $order ) {
$order_fulfillment_status = FulfillmentUtils::get_order_fulfillment_status( $order );
echo "
";
$this->render_order_fulfillment_status_badge( $order, $order_fulfillment_status );
echo '
';
}
/**
* Render the fulfillment status badge.
*
* @param WC_Order $order The order object.
* @param string $order_fulfillment_status The fulfillment status of the order.
*/
private function render_order_fulfillment_status_badge( $order, string $order_fulfillment_status ) {
$status_props = FulfillmentUtils::get_order_fulfillment_statuses()[ $order_fulfillment_status ];
if ( ! $status_props ) {
$status_props = array(
'label' => __( 'Unknown', 'woocommerce' ),
'background_color' => '#f0f0f0',
'text_color' => '#000',
);
}
echo '' . esc_html( $status_props['label'] ) . ' ';
echo "
";
}
/**
* Render the shipment provider column row data.
*
* @param WC_Order $order The order object.
* @param array $fulfillments The fulfillments.
*/
private function render_shipment_provider_column_row_data( WC_Order $order, array $fulfillments ) {
$providers = array();
foreach ( $fulfillments as $fulfillment ) {
$provider = $fulfillment->get_shipment_provider();
if ( ! empty( $provider ) ) {
$provider_name = $fulfillment->get_meta( '_provider_name' );
$key = 'other' === $provider && ! empty( $provider_name )
? $provider . '::' . $provider_name
: $provider;
$providers[ $key ] = $fulfillment;
}
}
if ( count( $providers ) > 1 ) {
echo '' . esc_html__( 'Multiple providers', 'woocommerce' ) . ' ';
} elseif ( 1 === count( $providers ) ) {
$provider_fulfillment = reset( $providers );
$provider_display_label = FulfillmentUtils::get_provider_display_name( $provider_fulfillment );
echo '' . esc_html( '' !== $provider_display_label ? $provider_display_label : '--' ) . ' ';
} else {
echo '-- ';
}
}
/**
* Render the shipment tracking column row data.
*
* @param WC_Order $order The order object.
* @param array $fulfillments The fulfillments.
*/
private function render_shipment_tracking_column_row_data( WC_Order $order, array $fulfillments ) {
$tracking = array();
foreach ( $fulfillments as $fulfillment ) {
$number = $fulfillment->get_tracking_number();
if ( ! empty( $number ) ) {
$tracking[] = array(
'number' => $number,
'url' => $fulfillment->get_tracking_url(),
);
}
}
if ( count( $tracking ) > 1 ) {
echo '' . esc_html__( 'Multiple trackings', 'woocommerce' ) . ' ';
} elseif ( 1 === count( $tracking ) ) {
$entry = $tracking[0];
if ( ! empty( $entry['url'] ) ) {
echo '' . esc_html( $entry['number'] ) . ' ';
} else {
echo '' . esc_html( $entry['number'] ) . ' ';
}
} else {
echo '-- ';
}
}
/**
* Render the fulfillment drawer.
*/
public function render_fulfillment_drawer_slot() {
if ( ! $this->should_render_fulfillment_drawer() ) {
return;
}
?>
maybe_read_fulfillments( $order );
// Fulfill all existing fulfillments.
foreach ( $fulfillments as $fulfillment ) {
$fulfillment->set_status( 'fulfilled' );
$fulfillment->save();
}
// Create a fulfillment for the order, containing all remaining items in the order.
$remaining_items = array_map(
function ( $item ) {
return array(
'item_id' => $item['item_id'],
'qty' => $item['qty'],
);
},
FulfillmentUtils::get_pending_items( $order, $fulfillments )
);
if ( 0 < count( $remaining_items ) ) {
$fulfillment = new Fulfillment();
$fulfillment->set_entity_type( WC_Order::class );
$fulfillment->set_entity_id( (string) $order->get_id() );
$fulfillment->set_status( 'fulfilled' );
$fulfillment->set_items( $remaining_items );
$fulfillment->save();
}
}
$redirect_to = add_query_arg( array( 'bulk_action' => $action ), $redirect_to );
}
return $redirect_to;
}
/**
* Render the fulfillment status text in the order details page and the order tracking page.
*
* @param string $order_status The order status text.
* @param WC_Order $order The order object.
*
* @return string The fulfillment status appended order status text.
*/
public function render_fulfillment_status_text( string $order_status, WC_Order $order ): string {
$fulfillments = $this->maybe_read_fulfillments( $order );
$fulfillment_status = FulfillmentUtils::get_order_fulfillment_status_text( $order, $fulfillments );
return sprintf( '%s %s', $order_status, $fulfillment_status );
}
/**
* Render the fulfillment customer details in the order details page.
*
* @param WC_Order $order The order object.
*/
public function render_fulfillment_customer_details( WC_Order $order ) {
$fulfillments = $this->maybe_read_fulfillments( $order );
if ( ! empty( $fulfillments ) ) {
?>
$fulfillment ) {
if ( ! $fulfillment->get_is_fulfilled() ) {
continue;
}
?>
get_date_fulfilled()
?? $fulfillment->get_date_updated();
// Append ' UTC' so strtotime treats the stored value as UTC, then render in the site's timezone.
$shipment_timestamp = $shipment_date_utc ? strtotime( $shipment_date_utc . ' UTC' ) : false;
$shipment_date_local = false !== $shipment_timestamp ? wp_date( 'F j, Y', $shipment_timestamp ) : '';
printf(
/* translators: %1$s is the shipment index, %2$s is the shipment date */
wp_kses( __( 'Shipment %1$s was shipped on %2$s ', 'woocommerce' ), 'b' ),
intval( $index ) + 1,
esc_html( (string) $shipment_date_local )
);
?>
';
// Get the fulfillment status for the order.
$fulfillments = $this->maybe_read_fulfillments( $order );
$order_fulfillment_status = FulfillmentUtils::calculate_order_fulfillment_status( $order, $fulfillments );
// Render order status badge.
$order_status = $order->get_status();
echo '' . esc_html( wc_get_order_status_name( $order_status ) ) . ' ';
// Render fulfillment status badge.
$this->render_order_fulfillment_status_badge( $order, $order_fulfillment_status );
echo '';
}
/**
* Loads the fulfillments scripts and styles.
*/
public function load_components() {
if ( ! $this->should_render_fulfillment_drawer() ) {
return;
}
$this->register_fulfillments_assets();
$this->load_fulfillments_js_settings();
}
/**
* Register the fulfillment assets.
*/
protected function register_fulfillments_assets() {
WCAdminAssets::register_style( 'fulfillments', 'style', array( 'wp-components' ) );
WCAdminAssets::register_script( 'wp-admin-scripts', 'fulfillments', true );
}
/**
* Load the fulfillments JS settings.
*
* @return void
*/
protected function load_fulfillments_js_settings() {
$providers_for_js = array();
foreach ( FulfillmentUtils::get_shipping_providers() as $provider ) {
$providers_for_js[ $provider->get_key() ] = array(
'label' => $provider->get_name(),
'icon' => $provider->get_icon(),
'value' => $provider->get_key(),
'url' => $provider->get_tracking_url( '__PLACEHOLDER__' ) ?? '',
);
}
$fulfillment_settings = array(
'providers' => $providers_for_js,
'currency_symbols' => get_woocommerce_currency_symbols(),
'fulfillment_statuses' => FulfillmentUtils::get_fulfillment_statuses(),
'order_fulfillment_statuses' => FulfillmentUtils::get_order_fulfillment_statuses(),
);
wp_localize_script( 'wc-admin-fulfillments', 'wcFulfillmentSettings', $fulfillment_settings );
}
/**
* Render the fulfillment filters in the orders table.
*/
public function render_fulfillment_filters() {
if ( ! self::should_render_fulfillment_drawer() ) {
return;
}
?>
>
$props ) : ?>
>
render_fulfillment_filters();
}
/**
* Apply the fulfillment status filter to the orders list.
*
* @param array $args The query arguments for the orders list.
* @return array The modified query arguments.
*/
public function filter_orders_list_table_query( $args ) {
// This is a read-only filter on the admin orders table, so nonce verification is not required.
// phpcs:ignore WordPress.Security.NonceVerification
if ( isset( $_GET['fulfillment_status'] ) && ! empty( $_GET['fulfillment_status'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification
$fulfillment_status = sanitize_text_field( wp_unslash( $_GET['fulfillment_status'] ) );
// Ensure the fulfillment status is one of the allowed values.
if ( FulfillmentUtils::is_valid_order_fulfillment_status( $fulfillment_status ) ) {
// Only track when the filter is explicitly submitted, not on pagination/refresh.
if ( isset( $_GET['filter_action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
FulfillmentsTracker::track_fulfillment_filter_used( 'fulfillment_status', $fulfillment_status );
}
$meta_query = FulfillmentUtils::get_order_fulfillment_status_meta_query( $fulfillment_status );
if ( ! empty( $meta_query ) ) {
if ( ! isset( $args['meta_query'] ) ) {
$args['meta_query'] = array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
}
$args['meta_query'][] = $meta_query;
}
}
}
return $args;
}
/**
* Filter the legacy orders list query to include fulfillment status.
*
* @param \WP_Query $query The WP_Query object.
*/
public function filter_legacy_orders_list_query( $query ) {
if (
is_admin()
&& $query->is_main_query()
&& $query->get( 'post_type' ) === 'shop_order'
&& isset( $_GET['fulfillment_status'] ) && ! empty( $_GET['fulfillment_status'] ) // phpcs:ignore WordPress.Security.NonceVerification
) {
$status = sanitize_text_field( wp_unslash( $_GET['fulfillment_status'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
// Ensure the fulfillment status is one of the allowed values.
if ( FulfillmentUtils::is_valid_order_fulfillment_status( $status ) ) {
// Only track when the filter is explicitly submitted, not on pagination/refresh.
if ( isset( $_GET['filter_action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
FulfillmentsTracker::track_fulfillment_filter_used( 'fulfillment_status', $status );
}
$query->set(
'meta_query',
'no_fulfillments' === $status ?
array(
'relation' => 'OR',
array(
'key' => '_fulfillment_status',
'compare' => 'NOT EXISTS',
),
) :
array(
array(
'key' => '_fulfillment_status',
'value' => $status,
'compare' => '=',
),
)
);
}
}
}
/**
* Render the shipping provider filter dropdown in the orders table.
*
* @since 10.7.0
*/
public function render_shipping_provider_filter(): void {
if ( ! self::should_render_fulfillment_drawer() ) {
return;
}
$providers = FulfillmentUtils::get_shipping_providers();
// This is a read-only filter on the admin orders table, so nonce verification is not required.
// phpcs:ignore WordPress.Security.NonceVerification
$selected_provider = isset( $_GET['shipping_provider'] ) ? sanitize_text_field( wp_unslash( $_GET['shipping_provider'] ) ) : '';
?>
>
get_key() ); ?>>
get_name() ); ?>
>
render_shipping_provider_filter();
}
/**
* Filter orders by shipping provider for the HPOS orders list.
*
* @since 10.7.0
*
* @param array $args The query arguments for the orders list.
* @return array The modified query arguments.
*/
public function filter_orders_by_shipping_provider( $args ) {
// This is a read-only filter on the admin orders table, so nonce verification is not required.
// phpcs:ignore WordPress.Security.NonceVerification
if ( ! isset( $_GET['shipping_provider'] ) || empty( $_GET['shipping_provider'] ) ) {
return $args;
}
// phpcs:ignore WordPress.Security.NonceVerification
$shipping_provider = sanitize_text_field( wp_unslash( $_GET['shipping_provider'] ) );
$order_ids = $this->get_order_ids_by_shipping_provider( $shipping_provider );
if ( empty( $order_ids ) ) {
$args['post__in'] = array( 0 );
} elseif ( isset( $args['post__in'] ) && is_array( $args['post__in'] ) ) {
$args['post__in'] = array_intersect( $args['post__in'], $order_ids );
if ( empty( $args['post__in'] ) ) {
$args['post__in'] = array( 0 );
}
} else {
$args['post__in'] = $order_ids;
}
return $args;
}
/**
* Filter legacy orders by shipping provider.
*
* @since 10.7.0
*
* @param \WP_Query $query The WP_Query object.
*/
public function filter_legacy_orders_by_shipping_provider( $query ): void {
if (
! is_admin()
|| ! $query->is_main_query()
|| 'shop_order' !== $query->get( 'post_type' )
// This is a read-only filter on the admin orders table, so nonce verification is not required.
// phpcs:ignore WordPress.Security.NonceVerification
|| ! isset( $_GET['shipping_provider'] )
// phpcs:ignore WordPress.Security.NonceVerification
|| empty( $_GET['shipping_provider'] )
) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification
$shipping_provider = sanitize_text_field( wp_unslash( $_GET['shipping_provider'] ) );
$order_ids = $this->get_order_ids_by_shipping_provider( $shipping_provider );
if ( empty( $order_ids ) ) {
$query->set( 'post__in', array( 0 ) );
} else {
$existing = $query->get( 'post__in' );
if ( ! empty( $existing ) && is_array( $existing ) ) {
$order_ids = array_intersect( $existing, $order_ids );
if ( empty( $order_ids ) ) {
$order_ids = array( 0 );
}
}
$query->set( 'post__in', $order_ids );
}
}
/**
* Get order IDs that have fulfillments with a specific shipping provider.
*
* @since 10.7.0
*
* @param string $shipping_provider The shipping provider key to filter by.
* @return array Array of order IDs.
*/
private function get_order_ids_by_shipping_provider( string $shipping_provider ): array {
global $wpdb;
$fulfillments_table = $wpdb->prefix . 'wc_order_fulfillments';
$meta_table = $wpdb->prefix . 'wc_order_fulfillment_meta';
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
if ( '__other__' === $shipping_provider ) {
$known_providers = FulfillmentUtils::get_shipping_providers();
$known_keys = array_keys( $known_providers );
if ( empty( $known_keys ) ) {
$results = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT f.entity_id
FROM {$fulfillments_table} f
INNER JOIN {$meta_table} m ON f.fulfillment_id = m.fulfillment_id
WHERE m.meta_key = %s
AND m.meta_value IS NOT NULL
AND m.meta_value NOT IN ( '', %s )
AND f.date_deleted IS NULL
AND m.date_deleted IS NULL",
'_shipment_provider',
wp_json_encode( '' )
)
);
} else {
// Exclude known providers and both empty string and the JSON-encoded one the data store writes.
$excluded = array_merge(
array( '', wp_json_encode( '' ) ),
array_map( 'wp_json_encode', $known_keys )
);
$placeholders = implode( ',', array_fill( 0, count( $excluded ), '%s' ) );
$results = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT f.entity_id
FROM {$fulfillments_table} f
INNER JOIN {$meta_table} m ON f.fulfillment_id = m.fulfillment_id
WHERE m.meta_key = '_shipment_provider'
AND m.meta_value NOT IN ({$placeholders})
AND m.meta_value IS NOT NULL
AND f.date_deleted IS NULL
AND m.date_deleted IS NULL",
...$excluded
)
);
}
} else {
$results = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT f.entity_id
FROM {$fulfillments_table} f
INNER JOIN {$meta_table} m ON f.fulfillment_id = m.fulfillment_id
WHERE m.meta_key = '_shipment_provider'
AND m.meta_value = %s
AND f.date_deleted IS NULL
AND m.date_deleted IS NULL",
wp_json_encode( $shipping_provider )
)
);
}
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
return array_map( 'absint', $results );
}
/**
* Check if the fulfillment drawer should be rendered (admin only).
*
* @return bool True if the fulfillment drawer should be rendered, false otherwise.
*/
protected function should_render_fulfillment_drawer(): bool {
if ( ! is_admin() ) {
return false;
}
if ( ! function_exists( 'get_current_screen' ) ) {
return false;
}
$current_screen = get_current_screen();
if ( ! $current_screen || ! $current_screen->id ) {
return false;
}
return 'woocommerce_page_wc-orders' === $current_screen->id // HPOS screen.
|| 'edit-shop_order' === $current_screen->id // Legacy screen.
|| 'shop_order' === $current_screen->id; // Order details screen (legacy).
}
/**
* Fetches the fulfillments for the given order, caching them to avoid multiple fetches.
*
* @param WC_Order $order The order object.
*
* @return array The fulfillments for the order.
*/
private function maybe_read_fulfillments( WC_Order $order ): array {
// Check if we've already fetched the fulfillments for this order.
if ( isset( $this->fulfillments_cache[ $order->get_id() ] ) ) {
return $this->fulfillments_cache[ $order->get_id() ];
}
// If not, fetch them and cache them.
try {
/**
* Fulfillments data store.
*
* @var \Automattic\WooCommerce\Admin\Features\Fulfillments\DataStore\FulfillmentsDataStore $data_store
*/
$data_store = \WC_Data_Store::load( 'order-fulfillment' );
$fulfillments = $data_store->read_fulfillments( WC_Order::class, '' . $order->get_id() );
} catch ( \Throwable $e ) {
wc_get_logger()->error(
sprintf( 'Failed to load fulfillments for order %d: %s', $order->get_id(), $e->getMessage() ),
array( 'source' => 'fulfillments' )
);
$fulfillments = array();
}
$this->fulfillments_cache[ $order->get_id() ] = $fulfillments;
return $fulfillments;
}
}