'Order', 'product' => 'Product', 'purchase' => 'Purchase', 'customer' => 'Customer', 'category' => 'Category', 'payment_notice' => 'Payment notice', 'task' => 'Task', 'user' => 'Team member', 'settings' => 'Settings', 'campaign' => 'Campaign', 'brand' => 'Brand', ); private const EVENTS = array( 'seo_short_description_draft' => 'Short description saved from a draft', 'seo_short_description_clear' => 'Templated short description cleared', 'seo_category_description_draft' => 'Category description saved from a draft', 'seo_orphan_draft' => 'Orphan page set to draft', 'seo_orphan_redirect' => 'Orphan page set to draft and redirected', 'health_jobs_fail_stuck' => 'Stuck background jobs marked failed', 'health_jobs_released' => 'Background jobs that stopped half way marked failed', 'health_timezone_set' => 'Store time zone set', 'health_till_returns_marked' => 'Another till\'s return documents marked as returns', 'locale_formats' => 'Date and time formats set for the site language', 'health_page_draft' => 'Empty page left from the previous theme set to draft', 'health_page_redirect' => 'Empty page left from the previous theme set to draft and redirected', 'health_template_default' => 'Missing page template replaced with the default', 'seo_title_suggested' => 'Damaged SEO title replaced with a suggested one', 'seo_title_template' => 'Damaged SEO title emptied for the title template', 'seo_robots_move' => 'robots.txt rules moved into WordPress', 'seo_robots_restore' => 'robots.txt file put back', 'inventory_moved' => 'Stock movement recorded', 'log_trimmed' => 'Log file cut back', 'phone_owner_set' => 'Phone number owner chosen', 'payment_notice_closed' => 'Payment notice closed', 'team_member_added' => 'Team member added', 'credit_limit_approved' => 'Sale over the credit limit approved', 'merchant_quote_created' => 'Merchant quote draft created', 'merchant_quote_sent' => 'Merchant quote sent', 'merchant_quote_send_failed' => 'Merchant quote email failed', 'cancelled' => 'Cancelled', 'order_edited' => 'Order edited', 'order_email_updated' => 'Order email updated', 'quick_edited' => 'Product updated', 'family_prices_updated' => 'Option prices updated', 'bulk_edited' => 'Products updated in bulk', 'cost_changed' => 'Purchase cost updated', 'category_created' => 'Category created', 'bundle_recipe_updated' => 'Package recipe updated', 'bundle_built' => 'Package prepared', 'bundle_disassembled' => 'Package disassembled', 'availability_reviewed' => 'Availability notice reviewed', 'payment_recorded' => 'Collection recorded', 'account_collection' => 'Customer payment recorded', 'payment_notice_created' => 'Payment notice received', 'payment_notice_approved' => 'Payment notice approved', 'payment_notice_rejected' => 'Payment notice rejected', 'invoice_info_updated' => 'Invoice information updated', 'shipment_tracking_added' => 'Tracking number added', 'shipment_tracking_corrected' => 'Tracking number corrected', 'handover' => 'Handed over', 'fulfil_shipped' => 'Order shipped', 'return_request' => 'Return requested', 'return_approve' => 'Return approved', 'return_confirm' => 'Return completed', 'return_reject' => 'Return rejected', 'customer_withdrawal_reviewed' => 'Customer return request reviewed', 'received' => 'Products received', 'receipt_reconciled' => 'Receipt corrected', 'landed_allocated' => 'Additional purchase costs allocated', 'customer_profile_updated' => 'Customer profile updated', 'customer_note_added' => 'Customer note added', 'customer_list_saved' => 'Customer list saved', 'customer_list_removed' => 'Customer list removed', 'order_view_saved' => 'Team order view saved', 'order_view_removed' => 'Team order view removed', 'customer_privacy_erased' => 'Customer notes and preferences erased', 'saved_view_saved' => 'Team view saved', 'saved_view_removed' => 'Team view removed', 'carrier_rates_quoted' => 'Shipping rates received', 'carrier_label_purchased' => 'Shipping label bought', 'carrier_label_uncertain' => 'Shipping label purchase needs a check', 'carrier_label_settled_bought' => 'Shipping label settled by hand as bought', 'carrier_label_settled_not_bought' => 'Shipping label settled by hand as not bought', 'carrier_label_closed_paid' => 'Shipping label closed by hand, stays paid', 'flow_bulk_start' => 'Preparation started for several orders', 'flow_bulk_ready' => 'Several orders marked as ready', 'delivery_refused' => 'Delivery not taken; order closed', 'risk_trusted' => 'Customer marked as trusted', 'risk_untrusted' => 'Trusted mark removed', 'risk_settings_saved' => 'Risky customer rules changed', 'sales_quota_saved' => 'Sales quota saved', 'sales_quota_removed' => 'Sales quota removed', 'store_credit_changed' => 'Store credit changed', 'card_part_settled' => 'Gift card part of a refund settled', 'license_saved' => 'License key changed', 'redirect_saved' => 'Redirect saved', 'redirects_imported' => 'Redirects imported', 'not_found_cleared' => 'Not-found list cleared', 'carrier_label_manual_overruled' => 'The provider\'s answer replaced a shipping label settled by hand', 'carrier_label_void_requested' => 'Shipping label cancellation requested', 'carrier_label_settings_saved' => 'Shipping label settings saved', 'trade_profile_updated' => 'Wholesale terms updated', 'price_rules_updated' => 'Pricing rules updated', 'team_member_created' => 'Team member added', 'team_role_changed' => 'Team role changed', 'team_member_removed' => 'Team member removed', 'team_refund_on' => 'Returns and refunds turned on for a team member', 'team_refund_off' => 'Returns and refunds turned off for a team member', 'team_access_on' => 'A permission turned on for a team member', 'team_access_off' => 'A permission turned off for a team member', 'automation_settings_updated' => 'Automation settings updated', 'automation_paid_to_preparing' => 'Order moved to preparation', 'automation_failed' => 'Automation needs attention', 'automation_low_stock_cleared' => 'Stock reminder cleared', 'automation_low_stock_task' => 'Stock reminder created', 'automation_shipment_email' => 'Tracking email sent', 'task_updated' => 'Task updated', 'entry_bill_added' => 'Product entry written on a purchase invoice', 'purchase_invoice_number' => 'Supplier invoice number entered', 'entry_bills_on' => 'Purchase invoices from product entry switched on', 'entry_bills_off' => 'Purchase invoices from product entry switched off', 'settings_backup_restored' => 'Settings backup restored', 'staff_stock_edit_on' => 'Store Staff may change stock', 'staff_stock_edit_off' => 'Store Staff may no longer change stock', 'catalog_export_started' => 'Catalog export started', 'catalog_export_ready' => 'Catalog export ready', 'accounting_export_started' => 'Accounting exports started', 'accounting_export_ready' => 'Accounting exports ready', 'accounting_export_failed' => 'Accounting exports could not be prepared', 'accounting_export_downloaded' => 'Accounting export downloaded', 'gift_card_issued' => 'Gift card created', 'gift_card_sent' => 'Gift card emailed', 'gift_card_adjusted' => 'Gift card balance changed', 'gift_card_disabled' => 'Gift card switched off', 'gift_card_replaced' => 'Gift card replaced', 'gift_card_sale_refunded' => 'Gift card sale refunded', 'marketplace_details_saved' => 'Marketplace details saved', 'marketplace_details_removed' => 'Marketplace details removed', 'marketplace_connected' => 'Marketplace connected', 'marketplace_fetch_started' => 'Marketplace listings read started', 'marketplace_fetch_done' => 'Marketplace listings read', 'marketplace_match_changed' => 'Marketplace listing matched', 'marketplace_orders_on' => 'Marketplace order import switched on', 'marketplace_stock_on' => 'Marketplace stock push switched on', 'marketplace_stock_off' => 'Marketplace stock push switched off', 'marketplace_orders_off' => 'Marketplace order import switched off', 'marketplace_restocked' => 'Marketplace units put back in stock', 'marketplace_package_retried' => 'Marketplace package imported again', 'marketplace_price_rule_saved' => 'Marketplace price rule saved', 'marketplace_price_on' => 'Marketplace price push switched on', 'marketplace_price_off' => 'Marketplace price push switched off', 'marketplace_price_blocklist' => 'Marketplace price blocklist changed', 'marketplace_prices_sent' => 'Marketplace prices sent', 'marketplace_status_settings' => 'Marketplace status updates changed', 'marketplace_unsupplied' => 'Reported to the marketplace as not supplied', 'marketplace_status_retry' => 'Marketplace status sent again', 'marketplace_identity_saved' => 'Marketplace barcode field changed', 'marketplace_category_mapped' => 'Marketplace category matched', 'marketplace_attribute_mapped' => 'Marketplace attribute matched', 'marketplace_brand_mapped' => 'Marketplace brand matched', 'marketplace_listings_created' => 'Marketplace listings created', 'marketplace_listing_reopened' => 'Refused marketplace listing reopened', 'marketplace_content_updated' => 'Marketplace listing updates queued', 'marketplace_content_follow_on' => 'Keeping marketplace content in step switched on', 'marketplace_content_follow_off' => 'Keeping marketplace content in step switched off', 'marketplace_content_followed' => 'Changed content queued for the marketplace', 'marketplace_listing_fixed' => 'Rejected marketplace listing fix queued', 'marketplace_listings_forgotten' => 'Listings of a previous marketplace account forgotten', 'geliver_token_saved' => 'Geliver token saved', 'geliver_token_removed' => 'Geliver token removed', 'geliver_connected' => 'Geliver connected', 'sendcloud_keys_saved' => 'Sendcloud keys saved', 'sendcloud_keys_removed' => 'Sendcloud keys removed', 'sendcloud_connected' => 'Sendcloud connected', 'kargonomi_token_saved' => 'Kargonomi token saved', 'kargonomi_token_removed' => 'Kargonomi token removed', 'kargonomi_connected' => 'Kargonomi access checked', 'carrier_label_tracking_added' => 'Label tracking number added', 'catalog_export_failed' => 'Catalog export failed', 'suite_upgraded' => 'StoreRuler updated', 'campaign_created' => 'Campaign created', 'campaign_updated' => 'Campaign updated', 'campaign_paused' => 'Campaign paused', 'campaign_resumed' => 'Campaign resumed', 'feeds_enabled' => 'Product feeds turned on', 'feeds_disabled' => 'Product feeds turned off', 'feeds_key_rotated' => 'Feed addresses replaced', 'feed_google_category_updated' => 'Google feed category changed', 'stock_alerts_enabled' => 'Back-in-stock requests turned on', 'stock_alerts_disabled' => 'Back-in-stock requests turned off', 'stock_alerts_sent' => 'Back-in-stock emails sent', 'cart_reminders_enabled' => 'Cart reminders turned on', 'cart_reminders_disabled' => 'Cart reminders turned off', 'catalog_source_added' => 'Supplier catalog added', 'catalog_source_removed' => 'Supplier catalog removed', 'catalog_run_started' => 'Supplier catalog run started', 'catalog_run_finished' => 'Supplier catalog run finished', 'catalog_run_undone' => 'Supplier catalog run undone', 'catalog_daily_failed' => 'Daily supplier catalog update failed', 'catalog_run_failed' => 'Supplier catalog run stopped', 'cod_blocked' => 'Cash on delivery stopped for this customer', 'cod_unblocked' => 'Cash on delivery offered again', 'payment_notice_customer' => 'Customer reported a bank transfer', 'owner_alert_settings_changed' => 'Notification choices updated', 'owner_push_device_removed' => 'Notification device removed', 'owner_push_reset' => 'Phone notifications reset', 'connection_credential_changed' => 'Connection credential updated', 'connection_credential_removed' => 'Connection credential removed', 'connection_key_verified' => 'Connection key verified', 'connection_job_queued' => 'Connection task queued', 'connection_job_complete' => 'Connection task completed', 'connection_job_failed' => 'Connection task needs attention', 'customer_message_submitted' => 'Customer email request submitted', 'order_tracking_settings_changed' => 'Order tracking settings changed', 'brand_created' => 'Brand created', 'brand_updated' => 'Brand updated', 'brand_deleted' => 'Brand deleted', 'brand_merge' => 'Brands merged', 'brand_append' => 'Brand added to products', 'brand_replace' => 'Product brands replaced', 'cart_reminder_manual' => 'Cart reminder requested', 'cart_reminders_permission_recorded' => 'Cart reminder permission reviewed', 'cart_signals_setting_updated' => 'Web cart summaries changed', 'customer_group_updated' => 'Customer group changed', 'demo_data_created' => 'Demo data created', 'demo_data_removed' => 'Demo data removed', 'existing_tracker_linked' => 'Existing tracking record linked', 'tracker_status_checked' => 'Shipment status checked', 'receiptless_return_unsettled' => 'Receiptless return could not be settled', 'review_prepare_deferred' => 'Review request preparation deferred', 'review_queue_unavailable' => 'Review request queue unavailable', 'review_replied' => 'Review answered', 'review_send_deferred' => 'Review request sending deferred', 'review_settings_saved' => 'Review request settings saved', 'reviews_bulk_approved' => 'Reviews approved in bulk', 'moderated_approve' => 'Review approved', 'moderated_pending' => 'Review held for review', 'moderated_spam' => 'Review marked as spam', 'moderated_reject' => 'Review moved to trash', 'moderated_restore' => 'Review restored', 'moderated_answer' => 'Question answered', 'seo_discarded' => 'SEO draft discarded', 'seo_started' => 'SEO update started', 'smart_category_saved' => 'Smart category saved', 'merchant_quote_send_uncertain' => 'Quote email needs checking', 'merchant_quote_discarded' => 'Unsent quote discarded', 'return_provider_unsaved' => 'Provider refund not saved', 'workflow_reopen' => 'Existing purchase draft opened unchanged', 'customer_details_updated' => 'Customer details updated', 'guest_order_linked' => 'Guest order linked', 'seo_finished' => 'SEO update finished', 'seo_undone' => 'SEO update undone', 'campaign_rule_checked' => 'Campaign product list checked at start', 'campaign_rule_unchecked' => 'Campaign product list could not be checked at start', 'fulfil_picked' => 'Products picked', 'fulfil_packed' => 'Products packed', 'return_refund' => 'Return processed', 'workflow_reverse' => 'Receiving reversal completed', 'parcel2go_purchases_on' => 'StoreRuler may buy Parcel2Go labels', 'parcel2go_purchases_off' => 'StoreRuler may no longer buy Parcel2Go labels', 'sendcloud_purchases_on' => 'StoreRuler may buy Sendcloud labels', 'sendcloud_purchases_off' => 'StoreRuler may no longer buy Sendcloud labels', 'geliver_purchases_on' => 'StoreRuler may buy Geliver labels', 'geliver_purchases_off' => 'StoreRuler may no longer buy Geliver labels', 'geliver_test_record_set_aside' => 'Geliver test shipment archived', 'sendcloud_home_approved' => 'Order switched to the customer\'s address (pickup point unavailable)', 'sendcloud_existing_shipment' => 'Sendcloud label not bought: the order already has a shipment', 'parcel2go_connection_failed' => 'Parcel2Go connection test failed', 'ai_draft_ready' => 'AI draft created', 'ai_draft_applied' => 'AI draft applied', 'product_content_saved' => 'Product content saved', 'product_content_undone' => 'Product content restored', 'review_photo_removed' => 'Review photo removed', 'seo_controls_applied' => 'SEO controls applied', 'product_ai_drafted' => 'AI draft created', 'product_ai_applied' => 'AI draft applied', 'product_ai_undone' => 'AI text undone', 'product_ai_connected' => 'OpenAI key connected', 'product_ai_disconnected' => 'OpenAI key removed', 'product_ai_limit_changed' => 'AI daily limit changed', ); /** Readable names for events of a record type that the list above does not name one by one. */ private const TYPE_FALLBACKS = array( 'order' => 'Sales record updated', 'product' => 'Product record updated', 'purchase' => 'Purchase updated', 'task' => 'Task updated', 'campaign' => 'Campaign updated', 'brand' => 'Brand updated', 'settings' => 'Store settings changed', ); /** The readable name of an activity event, for other screens (0.62: the home dashboard's recent activity). */ public static function event_label( string $event, string $type ) : string { return self::label( $event, $type ); } private static function label( string $event, string $type ) : string { if ( $event === 'created' ) { return I18n::t( ( $type === 'order' ? 'Order created' : (( $type === 'product' ? 'Product created' : 'Record created' )) ) ); } if ( isset( self::EVENTS[$event] ) ) { return I18n::t( self::EVENTS[$event] ); } // SearchSignals records its rules as the bare events "saved" and "removed". if ( $type === 'search_rule' && in_array( $event, array('saved', 'removed'), true ) ) { return I18n::t( ( $event === 'removed' ? 'Search rule removed' : 'Search rule saved' ) ); } foreach ( array( 'flow_' => 'Order stage updated', 'fulfil_' => 'Preparation updated', 'return_' => 'Return updated', 'workflow_' => 'Purchasing updated', ) as $prefix => $title ) { if ( str_starts_with( $event, $prefix ) ) { return I18n::t( $title ); } } if ( isset( self::TYPE_FALLBACKS[$type] ) ) { return I18n::t( self::TYPE_FALLBACKS[$type] ); } return I18n::t( 'Activity recorded' ); } /** * The latest entries for Home and Today (persona fixes REP-4): each with its readable name (label), what it was about * (detail: the order number, the product's or customer's name, never a bare "#26" that reads like an order number) and the * screen that opens it (url). The stock movements one order made (one row per order line, ProductActivity) are one entry: * "Order #107 · 3 products, −6 units (Web sale)". Read-only; at most 40 rows are read to fill $limit entries. */ public static function recent( int $limit = 8 ) : array { global $wpdb; $rows = DashboardTotals::rows( $wpdb->get_results( $wpdb->prepare( 'SELECT id,event,object_type,object_id,payload,created_at FROM %i ORDER BY id DESC LIMIT %d', $wpdb->prefix . 'storesuit_events', 40 ), ARRAY_A ) ); $out = array(); $moves = array(); foreach ( $rows as $row ) { $type = (string) $row['object_type']; $id = (int) $row['object_id']; $event = (string) $row['event']; if ( $event === 'inventory_moved' ) { $v = json_decode( (string) $row['payload'], true ); $ref = ( is_array( $v ) && ($v['ref_kind'] ?? 'order') === 'order' ? absint( $v['ref'] ?? 0 ) : 0 ); if ( $ref ) { // One entry per order and kind of movement, where its newest movement stands; its lines are added to it. A sale // and a later restock of the same order stay two entries (a sale of 2 and a refund putting 1 back read // "−1 unit (Back in stock (refunded))" as one). $kind = sanitize_key( (string) ($v['kind'] ?? 'sale') ); $group = $ref . ':' . $kind; if ( !isset( $moves[$group] ) ) { if ( count( $out ) >= $limit ) { continue; } $moves[$group] = count( $out ); $out[] = array( 'event' => $event, 'object_type' => 'order', 'object_id' => $ref, 'at' => get_date_from_gmt( $row['created_at'], 'Y-m-d H:i' ), 'label' => self::label( $event, $type ), 'detail' => '', 'url' => self::url( 'order', $ref ), 'products' => array(), 'units' => 0.0, 'kind' => $kind, ); } $entry =& $out[$moves[$group]]; $entry['products'][( absint( $v['variation_id'] ?? 0 ) ?: absint( $v['product_id'] ?? 0 ) )] = true; $entry['units'] += (float) ($v['delta'] ?? 0); unset($entry); continue; } } if ( count( $out ) >= $limit ) { continue; } $out[] = array( 'event' => $event, 'object_type' => $type, 'object_id' => $id, 'at' => get_date_from_gmt( $row['created_at'], 'Y-m-d H:i' ), 'label' => self::label( $event, $type ), 'detail' => self::record_name( $type, $id ), 'url' => self::url( $type, $id ), ); } $kinds = array( 'web_sale' => 'Web sale', 'quick_sale' => 'Quick sale', 'b2b_sale' => 'B2B sale', 'stock_return' => 'Stock return', 'cancel_restock' => 'Back in stock (order cancelled)', 'refund_restock' => 'Back in stock (refunded)', ); foreach ( $out as &$entry ) { if ( !isset( $entry['products'] ) ) { continue; } $units = (float) wc_stock_amount( abs( $entry['units'] ) ); $sign = ( $entry['units'] < 0 ? '−' : (( $entry['units'] > 0 ? '+' : '' )) ); $entry['detail'] = self::record_name( 'order', (int) $entry['object_id'] ) . ' · ' . I18n::n( '%s product', '%s products', count( $entry['products'] ) ) . ', ' . $sign . I18n::n( '%s unit', '%s units', $units ) . (( isset( $kinds[$entry['kind']] ) ? ' (' . I18n::t( $kinds[$entry['kind']] ) . ')' : '' )); unset($entry['products'], $entry['units'], $entry['kind']); } unset($entry); return $out; } /** What a record is called on the screens: "Order #107" (its order number), a product's or a customer's name, or "Purchase #5". */ public static function record_name( string $type, int $id ) : string { if ( !$id ) { return ''; } if ( $type === 'order' ) { $o = wc_get_order( $id ); return I18n::format( 'Order #%s', ( $o instanceof \WC_Order ? $o->get_order_number() : $id ) ); } if ( $type === 'product' ) { $p = wc_get_product( $id ); if ( $p ) { return Store::plain( $p->get_name() ); } } if ( $type === 'customer' || $type === 'user' ) { $user = get_userdata( $id ); if ( $user ) { return (string) $user->display_name; } } return I18n::t( self::TYPES[$type] ?? 'Record' ) . ' #' . $id; } public static function url( string $type, int $id ) : string { if ( !$id ) { return ''; } $pages = array( 'order' => 'storesuit-orders&order=' . $id, 'product' => 'storesuit-products&id=' . $id, 'customer' => 'storesuit-customers&id=' . $id, ); $page = $pages[$type] ?? ''; return ( $page ? admin_url( 'admin.php?page=' . $page ) : '' ); } public static function read( array $data ) : array { Store::require_cap( 'manage_woocommerce' ); global $wpdb; $type = Store::text( $data, 'type' ); if ( $type !== '' && !isset( self::TYPES[$type] ) ) { throw new \InvalidArgumentException(I18n::t( 'Choose a valid record type.' )); } foreach ( array('record', 'cursor') as $key ) { if ( isset( $data[$key] ) && !is_scalar( $data[$key] ) ) { throw new \InvalidArgumentException(I18n::t( 'Enter a valid record number.' )); } } $record = absint( $data['record'] ?? 0 ); $cursor = absint( $data['cursor'] ?? 0 ); if ( $record && $type === '' ) { throw new \InvalidArgumentException(I18n::t( 'Choose a record type to search by number.' )); } // One literal statement: the record type, the record and the cursor are constant switches such as (%d=0 OR ...), every value a placeholder. $rows = ( $wpdb->get_results( $wpdb->prepare( 'SELECT id,object_type,object_id,event,actor,created_at FROM %i WHERE (%d=0 OR object_type=%s) AND (%d=0 OR object_id=%d) AND (%d=0 OR id<%d) ORDER BY id DESC LIMIT 31', $wpdb->prefix . 'storesuit_events', ( $type !== '' ? 1 : 0 ), $type, ( $record ? 1 : 0 ), $record, ( $cursor ? 1 : 0 ), $cursor ), ARRAY_A ) ?: array() ); $more = count( $rows ) > 30; $rows = array_slice( $rows, 0, 30 ); $items = array(); foreach ( $rows as $row ) { $actor = (int) $row['actor']; $user = ( $actor ? get_userdata( $actor ) : false ); $kind = (string) $row['object_type']; $id = (int) $row['object_id']; $items[] = array( 'id' => (int) $row['id'], 'at' => wp_date( 'Y-m-d H:i', strtotime( $row['created_at'] . ' UTC' ) ), 'type' => I18n::t( self::TYPES[$kind] ?? 'Record' ), 'record' => $id, 'name' => self::name( $kind, $id ), 'event' => self::label( (string) $row['event'], $kind ), 'actor' => ( $user ? $user->display_name : I18n::t( ( $actor ? 'Former team member' : 'System' ) ) ), 'url' => self::url( $kind, $id ), ); } return array( 'items' => $items, 'next_cursor' => ( $more ? (int) end( $rows )['id'] : 0 ), 'types' => array_map( static fn( $label ) => I18n::t( $label ), self::TYPES ), ); } /** * The record's name where it is no person's: a product, a category or a brand read "Ürün #17" with nothing to say which. * Orders, customers and the team keep their number only. */ private static function name( string $kind, int $id ) : string { if ( $id <= 0 ) { return ''; } if ( $kind === 'product' ) { $p = wc_get_product( $id ); return ( $p && current_user_can( 'edit_post', $id ) ? Store::plain( $p->get_name() ) : '' ); } if ( $kind === 'category' || $kind === 'brand' ) { $term = get_term( $id ); return ( $term instanceof \WP_Term ? Store::plain( $term->name ) : '' ); } return ''; } /** A bounded, read-only view of the milestones for one accessible order. */ public static function order_timeline( int $id ) : array { Store::require_cap( 'storesuit_orders' ); $order = Orders::get( $id ); $events = array(); $sequence = 0; $append = static function ( $value, string $title, string $detail = '', int $actor = 0, bool $local = false, ?float $amount = null ) use(&$events, &$sequence) : void { if ( !is_string( $value ) || !preg_match( '/^\\d{4}-\\d{2}-\\d{2}[T ]/', $value ) ) { return; } try { $at = new \DateTimeImmutable($value, ( $local ? wp_timezone() : new \DateTimeZone('UTC') )); } catch ( \Throwable $e ) { return; } $user = ( $actor ? get_userdata( $actor ) : false ); $events[] = array( 'at' => $at->getTimestamp(), 'title' => I18n::t( $title ), 'detail' => $detail, 'actor' => ( $user ? $user->display_name : '' ), 'amount' => $amount, 'sequence' => $sequence++, ); }; $created = $order->get_date_created(); if ( $created ) { $append( gmdate( 'c', $created->getTimestamp() ), 'Order created' ); } foreach ( array( '_storesuit_preparation_started' => 'Preparation started', '_storesuit_ready_confirmed' => 'Order prepared', '_storesuit_delivery_confirmed' => 'Delivery confirmed', ) as $key => $title ) { $step = $order->get_meta( $key ); if ( is_array( $step ) ) { $append( (string) ($step['at'] ?? ''), $title, '', (int) ($step['actor'] ?? 0) ); } } $has_collection = false; // StoreRuler's reading of the paid date: a cash-on-delivery date with no money recorded is no payment (paid build). $paid = Orders::paid_date( $order ); // The till's receiptless return or exchange is no payment (paid build). $till_document = false; if ( $paid && !$has_collection && (float) $order->get_total() > 0 && !$till_document ) { $append( gmdate( 'c', $paid->getTimestamp() ), 'Order marked paid', '', 0, false, (float) $order->get_total() ); } foreach ( array_slice( (array) (( $order->get_meta( '_storesuit_shipments' ) ?: array() )), -80 ) as $shipment ) { if ( !is_array( $shipment ) ) { continue; } $handover = ($shipment['mode'] ?? '') === 'handover'; $detail = ( $handover ? '' : sanitize_text_field( (string) ($shipment['carrier_label'] ?? '') ) ); $append( (string) ($shipment['at'] ?? ''), ( $handover ? 'In-store handover recorded' : 'Shipment recorded' ), $detail, (int) ($shipment['actor'] ?? 0) ); } // The storefront return form's request (StorefrontCustomerFeatures); its review is the audit's customer_withdrawal_reviewed. $withdrawal = $order->get_meta( '_sr_withdrawal_request' ); if ( is_array( $withdrawal ) ) { $append( (string) ($withdrawal['at'] ?? ''), 'Customer sent a return request' ); } $return_requests = array_slice( array_values( (array) (( $order->get_meta( '_storesuit_returns' ) ?: array() )) ), -80 ); $represented_refunds = array(); foreach ( $return_requests as $request ) { if ( !is_array( $request ) ) { continue; } $append( (string) ($request['at'] ?? ''), 'Return requested', '', (int) ($request['actor'] ?? 0) ); if ( in_array( $request['status'] ?? '', array('refunded', 'exchanged'), true ) ) { // The reviewer approved the request, but the final action may // have been completed by a different team member. $append( (string) ($request['completed_at'] ?? ''), ( $request['status'] === 'exchanged' ? 'Exchange completed' : 'Return completed' ) ); } if ( !empty( $request['refund_id'] ) ) { $represented_refunds[] = (int) $request['refund_id']; } } foreach ( array_slice( $order->get_refunds(), -80 ) as $refund ) { if ( !$refund instanceof \WC_Order_Refund || in_array( $refund->get_id(), $represented_refunds, true ) ) { continue; } $at = $refund->get_date_created(); // A receiptless return's own refund is WooCommerce's record of the money paid out at the counter, not a second payment (0.62.9; paid build). $refund_title = 'Refund document recorded'; if ( $at ) { $append( gmdate( 'c', $at->getTimestamp() ), $refund_title, '', 0, false, -(float) $refund->get_amount() ); } } foreach ( OrderMessages::history( $order ) as $message ) { if ( !is_array( $message ) ) { continue; } $append( (string) ($message['at'] ?? ''), 'Customer email request submitted', ( ($message['kind'] ?? '') === 'tracking' ? I18n::t( 'Tracking details' ) : I18n::t( 'Payment link' ) ), 0, true ); } global $wpdb; $rows = ( $wpdb->get_results( $wpdb->prepare( 'SELECT id,event,actor,created_at FROM %i WHERE object_type=%s AND object_id=%d ORDER BY id DESC LIMIT 80', $wpdb->prefix . 'storesuit_events', 'order', $id ), ARRAY_A ) ?: array() ); $represented = array( 'created', 'payment_recorded', 'fulfil_shipped', 'handover', 'return_request', 'return_refund', 'return_exchange', 'customer_message_submitted', 'flow_start', 'flow_ready', 'flow_delivered' ); foreach ( $rows as $row ) { $event = (string) $row['event']; if ( in_array( $event, $represented, true ) ) { continue; } $append( (string) $row['created_at'], self::label( $event, 'order' ), '', (int) $row['actor'] ); } usort( $events, static fn( $a, $b ) => ( $b['at'] <=> $a['at'] ?: $b['sequence'] <=> $a['sequence'] ) ); $more = count( $events ) > 40; return array( 'items' => array_map( static fn( $event ) => array( 'at' => gmdate( 'c', $event['at'] ), 'local_at' => wp_date( 'Y-m-d H:i', $event['at'] ), 'title' => $event['title'], 'detail' => $event['detail'], 'actor' => $event['actor'], 'amount' => $event['amount'], ), array_slice( $events, 0, 40 ) ), 'truncated' => $more, 'timezone' => wp_timezone_string(), ); } }