403 ) ); }
check_admin_referer( self::ACTION );
$raw = isset( $_GET['order_ids'] ) ? array_map( 'absint', (array) wp_unslash( $_GET['order_ids'] ) ) : array();
$ids = array_values( array_unique( array_filter( $raw ) ) );
if ( ! $ids || count( $ids ) > 100 ) { wp_die( esc_html( I18n::t( 'Select 1–100 orders.' ) ), 400 ); }
// A selection with nothing to print (every order closed) says so instead of failing (review 0.59).
try { $body = self::body( $ids ); } catch ( \Throwable $e ) { wp_die( esc_html( $e->getMessage() ), '', array( 'response'=>400, 'back_link'=>true ) ); }
nocache_headers();
header( 'Content-Type: text/html; charset=' . get_option( 'blog_charset' ) );
PackingSlips::render( 'storesuit-pick-list', I18n::t( 'Pick list' ), self::CSS, $body );
exit;
}
/**
* The list: rows of product, SKU, what the product is (variation attributes), units to pick and the orders with their
* units; the orders that count and those left out. Up to 100 orders.
*/
public static function build( array $ids ): array {
Store::require_cap( 'storesuit_orders' );
$rows = array(); $orders = array(); $skipped = array();
foreach ( array_slice( array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) ), 0, 100 ) as $id ) {
$order = wc_get_order( $id );
if ( ! $order instanceof \WC_Order || $order->get_type() !== 'shop_order' ) { continue; }
if ( $order->has_status( array( 'cancelled', 'failed', 'refunded', 'trash', 'checkout-draft' ) ) || SaleStatus::quote( $order ) ) { $skipped[] = (string) $order->get_order_number(); continue; }
$progress = (array) ( $order->get_meta( '_storesuit_fulfilment' ) ?: array() ); $units = 0.0;
foreach ( $order->get_items() as $item_id => $item ) {
if ( ! $item instanceof \WC_Order_Item_Product ) { continue; }
$left = (float) $item->get_quantity() - abs( (float) $order->get_qty_refunded_for_item( $item_id ) ) - max( 0.0, (float) ( $progress[ $item_id ]['picked'] ?? 0 ) );
if ( $left <= 0 ) { continue; }
$product = $item->get_product(); $key = $item->get_variation_id() ?: $item->get_product_id() ?: 'item-' . $item_id;
if ( ! isset( $rows[ $key ] ) ) {
$rows[ $key ] = array( 'name'=>$product ? $product->get_name() : $item->get_name(), 'sku'=>$product ? (string) $product->get_sku() : '', 'gtin'=>$product && is_callable( array( $product, 'get_global_unique_id' ) ) ? (string) $product->get_global_unique_id() : '', 'what'=>self::attributes( $item ), 'qty'=>0.0, 'orders'=>array() );
}
$rows[ $key ]['qty'] += $left; $number = (string) $order->get_order_number();
$rows[ $key ]['orders'][ $number ] = ( $rows[ $key ]['orders'][ $number ] ?? 0 ) + $left; $units += $left;
}
$orders[] = array( 'number'=>(string) $order->get_order_number(), 'name'=>trim( $order->get_formatted_billing_full_name() ), 'delivery'=>$order->get_meta( '_storesuit_delivery' ) === 'pickup' ? 'pickup' : 'shipping', 'units'=>$units );
}
$rows = array_values( $rows );
usort( $rows, static fn( $a, $b ) => ( $a['sku'] === '' ) <=> ( $b['sku'] === '' ) ?: strnatcasecmp( $a['sku'], $b['sku'] ) ?: strnatcasecmp( $a['name'], $b['name'] ) );
return array( 'rows'=>$rows, 'orders'=>$orders, 'skipped'=>$skipped, 'units'=>array_sum( array_column( $rows, 'qty' ) ) );
}
/**
* When the list was printed, in the reader's language and the store's time zone ("9 Oct 2026, 14:09"), as the screens
* write dates (KIT-3); WordPress's own date format where PHP has no intl extension.
*/
private static function printed_at(): string {
if ( class_exists( '\IntlDateFormatter' ) ) {
$format = new \IntlDateFormatter( I18n::locale(), \IntlDateFormatter::MEDIUM, \IntlDateFormatter::SHORT, wp_timezone() );
$text = $format->format( time() );
if ( is_string( $text ) && $text !== '' ) { return $text; }
}
return wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) );
}
/** A variation's choices as the order line keeps them ("Colour: Red, Size: M"). */
private static function attributes( \WC_Order_Item_Product $item ): string {
$parts = array();
foreach ( $item->get_formatted_meta_data( '_', true ) as $meta ) { $parts[] = wp_strip_all_tags( (string) $meta->display_key ) . ': ' . wp_strip_all_tags( (string) $meta->display_value ); }
return implode( ', ', $parts );
}
/** The complete printed list of the selected orders (what print_page() prints). */
public static function document( array $ids ): string {
return PackingSlips::capture( 'storesuit-pick-list', I18n::t( 'Pick list' ), self::CSS, self::body( $ids ) );
}
/** The list's body: the toolbar with its Print button, the rows to pick, the orders in the list and those left out. */
private static function body( array $ids ): string {
$list = self::build( $ids );
if ( ! $list['orders'] ) { throw new \InvalidArgumentException( esc_html( I18n::t( 'No orders found to print.' ) ) ); }
$qty = static fn( $n ) => esc_html( wc_stock_amount( $n ) );
$body = '';
foreach ( $list['rows'] as $row ) {
$orders = array(); foreach ( $row['orders'] as $number => $n ) { $orders[] = '#' . esc_html( $number ) . ' × ' . $qty( $n ); }
$body .= '
| ' . $qty( $row['qty'] ) . ' | ' . esc_html( $row['name'] ) . '' . ( $row['what'] !== '' ? '' . esc_html( $row['what'] ) . '' : '' ) . ' | ' . esc_html( $row['sku'] ) . ( $row['gtin'] !== '' ? '' . esc_html( $row['gtin'] ) . '' : '' ) . ' | ' . implode( ', ', $orders ) . ' |
';
}
$order_rows = '';
foreach ( $list['orders'] as $o ) { $order_rows .= '#' . esc_html( $o['number'] ) . ' ' . esc_html( $o['name'] ) . ' · ' . esc_html( $o['delivery'] === 'pickup' ? I18n::t( 'Store pickup' ) : I18n::t( 'Shipping' ) ) . ' · ' . esc_html( I18n::n( '%s unit to pick', '%s units to pick', wc_stock_amount( $o['units'] ) ) ) . ''; }
return ''
. '' . esc_html( I18n::t( 'Pick list' ) ) . '
' . esc_html( implode( ' · ', array( I18n::n( '%s order', '%s orders', count( $list['orders'] ) ), I18n::n( '%s product', '%s products', count( $list['rows'] ) ), I18n::n( '%s unit', '%s units', wc_stock_amount( $list['units'] ) ), self::printed_at() ) ) ) . '
'
. ( $list['rows'] ? '| ' . esc_html( I18n::t( 'Picked' ) ) . ' | ' . esc_html( I18n::t( 'Units' ) ) . ' | ' . esc_html( I18n::t( 'Product' ) ) . ' | ' . esc_html( I18n::t( 'SKU' ) ) . ' | ' . esc_html( I18n::t( 'For orders' ) ) . ' |
' . $body . '
' : '' . esc_html( I18n::t( 'Everything in these orders is already picked.' ) ) . '
' )
. '' . esc_html( I18n::t( 'Orders in this list' ) ) . '
'
. ( $list['skipped'] ? '' . esc_html( I18n::format( 'Left out (closed): %s', '#' . implode( ', #', $list['skipped'] ) ) ) . '
' : '' )
. '';
}
}