'New', 'preparing' => 'Preparing', 'ready' => 'Ready to ship', 'done' => 'Completed', 'closed' => 'Cancelled / returned', ); /** * A sale at the register leaves with the customer at the counter: nothing to pick, pack or ship (0.61.1). A POS return is * not a sale. Any till's sale counts (Orders::store_sale(), Almira audit SAT-2); one with a shipping line was sold in the * store and is sent to the customer, so it keeps the shipping flow. */ public static function counter( \WC_Order $order ) : bool { return Orders::store_sale( $order ) && !$order->get_shipping_methods(); } public static function read( \WC_Order $order ) : array { $all = (array) (( $order->get_meta( '_storesuit_fulfilment' ) ?: array() )); $totals = array( 'quantity' => 0.0, 'picked' => 0.0, 'packed' => 0.0, 'shipped' => 0.0, ); foreach ( $order->get_items() as $id => $item ) { $qty = max( 0, (float) $item->get_quantity() - abs( $order->get_qty_refunded_for_item( $id ) ) ); $totals['quantity'] += $qty; foreach ( array('picked', 'packed', 'shipped') as $key ) { $totals[$key] += min( $qty, max( 0, (float) ($all[$id][$key] ?? 0) ) ); } } $has = $totals['quantity'] > 0; $shipped = $has && $totals['shipped'] >= $totals['quantity']; $delivered = (array) (( $order->get_meta( '_storesuit_delivery_confirmed' ) ?: array() )); $shipments = (array) (( $order->get_meta( '_storesuit_shipments' ) ?: array() )); $handover = $shipped && count( $shipments ) > 0; foreach ( $shipments as $s ) { if ( ($s['mode'] ?? '') !== 'handover' ) { $handover = false; break; } } if ( SaleStatus::quote( $order ) ) { $stage = SaleStatus::QUOTE_STAGE; } elseif ( $order->has_status( array( 'cancelled', 'failed', 'refunded', 'checkout-draft', 'trash' ) ) ) { $stage = 'closed'; } elseif ( self::counter( $order ) ) { $stage = 'done'; } elseif ( $shipped ) { $stage = 'done'; } elseif ( $order->has_status( 'completed' ) || Orders::shipped_status( $order ) ) { $stage = 'done'; } elseif ( $has && $totals['packed'] >= $totals['quantity'] ) { $stage = 'ready'; } elseif ( $order->get_meta( '_storesuit_preparation_started' ) || $totals['picked'] > 0 || $totals['packed'] > 0 || $totals['shipped'] > 0 ) { $stage = 'preparing'; } else { $stage = 'new'; } $returns = 0; foreach ( (array) (( $order->get_meta( '_storesuit_returns' ) ?: array() )) as $r ) { if ( in_array( $r['status'] ?? '', array('requested', 'approved'), true ) ) { ++$returns; } } $withdrawal = Operations::pending_withdrawal( $order ); if ( $withdrawal ) { ++$returns; } $abandoned = Orders::abandoned_checkout( $order ); $open = ( $stage !== 'closed' && $stage !== SaleStatus::QUOTE_STAGE && !$abandoned ? Orders::outstanding( $order ) : 0.0 ); $next = 'view'; $next_label = 'Review order'; if ( $stage === SaleStatus::QUOTE_STAGE ) { $next_label = 'Review quote'; } elseif ( $withdrawal ) { $next = 'view'; $next_label = 'Review customer return request'; } elseif ( $returns ) { $next = 'returns'; $next_label = 'Manage return'; } elseif ( $stage === 'new' && $order->has_status( 'pending' ) ) { $next = 'checkout'; $next_label = ( $abandoned ? 'Checkout not completed' : 'Waiting for the customer\'s payment' ); } elseif ( $stage === 'new' && $has ) { $next = 'start'; $next_label = 'Start preparing'; } elseif ( $stage === 'preparing' ) { $next = 'ready'; $next_label = 'Mark as ready'; } elseif ( $stage === 'ready' ) { $next = 'prepare'; $next_label = ( $order->get_meta( '_storesuit_delivery' ) === 'pickup' ? 'Record in-person handover' : 'Record delivery' ); } elseif ( $open > 0 ) { $next = 'payment'; $next_label = 'Track payment'; // The free build's cash on delivery is paid when its delivery is confirmed (ORD-2, OD-3). if ( $stage === 'done' && $shipped && !$delivered && !$handover && Orders::cod_paid_on_delivery( $order ) ) { $next = 'delivered'; $next_label = 'Confirm delivery'; } } $stage_label = ( $stage === 'ready' && $order->get_meta( '_storesuit_delivery' ) === 'pickup' ? 'Ready for pickup' : (( $stage === SaleStatus::QUOTE_STAGE ? 'Quote' : self::STAGES[$stage] )) ); // Handed to the carrier while money is open (ORD-3, P1-04): the stage stays 'done' (owner item 40; the index counts it so), // only its name says that the payment is still open. if ( $stage === 'done' && $open > 0 && !self::counter( $order ) ) { $stage_label = ( !$shipped ? 'Completed · payment open' : (( $handover ? 'Handed over · payment open' : (( Orders::is_cod( $order ) ? 'Shipped · collect on delivery' : 'Shipped · payment open' )) )) ); } return array( 'stage' => $stage, 'label' => I18n::t( $stage_label ), 'next' => $next, 'next_label' => I18n::t( $next_label ), 'totals' => $totals, 'open_returns' => $returns, 'withdrawal_requested' => $withdrawal, 'delivery_confirmed' => ( $delivered ?: null ), 'partial_shipment' => $totals['shipped'] > 0 && !$shipped, 'counter' => self::counter( $order ), ); } /** * The same step for several orders at once (0.58): each order is checked and moved as on its own, in its own * transaction; one that cannot take the step is skipped with the reason and holds no other back. Each order's key comes * from the request's key, so sending the same request again moves nothing twice. */ public static function bulk( array $data ) : array { Store::require_cap( 'storesuit_fulfil' ); $operation = Store::text( $data, 'operation' ); if ( !in_array( $operation, array('start', 'ready', 'ship'), true ) ) { throw new \InvalidArgumentException(I18n::t( 'Choose a valid order step.' )); } if ( empty( $data['confirmed'] ) ) { throw new \InvalidArgumentException(I18n::t( 'Review and confirm the action.' )); } $key = Store::text( $data, 'request_key' ); if ( !preg_match( '/^[a-zA-Z0-9_-]{16,80}$/', $key ) ) { throw new \InvalidArgumentException(I18n::t( 'The action key is missing.' )); } $ids = array_values( array_unique( array_filter( array_map( 'absint', ( is_array( $data['ids'] ?? null ) ? $data['ids'] : array() ) ) ) ) ); if ( !$ids || count( $ids ) > 100 ) { throw new \InvalidArgumentException(I18n::t( 'Select between 1 and 100 orders.' )); } // Shipping several orders (ORD-3, P3-07): one carrier and an optional expected date for all, no tracking number; checked once // before any order moves. Unpaid prepaid orders go only when the request says so. $shipment = array(); if ( $operation === 'ship' ) { $shipment = array( 'carrier' => Store::text( $data, 'carrier' ), 'carrier_other' => Store::text( $data, 'carrier_other' ), 'expected_delivery' => Store::text( $data, 'expected_delivery' ), 'ship_unpaid' => ( empty( $data['ship_unpaid'] ) ? 0 : 1 ), ); if ( $shipment['carrier'] === '' || $shipment['carrier'] === 'other' && $shipment['carrier_other'] === '' ) { throw new \InvalidArgumentException(I18n::t( 'Enter the carrier or courier name.' )); } Shipping::details( $shipment ); // The carrier and the date are valid, or nothing is shipped. } $done = array(); $skipped = array(); $skip = static function ( int $id, $order, string $why ) use(&$skipped) { $skipped[] = array( 'id' => $id, 'number' => ( $order instanceof \WC_Order ? (string) $order->get_order_number() : (string) $id ), 'reason' => $why, ); }; foreach ( $ids as $id ) { $order_key = substr( hash( 'sha256', $key . ':' . $id ), 0, 40 ); // A plain read first: only an order that can take the step (or already took it with this key) enters advance() and // its locks, so orders already further on never hold the store's money lock or flush the cache (review 0.58 M8). $order = wc_get_order( $id ); if ( !$order instanceof \WC_Order || $order->get_type() !== 'shop_order' ) { $skip( $id, null, I18n::t( 'Order not found.' ) ); continue; } $history = (array) (( $order->get_meta( ( $operation === 'ship' ? '_storesuit_fulfil_keys' : '_storesuit_flow_keys' ) ) ?: array() )); $taken = ( $operation === 'ship' ? in_array( $order_key, $history, true ) : isset( $history[$order_key] ) ); $why = ( $taken ? '' : self::bulk_refusal( $order, $operation, $shipment ) ); if ( $why !== '' ) { $skip( $id, $order, $why ); continue; } try { // Each order in its own transaction, under its own lock, as the order screen's Ship order (Operations::ship_order()). if ( $operation === 'ship' ) { Operations::fulfil( $id, array_merge( $shipment, array( 'stage' => 'ship_order', 'request_key' => $order_key, ) ) ); } else { self::advance( $id, array( 'operation' => $operation, 'confirmed' => 1, 'request_key' => $order_key, ) ); } $done[] = $id; } catch ( \Throwable $e ) { $skip( $id, wc_get_order( $id ), $e->getMessage() ); } } if ( $done ) { Store::audit( 'order', 0, 'flow_bulk_' . $operation, array( 'done' => count( $done ), 'skipped' => count( $skipped ), ) ); } return array( 'operation' => $operation, 'done' => $done, 'skipped' => $skipped, ); } /** Why an order cannot take a bulk step, read without locks ('' when it can); advance() checks the same under its locks. */ private static function bulk_refusal( \WC_Order $order, string $operation, array $shipment = array() ) : string { $flow = self::read( $order ); if ( $operation === 'start' ) { return ( $flow['stage'] !== 'new' || $order->has_status( 'pending' ) || $flow['totals']['quantity'] <= 0 || $flow['open_returns'] ? I18n::t( 'The order is no longer eligible to start preparation or has an open return. Refresh the view.' ) : '' ); } if ( $operation === 'ship' ) { if ( $flow['stage'] !== 'ready' || $flow['totals']['quantity'] <= 0 ) { return I18n::t( 'The order is not ready to ship. Refresh the view.' ); } if ( (( $order->get_meta( '_storesuit_delivery' ) ?: 'shipping' )) === 'pickup' ) { return I18n::t( 'Use the in-person handover step for a store-pickup order.' ); } if ( $flow['open_returns'] ) { return I18n::t( 'Resolve the open return request first.' ); } if ( empty( $shipment['ship_unpaid'] ) && Orders::unpaid_prepaid( $order ) ) { return I18n::format( 'Payment not received yet (%s open). Ship it from the order, or tick the option to ship unpaid orders too.', Orders::money_text( Orders::outstanding( $order ), $order->get_currency() ) ); } return ''; } return ( $flow['stage'] !== 'preparing' || $flow['totals']['quantity'] <= 0 || $flow['open_returns'] ? I18n::t( 'The order cannot be marked ready or has an open return. Refresh the view.' ) : '' ); } public static function advance( int $id, array $data ) : array { Store::require_cap( 'storesuit_fulfil' ); $action = Store::text( $data, 'operation' ); if ( !in_array( $action, array('start', 'ready', 'delivered'), true ) ) { throw new \InvalidArgumentException(I18n::t( 'Choose a valid order step.' )); } if ( empty( $data['confirmed'] ) ) { throw new \InvalidArgumentException(I18n::t( 'Review and confirm the action.' )); } return Store::atomic( static function () use($id, $data, $action) { return Store::lock( 'order-' . $id, static function () use($id, $data, $action) { $o = Orders::fresh( $id ); $key = Store::text( $data, 'request_key' ); if ( !preg_match( '/^[a-zA-Z0-9_-]{16,80}$/', $key ) ) { throw new \InvalidArgumentException(I18n::t( 'The action key is missing.' )); } $history = (array) (( $o->get_meta( '_storesuit_flow_keys' ) ?: array() )); if ( isset( $history[$key] ) ) { if ( $history[$key] !== $action ) { throw new \InvalidArgumentException(I18n::t( 'This action key was used for another step.' )); } return Orders::summary( $o ); } $flow = self::read( $o ); $reference = ''; if ( $action === 'start' ) { if ( $flow['stage'] !== 'new' || $o->has_status( 'pending' ) || $flow['totals']['quantity'] <= 0 || $flow['open_returns'] ) { throw new \InvalidArgumentException(I18n::t( 'The order is no longer eligible to start preparation or has an open return. Refresh the view.' )); } $o->update_meta_data( '_storesuit_preparation_started', array( 'at' => gmdate( 'c' ), 'actor' => get_current_user_id(), ) ); } elseif ( $action === 'ready' ) { if ( $flow['stage'] !== 'preparing' || $flow['totals']['quantity'] <= 0 || $flow['open_returns'] ) { throw new \InvalidArgumentException(I18n::t( 'The order cannot be marked ready or has an open return. Refresh the view.' )); } $all = (array) (( $o->get_meta( '_storesuit_fulfilment' ) ?: array() )); foreach ( $o->get_items() as $item_id => $item ) { $qty = max( 0, (float) $item->get_quantity() - abs( $o->get_qty_refunded_for_item( $item_id ) ) ); $line = $all[$item_id] ?? array( 'picked' => 0, 'packed' => 0, 'shipped' => 0, ); $line['picked'] = $qty; $line['packed'] = $qty; $line['shipped'] = min( $qty, max( 0, (float) ($line['shipped'] ?? 0) ) ); $all[$item_id] = $line; } $o->update_meta_data( '_storesuit_fulfilment', $all ); $o->update_meta_data( '_storesuit_ready_confirmed', array( 'at' => gmdate( 'c' ), 'actor' => get_current_user_id(), ) ); } else { $reference = Store::text( $data, 'reference' ); $all_shipped = $flow['totals']['quantity'] > 0 && $flow['totals']['shipped'] >= $flow['totals']['quantity']; if ( !$all_shipped || !empty( $flow['delivery_confirmed'] ) || $reference === '' ) { throw new \InvalidArgumentException(I18n::t( 'Ship all products first and enter the recipient or delivery confirmation reference.' )); } if ( $flow['open_returns'] ) { throw new \InvalidArgumentException(I18n::t( 'Resolve the open return request first.' )); } $o->update_meta_data( '_storesuit_delivery_confirmed', array( 'at' => gmdate( 'c' ), 'actor' => get_current_user_id(), 'reference' => $reference, ) ); } $history[$key] = $action; $o->update_meta_data( '_storesuit_flow_keys', $history ); $o->save(); // Delivered and nothing left to pay (the free build's cash on delivery is paid by this confirmation, ORD-2): the order is // complete, as shipping a paid order completes it (Operations::ship_order()). if ( $action === 'delivered' && !$o->has_status( 'completed' ) && Orders::outstanding( $o ) <= 0 && Operations::all_shipped( $o ) && !Operations::return_open( $o ) ) { $o->update_status( 'completed', I18n::t( 'StoreRuler: delivery confirmed and nothing is left to pay; the order is complete.' ) ); } Store::audit( 'order', $id, 'flow_' . $action, array( 'reference' => Store::text( $data, 'reference' ), ) ); return Orders::summary( $o ); } ); } ); } }