PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 10.9.3
WooCommerce v10.9.3
10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Admin / Features / Fulfillments / FulfillmentsRenderer.php
woocommerce / src / Admin / Features / Fulfillments Last commit date
DataStore 1 month ago Providers 3 months ago Fulfillment.php 1 month ago FulfillmentException.php 3 months ago FulfillmentOrderNotes.php 3 months ago FulfillmentUtils.php 2 months ago FulfillmentsController.php 3 months ago FulfillmentsManager.php 2 months ago FulfillmentsRenderer.php 1 month ago FulfillmentsSettings.php 3 months ago FulfillmentsTracker.php 3 months ago OrderFulfillmentsRestController.php 1 month ago ShippingProviders.php 3 months ago
FulfillmentsRenderer.php
796 lines
1 <?php
2 /**
3 * WooCommerce order fulfillments renderer script.
4 */
5
6 declare( strict_types=1 );
7
8 namespace Automattic\WooCommerce\Admin\Features\Fulfillments;
9
10 use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
11 use Automattic\WooCommerce\Utilities\OrderUtil;
12 use WC_Order;
13
14 /**
15 * FulfillmentsRenderer class.
16 */
17 class FulfillmentsRenderer {
18
19 /**
20 * Fulfillments cache, that holds the fulfillments for each order to eliminate
21 * fetching fulfillment records of an order on each column render.
22 *
23 * @var array
24 */
25 private array $fulfillments_cache = array();
26
27 /**
28 * Registers the hooks related to fulfillments.
29 */
30 public function register() {
31 if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
32 // Hook into column definitions and add the new fulfillment columns.
33 add_filter( 'manage_woocommerce_page_wc-orders_columns', array( $this, 'add_fulfillment_columns' ) );
34 // Hook into the column rendering and render the new fulfillment columns.
35 add_action( 'manage_woocommerce_page_wc-orders_custom_column', array( $this, 'render_fulfillment_column_row_data' ), 10, 2 );
36 } else {
37 // For legacy orders table, hook into column definitions and add the new fulfillment columns.
38 add_filter( 'manage_edit-shop_order_columns', array( $this, 'add_fulfillment_columns' ) );
39 // Hook into the column rendering and render the new fulfillment columns.
40 add_action( 'manage_shop_order_posts_custom_column', array( $this, 'render_fulfillment_column_row_data_legacy' ), 25, 1 );
41 }
42 // Hook into the admin footer to add the fulfillment drawer slot, which the React component will mount on.
43 add_action( 'admin_footer', array( $this, 'render_fulfillment_drawer_slot' ) );
44 // Hook into the admin enqueue scripts to load the fulfillment drawer component.
45 add_action( 'admin_enqueue_scripts', array( $this, 'load_components' ) );
46 // Hook into the order details page to render the fulfillment badges.
47 add_action( 'woocommerce_admin_order_data_header_right', array( $this, 'render_order_details_badges' ) );
48 // Hook into the order details before order table to render the fulfillment customer details.
49 add_action( 'woocommerce_order_details_before_order_table', array( $this, 'render_fulfillment_customer_details' ) );
50 // Initialize the renderer for bulk actions.
51 add_action( 'admin_init', array( $this, 'init_admin_hooks' ) );
52 // Hook into the order status text to append the fulfillment status.
53 add_filter( 'woocommerce_order_details_status', array( $this, 'render_fulfillment_status_text' ), 10, 2 );
54 add_filter( 'woocommerce_order_tracking_status', array( $this, 'render_fulfillment_status_text' ), 10, 2 );
55 }
56
57 /**
58 * Initialize the hooks that should run after `admin_init` hook.
59 */
60 public function init_admin_hooks() {
61 if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
62 // For custom orders table, we need to add the bulk actions to the custom orders table.
63 add_filter( 'bulk_actions-woocommerce_page_wc-orders', array( $this, 'define_fulfillment_bulk_actions' ) );
64 add_filter( 'handle_bulk_actions-woocommerce_page_wc-orders', array( $this, 'handle_fulfillment_bulk_actions' ), 10, 3 );
65 // For custom orders table, we need to filter the query to include fulfillment status.
66 add_action( 'woocommerce_order_list_table_restrict_manage_orders', array( $this, 'render_fulfillment_filters' ) );
67 add_action( 'woocommerce_order_list_table_restrict_manage_orders', array( $this, 'render_shipping_provider_filter' ) );
68 add_filter( 'woocommerce_order_query_args', array( $this, 'filter_orders_list_table_query' ), 10, 1 );
69 add_filter( 'woocommerce_order_list_table_prepare_items_query_args', array( $this, 'filter_orders_by_shipping_provider' ), 10, 1 );
70 } else {
71 // For legacy orders table, we need to add the bulk actions to the legacy orders table.
72 add_filter( 'bulk_actions-edit-shop_order', array( $this, 'define_fulfillment_bulk_actions' ) );
73 add_filter( 'handle_bulk_actions-edit-shop_order', array( $this, 'handle_fulfillment_bulk_actions' ), 10, 3 );
74 // For legacy orders table, we need to filter the query to include fulfillment status.
75 add_action( 'restrict_manage_posts', array( $this, 'render_fulfillment_filters_legacy' ) );
76 add_action( 'restrict_manage_posts', array( $this, 'render_shipping_provider_filter_legacy' ) );
77 add_action( 'pre_get_posts', array( $this, 'filter_legacy_orders_list_query' ) );
78 add_action( 'pre_get_posts', array( $this, 'filter_legacy_orders_by_shipping_provider' ) );
79 }
80 }
81
82 /**
83 * Add the fulfillment related columns to the orders table, after the order_status column.
84 *
85 * @param array $columns The columns in the orders page.
86 * @return array The modified columns.
87 */
88 public function add_fulfillment_columns( $columns ) {
89 $new_columns = array();
90 foreach ( $columns as $column_name => $column_info ) {
91 $new_columns[ $column_name ] = $column_info;
92 if ( 'order_status' === $column_name ) {
93 $new_columns[ $column_name ] = 'Order Status';
94 $new_columns['fulfillment_status'] = __( 'Fulfillment Status', 'woocommerce' );
95 $new_columns['shipment_tracking'] = __( 'Shipment Tracking', 'woocommerce' );
96 $new_columns['shipment_provider'] = __( 'Shipment Provider', 'woocommerce' );
97 }
98 }
99 return $new_columns;
100 }
101
102 /**
103 * Render the fulfillment column row data for legacy order list support.
104 *
105 * @param string $column_name The name of the column.
106 */
107 public function render_fulfillment_column_row_data_legacy( string $column_name ) {
108 global $the_order;
109 // This method is kept for legacy support, but the main rendering logic is now in render_fulfillment_column_row_data.
110 return $this->render_fulfillment_column_row_data( $column_name, $the_order );
111 }
112
113 /**
114 * Render the fulfillment status column.
115 *
116 * @param string $column_name The name of the column.
117 * @param WC_Order $order The order object.
118 */
119 public function render_fulfillment_column_row_data( string $column_name, WC_Order $order ) {
120 $fulfillments = $this->maybe_read_fulfillments( $order );
121
122 // Render the column data based on the column name.
123 switch ( $column_name ) {
124 case 'fulfillment_status':
125 $this->render_order_fulfillment_status_column_row_data( $order );
126 break;
127 case 'shipment_tracking':
128 $this->render_shipment_tracking_column_row_data( $order, $fulfillments );
129 break;
130 case 'shipment_provider':
131 $this->render_shipment_provider_column_row_data( $order, $fulfillments );
132 break;
133 }
134 }
135
136 /**
137 * Render the fulfillment status column row data.
138 *
139 * @param WC_Order $order The order object.
140 */
141 private function render_order_fulfillment_status_column_row_data( WC_Order $order ) {
142 $order_fulfillment_status = FulfillmentUtils::get_order_fulfillment_status( $order );
143
144 echo "<div class='fulfillment-status-wrapper'>";
145 $this->render_order_fulfillment_status_badge( $order, $order_fulfillment_status );
146 echo '</div>';
147 }
148
149 /**
150 * Render the fulfillment status badge.
151 *
152 * @param WC_Order $order The order object.
153 * @param string $order_fulfillment_status The fulfillment status of the order.
154 */
155 private function render_order_fulfillment_status_badge( $order, string $order_fulfillment_status ) {
156 $status_props = FulfillmentUtils::get_order_fulfillment_statuses()[ $order_fulfillment_status ];
157 if ( ! $status_props ) {
158 $status_props = array(
159 'label' => __( 'Unknown', 'woocommerce' ),
160 'background_color' => '#f0f0f0',
161 'text_color' => '#000',
162 );
163 }
164
165 echo '<mark class="fulfillment-status fulfillments-trigger" style="background-color:' . esc_attr( $status_props['background_color'] ) . '; color: ' . esc_attr( $status_props['text_color'] ) . ';" role="button" tabindex="0" data-order-id="' . esc_attr( (string) $order->get_id() ) . '"><span>' . esc_html( $status_props['label'] ) . '</span></mark>';
166 echo "<a href='#' class='fulfillments-trigger' data-order-id='" . esc_attr( $order->get_id() ) . "' title='" . esc_attr__( 'View Fulfillments', 'woocommerce' ) . "'>
167 <svg width='16' height='16' viewBox='0 0 12 14' xmlns='http://www.w3.org/2000/svg'>
168 <path d='M11.8333 2.83301L9.33329 0.333008L2.24996 7.41634L1.41663 10.7497L4.74996 9.91634L11.8333 2.83301ZM5.99996 12.4163H0.166626V13.6663H5.99996V12.4163Z' />
169 </svg>
170 </a>";
171 }
172
173 /**
174 * Render the shipment provider column row data.
175 *
176 * @param WC_Order $order The order object.
177 * @param array $fulfillments The fulfillments.
178 */
179 private function render_shipment_provider_column_row_data( WC_Order $order, array $fulfillments ) {
180 $providers = array();
181 foreach ( $fulfillments as $fulfillment ) {
182 $provider = $fulfillment->get_shipment_provider();
183 if ( ! empty( $provider ) ) {
184 $provider_name = $fulfillment->get_meta( '_provider_name' );
185 $key = 'other' === $provider && ! empty( $provider_name )
186 ? $provider . '::' . $provider_name
187 : $provider;
188 $providers[ $key ] = $fulfillment;
189 }
190 }
191
192 if ( count( $providers ) > 1 ) {
193 echo '<span>' . esc_html__( 'Multiple providers', 'woocommerce' ) . '</span>';
194 } elseif ( 1 === count( $providers ) ) {
195 $provider_fulfillment = reset( $providers );
196 $provider_slug = $provider_fulfillment->get_shipment_provider();
197 $known_providers = FulfillmentUtils::get_shipping_providers();
198 $provider_name_meta = $provider_fulfillment->get_meta( '_provider_name' );
199 $provider_display_label = isset( $known_providers[ $provider_slug ] )
200 ? $known_providers[ $provider_slug ]->get_name()
201 : ( ! empty( $provider_name_meta ) ? $provider_name_meta : $provider_slug );
202 echo '<span>' . esc_html( $provider_display_label ) . '</span>';
203 } else {
204 echo '<span>--</span>';
205 }
206 }
207
208 /**
209 * Render the shipment tracking column row data.
210 *
211 * @param WC_Order $order The order object.
212 * @param array $fulfillments The fulfillments.
213 */
214 private function render_shipment_tracking_column_row_data( WC_Order $order, array $fulfillments ) {
215 $tracking = array();
216 foreach ( $fulfillments as $fulfillment ) {
217 $number = $fulfillment->get_tracking_number();
218 if ( ! empty( $number ) ) {
219 $tracking[] = array(
220 'number' => $number,
221 'url' => $fulfillment->get_tracking_url(),
222 );
223 }
224 }
225
226 if ( count( $tracking ) > 1 ) {
227 echo '<span>' . esc_html__( 'Multiple trackings', 'woocommerce' ) . '</span>';
228 } elseif ( 1 === count( $tracking ) ) {
229 $entry = $tracking[0];
230 if ( ! empty( $entry['url'] ) ) {
231 echo '<a href="' . esc_url( $entry['url'] ) . '" target="_blank" rel="noopener noreferrer" style="text-decoration: underline; color: #2f2f2f;">' . esc_html( $entry['number'] ) . '</a>';
232 } else {
233 echo '<span>' . esc_html( $entry['number'] ) . '</span>';
234 }
235 } else {
236 echo '<span>--</span>';
237 }
238 }
239
240 /**
241 * Render the fulfillment drawer.
242 */
243 public function render_fulfillment_drawer_slot() {
244 if ( ! $this->should_render_fulfillment_drawer() ) {
245 return;
246 }
247 ?>
248 <div id="wc_order_fulfillments_panel_container"></div>
249 <?php
250 }
251
252 /**
253 * Define bulk actions for fulfillments.
254 *
255 * @param array $actions Existing actions.
256 * @return array
257 */
258 public function define_fulfillment_bulk_actions( $actions ) {
259 $actions['fulfill'] = __( 'Mark as fulfilled', 'woocommerce' );
260
261 return $actions;
262 }
263
264 /**
265 * Handle bulk actions for fulfillments.
266 *
267 * @param string $redirect_to The redirect URL.
268 * @param string $action The action being performed.
269 * @param array $post_ids The post IDs being acted upon.
270 * @return string
271 */
272 public function handle_fulfillment_bulk_actions( $redirect_to, $action, $post_ids ) {
273 if ( 'fulfill' === $action ) {
274 FulfillmentsTracker::track_fulfillment_bulk_action_used( 'fulfill_orders', count( $post_ids ) );
275 foreach ( $post_ids as $post_id ) {
276 $order = wc_get_order( $post_id );
277 if ( ! $order ) {
278 continue;
279 }
280
281 $fulfillments = $this->maybe_read_fulfillments( $order );
282
283 // Fulfill all existing fulfillments.
284 foreach ( $fulfillments as $fulfillment ) {
285 $fulfillment->set_status( 'fulfilled' );
286 $fulfillment->save();
287 }
288
289 // Create a fulfillment for the order, containing all remaining items in the order.
290 $remaining_items = array_map(
291 function ( $item ) {
292 return array(
293 'item_id' => $item['item_id'],
294 'qty' => $item['qty'],
295 );
296 },
297 FulfillmentUtils::get_pending_items( $order, $fulfillments )
298 );
299
300 if ( 0 < count( $remaining_items ) ) {
301 $fulfillment = new Fulfillment();
302 $fulfillment->set_entity_type( WC_Order::class );
303 $fulfillment->set_entity_id( (string) $order->get_id() );
304 $fulfillment->set_status( 'fulfilled' );
305 $fulfillment->set_items( $remaining_items );
306 $fulfillment->save();
307 }
308 }
309 $redirect_to = add_query_arg( array( 'bulk_action' => $action ), $redirect_to );
310 }
311 return $redirect_to;
312 }
313
314 /**
315 * Render the fulfillment status text in the order details page and the order tracking page.
316 *
317 * @param string $order_status The order status text.
318 * @param WC_Order $order The order object.
319 *
320 * @return string The fulfillment status appended order status text.
321 */
322 public function render_fulfillment_status_text( string $order_status, WC_Order $order ): string {
323 $fulfillments = $this->maybe_read_fulfillments( $order );
324 $fulfillment_status = FulfillmentUtils::get_order_fulfillment_status_text( $order, $fulfillments );
325 return sprintf( '%s %s', $order_status, $fulfillment_status );
326 }
327
328 /**
329 * Render the fulfillment customer details in the order details page.
330 *
331 * @param WC_Order $order The order object.
332 */
333 public function render_fulfillment_customer_details( WC_Order $order ) {
334 $fulfillments = $this->maybe_read_fulfillments( $order );
335
336 if ( ! empty( $fulfillments ) ) {
337 ?>
338 <section class="woocommerce-order-details">
339 <table class="woocommerce-table woocommerce-table--order-details shop_table order_details">
340 <thead>
341 <?php
342 foreach ( $fulfillments as $index => $fulfillment ) {
343 if ( ! $fulfillment->get_is_fulfilled() ) {
344 continue;
345 }
346 ?>
347 <tr>
348 <th class="woocommerce-table__shipment-info shipment-info" style="font-weight: normal;">
349 <?php
350 // Use the UTC fulfilled date when available; fall back to date_updated.
351 $shipment_date_utc = $fulfillment->get_date_fulfilled()
352 ?? $fulfillment->get_date_updated();
353 // Append ' UTC' so strtotime treats the stored value as UTC, then render in the site's timezone.
354 $shipment_timestamp = $shipment_date_utc ? strtotime( $shipment_date_utc . ' UTC' ) : false;
355 $shipment_date_local = false !== $shipment_timestamp ? wp_date( 'F j, Y', $shipment_timestamp ) : '';
356 printf(
357 /* translators: %1$s is the shipment index, %2$s is the shipment date */
358 wp_kses( __( '<b>Shipment %1$s</b> was shipped on <b>%2$s</b>', 'woocommerce' ), 'b' ),
359 intval( $index ) + 1,
360 esc_html( (string) $shipment_date_local )
361 );
362 ?>
363 </th>
364 <th class="woocommerce-table__shipment-tracking shipment-tracking" style="font-weight: normal;">
365 <?php echo wp_kses( FulfillmentUtils::get_tracking_info_html( $fulfillment ), 'a' ); ?>
366 </th>
367 </tr>
368 <?php
369 }
370 ?>
371 </thead>
372 </table>
373 </section>
374 <?php
375 }
376 }
377
378 /**
379 * Render the fulfillment badges in the order details page.
380 *
381 * @param WC_Order $order The order object.
382 */
383 public function render_order_details_badges( WC_Order $order ) {
384 echo '<div class="wc-order-fulfillment-badges">';
385
386 // Get the fulfillment status for the order.
387 $fulfillments = $this->maybe_read_fulfillments( $order );
388 $order_fulfillment_status = FulfillmentUtils::calculate_order_fulfillment_status( $order, $fulfillments );
389
390 // Render order status badge.
391 $order_status = $order->get_status();
392 echo '<mark class="order-status status-' . esc_attr( $order_status ) . '"><span>' . esc_html( wc_get_order_status_name( $order_status ) ) . '</span></mark>';
393
394 // Render fulfillment status badge.
395 $this->render_order_fulfillment_status_badge( $order, $order_fulfillment_status );
396 echo '</div>';
397 }
398
399 /**
400 * Loads the fulfillments scripts and styles.
401 */
402 public function load_components() {
403 if ( ! $this->should_render_fulfillment_drawer() ) {
404 return;
405 }
406
407 $this->register_fulfillments_assets();
408 $this->load_fulfillments_js_settings();
409 }
410
411 /**
412 * Register the fulfillment assets.
413 */
414 protected function register_fulfillments_assets() {
415 WCAdminAssets::register_style( 'fulfillments', 'style', array( 'wp-components' ) );
416 WCAdminAssets::register_script( 'wp-admin-scripts', 'fulfillments', true );
417 }
418
419 /**
420 * Load the fulfillments JS settings.
421 *
422 * @return void
423 */
424 protected function load_fulfillments_js_settings() {
425 $providers_for_js = array();
426 foreach ( FulfillmentUtils::get_shipping_providers() as $provider ) {
427 $providers_for_js[ $provider->get_key() ] = array(
428 'label' => $provider->get_name(),
429 'icon' => $provider->get_icon(),
430 'value' => $provider->get_key(),
431 'url' => $provider->get_tracking_url( '__PLACEHOLDER__' ) ?? '',
432 );
433 }
434
435 $fulfillment_settings = array(
436 'providers' => $providers_for_js,
437 'currency_symbols' => get_woocommerce_currency_symbols(),
438 'fulfillment_statuses' => FulfillmentUtils::get_fulfillment_statuses(),
439 'order_fulfillment_statuses' => FulfillmentUtils::get_order_fulfillment_statuses(),
440 );
441
442 wp_localize_script( 'wc-admin-fulfillments', 'wcFulfillmentSettings', $fulfillment_settings );
443 }
444
445 /**
446 * Render the fulfillment filters in the orders table.
447 */
448 public function render_fulfillment_filters() {
449 if ( ! self::should_render_fulfillment_drawer() ) {
450 return;
451 }
452 ?>
453 <?php
454 // This is a read-only filter on the admin orders table, so nonce verification is not required.
455 // phpcs:ignore WordPress.Security.NonceVerification ?>
456 <?php $selected_status = isset( $_GET['fulfillment_status'] ) ? sanitize_text_field( wp_unslash( $_GET['fulfillment_status'] ) ) : ''; ?>
457 <select id="fulfillment-status-filter" name="fulfillment_status">
458 <option value="" <?php selected( $selected_status, '' ); ?>><?php esc_html_e( 'Filter by fulfillment', 'woocommerce' ); ?></option>
459 <?php foreach ( FulfillmentUtils::get_order_fulfillment_statuses() as $status => $props ) : ?>
460 <option value="<?php echo esc_attr( $status ); ?>" <?php selected( $selected_status, $status ); ?>>
461 <?php echo esc_html( $props['label'] ?? '' ); ?>
462 </option>
463 <?php endforeach; ?>
464 </select>
465 <?php
466 }
467
468 /**
469 * Render the fulfillment filters in the legacy orders table.
470 */
471 public function render_fulfillment_filters_legacy() {
472 global $typenow;
473
474 if ( 'shop_order' !== $typenow ) {
475 return;
476 }
477
478 $this->render_fulfillment_filters();
479 }
480
481 /**
482 * Apply the fulfillment status filter to the orders list.
483 *
484 * @param array $args The query arguments for the orders list.
485 * @return array The modified query arguments.
486 */
487 public function filter_orders_list_table_query( $args ) {
488 // This is a read-only filter on the admin orders table, so nonce verification is not required.
489 // phpcs:ignore WordPress.Security.NonceVerification
490 if ( isset( $_GET['fulfillment_status'] ) && ! empty( $_GET['fulfillment_status'] ) ) {
491 // phpcs:ignore WordPress.Security.NonceVerification
492 $fulfillment_status = sanitize_text_field( wp_unslash( $_GET['fulfillment_status'] ) );
493
494 // Ensure the fulfillment status is one of the allowed values.
495 if ( FulfillmentUtils::is_valid_order_fulfillment_status( $fulfillment_status ) ) {
496 // Only track when the filter is explicitly submitted, not on pagination/refresh.
497 if ( isset( $_GET['filter_action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
498 FulfillmentsTracker::track_fulfillment_filter_used( 'fulfillment_status', $fulfillment_status );
499 }
500 $meta_query = FulfillmentUtils::get_order_fulfillment_status_meta_query( $fulfillment_status );
501 if ( ! empty( $meta_query ) ) {
502 if ( ! isset( $args['meta_query'] ) ) {
503 $args['meta_query'] = array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
504 }
505 $args['meta_query'][] = $meta_query;
506 }
507 }
508 }
509
510 return $args;
511 }
512
513 /**
514 * Filter the legacy orders list query to include fulfillment status.
515 *
516 * @param \WP_Query $query The WP_Query object.
517 */
518 public function filter_legacy_orders_list_query( $query ) {
519 if (
520 is_admin()
521 && $query->is_main_query()
522 && $query->get( 'post_type' ) === 'shop_order'
523 && isset( $_GET['fulfillment_status'] ) && ! empty( $_GET['fulfillment_status'] ) // phpcs:ignore WordPress.Security.NonceVerification
524 ) {
525 $status = sanitize_text_field( wp_unslash( $_GET['fulfillment_status'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
526 // Ensure the fulfillment status is one of the allowed values.
527 if ( FulfillmentUtils::is_valid_order_fulfillment_status( $status ) ) {
528 // Only track when the filter is explicitly submitted, not on pagination/refresh.
529 if ( isset( $_GET['filter_action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
530 FulfillmentsTracker::track_fulfillment_filter_used( 'fulfillment_status', $status );
531 }
532 $query->set(
533 'meta_query',
534 'no_fulfillments' === $status ?
535 array(
536 'relation' => 'OR',
537 array(
538 'key' => '_fulfillment_status',
539 'compare' => 'NOT EXISTS',
540 ),
541 ) :
542 array(
543 array(
544 'key' => '_fulfillment_status',
545 'value' => $status,
546 'compare' => '=',
547 ),
548 )
549 );
550 }
551 }
552 }
553
554 /**
555 * Render the shipping provider filter dropdown in the orders table.
556 *
557 * @since 10.7.0
558 */
559 public function render_shipping_provider_filter(): void {
560 if ( ! self::should_render_fulfillment_drawer() ) {
561 return;
562 }
563
564 $providers = FulfillmentUtils::get_shipping_providers();
565
566 // This is a read-only filter on the admin orders table, so nonce verification is not required.
567 // phpcs:ignore WordPress.Security.NonceVerification
568 $selected_provider = isset( $_GET['shipping_provider'] ) ? sanitize_text_field( wp_unslash( $_GET['shipping_provider'] ) ) : '';
569 ?>
570 <select id="shipping-provider-filter" name="shipping_provider">
571 <option value="" <?php selected( $selected_provider, '' ); ?>><?php esc_html_e( 'Filter by shipping provider', 'woocommerce' ); ?></option>
572 <?php foreach ( $providers as $provider ) : ?>
573 <option value="<?php echo esc_attr( $provider->get_key() ); ?>" <?php selected( $selected_provider, $provider->get_key() ); ?>>
574 <?php echo esc_html( $provider->get_name() ); ?>
575 </option>
576 <?php endforeach; ?>
577 <option value="__other__" <?php selected( $selected_provider, '__other__' ); ?>><?php esc_html_e( 'Other', 'woocommerce' ); ?></option>
578 </select>
579 <?php
580 }
581
582 /**
583 * Render the shipping provider filter in the legacy orders table.
584 *
585 * @since 10.7.0
586 */
587 public function render_shipping_provider_filter_legacy(): void {
588 global $typenow;
589
590 if ( 'shop_order' !== $typenow ) {
591 return;
592 }
593
594 $this->render_shipping_provider_filter();
595 }
596
597 /**
598 * Filter orders by shipping provider for the HPOS orders list.
599 *
600 * @since 10.7.0
601 *
602 * @param array $args The query arguments for the orders list.
603 * @return array The modified query arguments.
604 */
605 public function filter_orders_by_shipping_provider( $args ) {
606 // This is a read-only filter on the admin orders table, so nonce verification is not required.
607 // phpcs:ignore WordPress.Security.NonceVerification
608 if ( ! isset( $_GET['shipping_provider'] ) || empty( $_GET['shipping_provider'] ) ) {
609 return $args;
610 }
611
612 // phpcs:ignore WordPress.Security.NonceVerification
613 $shipping_provider = sanitize_text_field( wp_unslash( $_GET['shipping_provider'] ) );
614 $order_ids = $this->get_order_ids_by_shipping_provider( $shipping_provider );
615
616 if ( empty( $order_ids ) ) {
617 $args['post__in'] = array( 0 );
618 } elseif ( isset( $args['post__in'] ) && is_array( $args['post__in'] ) ) {
619 $args['post__in'] = array_intersect( $args['post__in'], $order_ids );
620 if ( empty( $args['post__in'] ) ) {
621 $args['post__in'] = array( 0 );
622 }
623 } else {
624 $args['post__in'] = $order_ids;
625 }
626
627 return $args;
628 }
629
630 /**
631 * Filter legacy orders by shipping provider.
632 *
633 * @since 10.7.0
634 *
635 * @param \WP_Query $query The WP_Query object.
636 */
637 public function filter_legacy_orders_by_shipping_provider( $query ): void {
638 if (
639 ! is_admin()
640 || ! $query->is_main_query()
641 || 'shop_order' !== $query->get( 'post_type' )
642 // This is a read-only filter on the admin orders table, so nonce verification is not required.
643 // phpcs:ignore WordPress.Security.NonceVerification
644 || ! isset( $_GET['shipping_provider'] )
645 // phpcs:ignore WordPress.Security.NonceVerification
646 || empty( $_GET['shipping_provider'] )
647 ) {
648 return;
649 }
650
651 // phpcs:ignore WordPress.Security.NonceVerification
652 $shipping_provider = sanitize_text_field( wp_unslash( $_GET['shipping_provider'] ) );
653 $order_ids = $this->get_order_ids_by_shipping_provider( $shipping_provider );
654
655 if ( empty( $order_ids ) ) {
656 $query->set( 'post__in', array( 0 ) );
657 } else {
658 $existing = $query->get( 'post__in' );
659 if ( ! empty( $existing ) && is_array( $existing ) ) {
660 $order_ids = array_intersect( $existing, $order_ids );
661 if ( empty( $order_ids ) ) {
662 $order_ids = array( 0 );
663 }
664 }
665 $query->set( 'post__in', $order_ids );
666 }
667 }
668
669 /**
670 * Get order IDs that have fulfillments with a specific shipping provider.
671 *
672 * @since 10.7.0
673 *
674 * @param string $shipping_provider The shipping provider key to filter by.
675 * @return array Array of order IDs.
676 */
677 private function get_order_ids_by_shipping_provider( string $shipping_provider ): array {
678 global $wpdb;
679
680 $fulfillments_table = $wpdb->prefix . 'wc_order_fulfillments';
681 $meta_table = $wpdb->prefix . 'wc_order_fulfillment_meta';
682
683 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
684 if ( '__other__' === $shipping_provider ) {
685 $known_providers = FulfillmentUtils::get_shipping_providers();
686 $known_keys = array_keys( $known_providers );
687
688 if ( empty( $known_keys ) ) {
689 $results = $wpdb->get_col(
690 $wpdb->prepare(
691 "SELECT DISTINCT f.entity_id
692 FROM {$fulfillments_table} f
693 INNER JOIN {$meta_table} m ON f.fulfillment_id = m.fulfillment_id
694 WHERE m.meta_key = %s
695 AND m.meta_value IS NOT NULL
696 AND m.meta_value != ''
697 AND f.date_deleted IS NULL
698 AND m.date_deleted IS NULL",
699 '_shipment_provider'
700 )
701 );
702 } else {
703 $placeholders = implode( ',', array_fill( 0, count( $known_keys ), '%s' ) );
704 $results = $wpdb->get_col(
705 $wpdb->prepare(
706 "SELECT DISTINCT f.entity_id
707 FROM {$fulfillments_table} f
708 INNER JOIN {$meta_table} m ON f.fulfillment_id = m.fulfillment_id
709 WHERE m.meta_key = '_shipment_provider'
710 AND m.meta_value NOT IN ({$placeholders})
711 AND m.meta_value IS NOT NULL
712 AND m.meta_value != ''
713 AND f.date_deleted IS NULL
714 AND m.date_deleted IS NULL",
715 ...array_map( 'wp_json_encode', $known_keys )
716 )
717 );
718 }
719 } else {
720 $results = $wpdb->get_col(
721 $wpdb->prepare(
722 "SELECT DISTINCT f.entity_id
723 FROM {$fulfillments_table} f
724 INNER JOIN {$meta_table} m ON f.fulfillment_id = m.fulfillment_id
725 WHERE m.meta_key = '_shipment_provider'
726 AND m.meta_value = %s
727 AND f.date_deleted IS NULL
728 AND m.date_deleted IS NULL",
729 wp_json_encode( $shipping_provider )
730 )
731 );
732 }
733 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
734
735 return array_map( 'absint', $results );
736 }
737
738 /**
739 * Check if the fulfillment drawer should be rendered (admin only).
740 *
741 * @return bool True if the fulfillment drawer should be rendered, false otherwise.
742 */
743 protected function should_render_fulfillment_drawer(): bool {
744 if ( ! is_admin() ) {
745 return false;
746 }
747
748 if ( ! function_exists( 'get_current_screen' ) ) {
749 return false;
750 }
751
752 $current_screen = get_current_screen();
753 if ( ! $current_screen || ! $current_screen->id ) {
754 return false;
755 }
756
757 return 'woocommerce_page_wc-orders' === $current_screen->id // HPOS screen.
758 || 'edit-shop_order' === $current_screen->id // Legacy screen.
759 || 'shop_order' === $current_screen->id; // Order details screen (legacy).
760 }
761
762 /**
763 * Fetches the fulfillments for the given order, caching them to avoid multiple fetches.
764 *
765 * @param WC_Order $order The order object.
766 *
767 * @return array The fulfillments for the order.
768 */
769 private function maybe_read_fulfillments( WC_Order $order ): array {
770 // Check if we've already fetched the fulfillments for this order.
771 if ( isset( $this->fulfillments_cache[ $order->get_id() ] ) ) {
772 return $this->fulfillments_cache[ $order->get_id() ];
773 }
774
775 // If not, fetch them and cache them.
776 try {
777 /**
778 * Fulfillments data store.
779 *
780 * @var \Automattic\WooCommerce\Admin\Features\Fulfillments\DataStore\FulfillmentsDataStore $data_store
781 */
782 $data_store = \WC_Data_Store::load( 'order-fulfillment' );
783 $fulfillments = $data_store->read_fulfillments( WC_Order::class, '' . $order->get_id() );
784 } catch ( \Throwable $e ) {
785 wc_get_logger()->error(
786 sprintf( 'Failed to load fulfillments for order %d: %s', $order->get_id(), $e->getMessage() ),
787 array( 'source' => 'fulfillments' )
788 );
789 $fulfillments = array();
790 }
791 $this->fulfillments_cache[ $order->get_id() ] = $fulfillments;
792
793 return $fulfillments;
794 }
795 }
796