0118fc3a1a
wc_last_active / wc_order_count_<prefix> / last_update 先前未註冊,會落回 wp_usermeta。三者皆由 WooCommerce 在 order/session 事件寫入,繁忙商店為高頻。 wc_order_count 走 Users::update_site_user_meta(),key 是 'wc_order_count' . '_' . rtrim( get_blog_prefix(), '_' );來源外掛寫死主站 形式 'wc_order_count_wp',multisite 子站(_wp_2)會漏接,這裡改用同一條運算式。
694 lines
21 KiB
PHP
694 lines
21 KiB
PHP
<?php
|
||
/**
|
||
* TMDO_WooCommerce — integration with WooCommerce + WooCommerce Subscriptions.
|
||
*
|
||
* Strategy: WC core has its OWN anti-EAV story (HPOS for orders;
|
||
* `wp_wc_product_meta_lookup` for product search). WPDO's job is to:
|
||
* 1. Register the 20 `wp_wc_*` tables to Custom_Table_Registry
|
||
* (so doctor / conflict-scan know about them)
|
||
* 2. Add hot-zone fields to Schema_Registry for the legacy postmeta keys
|
||
* WC still uses on products / orders (when HPOS off)
|
||
* 3. Detect HPOS state and surface it to admin
|
||
* 4. NOT duplicate what WC already does (no shadow lookup tables)
|
||
*
|
||
* This is "Tier 5" in ENTITY_ADAPTER_COOKBOOK.md: integrating with a plugin
|
||
* that has its own anti-EAV solution.
|
||
*
|
||
* @package TMDO
|
||
* @since 2.1.0
|
||
*/
|
||
|
||
if ( ! defined( 'ABSPATH' ) ) {
|
||
exit;
|
||
}
|
||
|
||
/**
|
||
* WooCommerce integration. No-op when WC not active.
|
||
*/
|
||
final class TMDO_WooCommerce {
|
||
|
||
/**
|
||
* Provider key for registry.
|
||
*/
|
||
public const PROVIDER = 'woocommerce';
|
||
|
||
/**
|
||
* All 20 wp_wc_* tables WC core creates. Keyed by suffix (no prefix).
|
||
*
|
||
* @var array<string, array{primary_key: string, post_type_link: ?string, description: string, since: string}>
|
||
*/
|
||
private const WC_CORE_TABLES = array(
|
||
'wc_admin_note_actions' => array(
|
||
'primary_key' => 'action_id',
|
||
'post_type_link' => null,
|
||
'description' => 'WC admin notes — action buttons',
|
||
'since' => 'WC 4.0',
|
||
),
|
||
'wc_admin_notes' => array(
|
||
'primary_key' => 'note_id',
|
||
'post_type_link' => null,
|
||
'description' => 'WC admin notes',
|
||
'since' => 'WC 4.0',
|
||
),
|
||
'wc_category_lookup' => array(
|
||
'primary_key' => 'category_tree_id',
|
||
'post_type_link' => null,
|
||
'description' => 'Product category hierarchy lookup',
|
||
'since' => 'WC 4.6',
|
||
),
|
||
'wc_customer_lookup' => array(
|
||
'primary_key' => 'customer_id',
|
||
'post_type_link' => null,
|
||
'description' => 'Customer aggregate (anti-EAV by WC core)',
|
||
'since' => 'WC 4.0',
|
||
),
|
||
'wc_download_log' => array(
|
||
'primary_key' => 'download_log_id',
|
||
'post_type_link' => null,
|
||
'description' => 'Downloadable product access log',
|
||
'since' => 'WC 3.3',
|
||
),
|
||
'wc_order_addresses' => array(
|
||
'primary_key' => 'id',
|
||
'post_type_link' => 'shop_order',
|
||
'description' => 'Order billing/shipping addresses (HPOS)',
|
||
'since' => 'WC 8.2',
|
||
),
|
||
'wc_order_coupon_lookup' => array(
|
||
'primary_key' => 'order_id',
|
||
'post_type_link' => 'shop_order',
|
||
'description' => 'Order coupon analytics lookup',
|
||
'since' => 'WC 4.0',
|
||
),
|
||
'wc_order_operational_data' => array(
|
||
'primary_key' => 'id',
|
||
'post_type_link' => 'shop_order',
|
||
'description' => 'Order operational data (HPOS)',
|
||
'since' => 'WC 8.2',
|
||
),
|
||
'wc_order_product_lookup' => array(
|
||
'primary_key' => 'order_item_id',
|
||
'post_type_link' => 'shop_order',
|
||
'description' => 'Order product analytics lookup',
|
||
'since' => 'WC 4.0',
|
||
),
|
||
'wc_order_stats' => array(
|
||
'primary_key' => 'order_id',
|
||
'post_type_link' => 'shop_order',
|
||
'description' => 'Order statistics (anti-EAV by WC core)',
|
||
'since' => 'WC 4.0',
|
||
),
|
||
'wc_order_tax_lookup' => array(
|
||
'primary_key' => null,
|
||
'post_type_link' => 'shop_order',
|
||
'description' => 'Order tax analytics lookup',
|
||
'since' => 'WC 4.0',
|
||
),
|
||
'wc_orders' => array(
|
||
'primary_key' => 'id',
|
||
'post_type_link' => 'shop_order',
|
||
'description' => 'Orders main table (HPOS)',
|
||
'since' => 'WC 8.2',
|
||
),
|
||
'wc_orders_meta' => array(
|
||
'primary_key' => 'id',
|
||
'post_type_link' => 'shop_order',
|
||
'description' => 'Order meta (HPOS replacement for postmeta)',
|
||
'since' => 'WC 8.2',
|
||
),
|
||
'wc_product_attributes_lookup' => array(
|
||
'primary_key' => null,
|
||
'post_type_link' => 'product',
|
||
'description' => 'Product attributes lookup (faceted search)',
|
||
'since' => 'WC 4.9',
|
||
),
|
||
'wc_product_download_directories' => array(
|
||
'primary_key' => 'url_id',
|
||
'post_type_link' => null,
|
||
'description' => 'Approved download directories',
|
||
'since' => 'WC 6.5',
|
||
),
|
||
'wc_product_meta_lookup' => array(
|
||
'primary_key' => 'product_id',
|
||
'post_type_link' => 'product',
|
||
'description' => 'Product meta lookup (anti-EAV by WC core)',
|
||
'since' => 'WC 3.6',
|
||
),
|
||
'wc_rate_limits' => array(
|
||
'primary_key' => 'rate_limit_id',
|
||
'post_type_link' => null,
|
||
'description' => 'API rate limits',
|
||
'since' => 'WC 5.6',
|
||
),
|
||
'wc_reserved_stock' => array(
|
||
'primary_key' => null,
|
||
'post_type_link' => 'product',
|
||
'description' => 'Reserved stock (cart hold)',
|
||
'since' => 'WC 4.5',
|
||
),
|
||
'wc_tax_rate_classes' => array(
|
||
'primary_key' => 'tax_rate_class_id',
|
||
'post_type_link' => null,
|
||
'description' => 'Tax rate classes',
|
||
'since' => 'WC 3.7',
|
||
),
|
||
'wc_webhooks' => array(
|
||
'primary_key' => 'webhook_id',
|
||
'post_type_link' => null,
|
||
'description' => 'Webhooks subscriptions',
|
||
'since' => 'WC 2.6',
|
||
),
|
||
);
|
||
|
||
/**
|
||
* Wire integration. No-op when WC not detected.
|
||
*
|
||
* @return void
|
||
*/
|
||
public static function register(): void {
|
||
if ( ! self::is_active() ) {
|
||
return;
|
||
}
|
||
|
||
add_action( 'wpdo_register_custom_tables', array( __CLASS__, 'register_custom_tables' ) );
|
||
add_action( 'wpdo_register_fields', array( __CLASS__, 'register_schema_fields' ) );
|
||
// v2.1.6: customer hot fields (_money_spent / _order_count / _last_order) move
|
||
// to Entity_Registry so Hook Bus can route them to wp_wpdo_user_hot once
|
||
// `entity_user` mode advances past `disabled`. See PLAN-entity-user-cutover.md.
|
||
add_action( 'wpdo_register_entity_fields', array( __CLASS__, 'register_user_entity_fields' ) );
|
||
|
||
// HPOS recommendation notice — only on WC admin pages, only when:
|
||
// (a) HPOS is OFF, (b) the threshold of legacy orders has been crossed.
|
||
if ( is_admin() ) {
|
||
add_action( 'admin_notices', array( __CLASS__, 'maybe_render_hpos_notice' ) );
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Threshold of legacy `shop_order` posts above which we recommend HPOS.
|
||
* Below this, the HPOS migration overhead isn't justified.
|
||
*/
|
||
private const HPOS_RECOMMEND_THRESHOLD = 100;
|
||
|
||
/**
|
||
* Option name used to permanently dismiss the notice.
|
||
*/
|
||
private const HPOS_NOTICE_DISMISSED_OPT = 'wpdo_hpos_notice_dismissed';
|
||
|
||
/**
|
||
* Conditionally render the HPOS recommendation admin notice.
|
||
*
|
||
* @return void
|
||
*/
|
||
public static function maybe_render_hpos_notice(): void {
|
||
// Already enabled or already dismissed.
|
||
if ( self::is_hpos_enabled() ) {
|
||
return;
|
||
}
|
||
if ( '1' === (string) get_option( self::HPOS_NOTICE_DISMISSED_OPT, '' ) ) {
|
||
return;
|
||
}
|
||
if ( ! current_user_can( 'manage_woocommerce' ) ) {
|
||
return;
|
||
}
|
||
|
||
// Handle dismiss action (lightweight, no separate AJAX endpoint).
|
||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only nonce check below.
|
||
if ( ! empty( $_GET['wpdo_hpos_dismiss'] ) ) {
|
||
$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['_wpnonce'] ) ) : '';
|
||
if ( wp_verify_nonce( $nonce, 'wpdo_hpos_dismiss' ) ) {
|
||
update_option( self::HPOS_NOTICE_DISMISSED_OPT, '1', false );
|
||
return;
|
||
}
|
||
}
|
||
|
||
$legacy_count = self::count_legacy_orders();
|
||
if ( $legacy_count < self::HPOS_RECOMMEND_THRESHOLD ) {
|
||
return;
|
||
}
|
||
|
||
$dismiss_url = wp_nonce_url(
|
||
add_query_arg( 'wpdo_hpos_dismiss', '1' ),
|
||
'wpdo_hpos_dismiss'
|
||
);
|
||
$settings_url = admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=features' );
|
||
|
||
?>
|
||
<div class="notice notice-warning is-dismissible">
|
||
<p>
|
||
<strong>WP Data Optimizer:</strong>
|
||
<?php
|
||
printf(
|
||
/* translators: 1: legacy order count, 2: HPOS settings URL, 3: dismiss URL */
|
||
esc_html__( '偵測到 %1$d 筆 legacy shop_order(postmeta-based)。建議啟用 WooCommerce High Performance Order Storage (HPOS) 加速 order 查詢。前往 %2$s 啟用,或 %3$s 永久隱藏此通知。', '2meet-data-optimizer' ),
|
||
(int) $legacy_count,
|
||
'<a href="' . esc_url( $settings_url ) . '">WooCommerce → 進階 → 功能</a>',
|
||
'<a href="' . esc_url( $dismiss_url ) . '">關閉</a>'
|
||
);
|
||
?>
|
||
</p>
|
||
</div>
|
||
<?php
|
||
}
|
||
|
||
/**
|
||
* Count legacy `shop_order` posts (excluding HPOS table).
|
||
*
|
||
* @return int
|
||
*/
|
||
private static function count_legacy_orders(): int {
|
||
global $wpdb;
|
||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||
return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'shop_order'" );
|
||
}
|
||
|
||
/**
|
||
* Active when WC class loaded OR WC plugin file exists (loose detect for early bootstrap).
|
||
*
|
||
* @return bool
|
||
*/
|
||
public static function is_active(): bool {
|
||
return class_exists( 'WooCommerce' )
|
||
|| ( defined( 'WC_VERSION' ) && WC_VERSION )
|
||
|| file_exists( WP_PLUGIN_DIR . '/woocommerce/woocommerce.php' );
|
||
}
|
||
|
||
/**
|
||
* HPOS (custom orders table) detection.
|
||
*
|
||
* @return bool True when HPOS is enabled.
|
||
*/
|
||
public static function is_hpos_enabled(): bool {
|
||
// WC OrderUtil class — modern WC API.
|
||
if ( class_exists( 'Automattic\\WooCommerce\\Utilities\\OrderUtil' )
|
||
&& method_exists( 'Automattic\\WooCommerce\\Utilities\\OrderUtil', 'custom_orders_table_usage_is_enabled' )
|
||
) {
|
||
return (bool) Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
|
||
}
|
||
// Fallback: option check.
|
||
return 'yes' === get_option( 'woocommerce_custom_orders_table_enabled' );
|
||
}
|
||
|
||
/**
|
||
* Detected WC version (or 'unknown').
|
||
*
|
||
* @return string
|
||
*/
|
||
public static function wc_version(): string {
|
||
if ( defined( 'WC_VERSION' ) ) {
|
||
return WC_VERSION;
|
||
}
|
||
return 'unknown';
|
||
}
|
||
|
||
/**
|
||
* Register all 20 wp_wc_* tables.
|
||
*
|
||
* @param mixed $registry TMDO_Custom_Table_Registry (passed by action).
|
||
* @return void
|
||
*/
|
||
public static function register_custom_tables( $registry = null ): void {
|
||
if ( ! class_exists( 'TMDO_Custom_Table_Registry' ) ) {
|
||
return;
|
||
}
|
||
$registry = $registry ?: TMDO_Custom_Table_Registry::instance();
|
||
|
||
foreach ( self::WC_CORE_TABLES as $name => $meta ) {
|
||
$registry->register(
|
||
self::PROVIDER,
|
||
array(
|
||
'table_name' => $name,
|
||
'primary_key' => $meta['primary_key'] ?: 'id',
|
||
'post_type_link' => $meta['post_type_link'],
|
||
'doctor_callback' => array( __CLASS__, 'doctor_check' ),
|
||
'description' => $meta['description'] . ' (' . $meta['since'] . ')',
|
||
)
|
||
);
|
||
}
|
||
|
||
// Our own commission tracking table (v2.1.0).
|
||
$registry->register(
|
||
self::PROVIDER,
|
||
array(
|
||
'table_name' => 'wpdo_wc_commissions',
|
||
'primary_key' => 'id',
|
||
'post_type_link' => null,
|
||
'doctor_callback' => array( __CLASS__, 'doctor_check' ),
|
||
'description' => 'Vendor commission tracking (replaces hpct_wc_orders, HPOS-aware) (WPDO 2.1.0)',
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Register hot-zone meta fields for products + orders + customers.
|
||
*
|
||
* Delegates to dedicated registrars to keep this method readable.
|
||
*
|
||
* @param mixed $schema TMDO_Schema_Registry (passed by action).
|
||
* @return void
|
||
*/
|
||
public static function register_schema_fields( $schema = null ): void {
|
||
if ( ! class_exists( 'TMDO_Schema_Registry' ) ) {
|
||
return;
|
||
}
|
||
$schema = $schema ?: TMDO_Schema_Registry::instance();
|
||
|
||
self::register_product_fields( $schema );
|
||
|
||
// Order fields only matter when HPOS is OFF (otherwise WC stores them in wc_orders_meta).
|
||
if ( ! self::is_hpos_enabled() ) {
|
||
self::register_order_fields( $schema );
|
||
}
|
||
|
||
// v2.1.6: Customer usermeta hot fields moved out of Schema_Registry — they
|
||
// are now registered via `register_user_entity_fields()` on the
|
||
// `wpdo_register_entity_fields` action so Hook Bus can intercept them.
|
||
// Schema_Registry never had a user-meta interceptor; the previous
|
||
// registration was inert. See PLAN-entity-user-cutover.md.
|
||
|
||
// Subscription fields if WC Subscriptions OR wc-linepay-subscription is active.
|
||
if ( self::has_subscriptions() ) {
|
||
self::register_subscription_fields( $schema );
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Product hot fields (search/filter shop pages).
|
||
*
|
||
* @param TMDO_Schema_Registry $schema Registry.
|
||
* @return void
|
||
*/
|
||
private static function register_product_fields( $schema ): void {
|
||
$schema->register_many(
|
||
self::PROVIDER,
|
||
array(
|
||
array(
|
||
'post_type' => 'product',
|
||
'meta_key' => '_price',
|
||
'zone' => 'hot',
|
||
'data_type' => 'decimal(15,4) NOT NULL DEFAULT 0',
|
||
'column' => '_price',
|
||
'indexed' => true,
|
||
),
|
||
array(
|
||
'post_type' => 'product',
|
||
'meta_key' => '_regular_price',
|
||
'zone' => 'hot',
|
||
'data_type' => 'decimal(15,4) NOT NULL DEFAULT 0',
|
||
'column' => '_regular_price',
|
||
),
|
||
array(
|
||
'post_type' => 'product',
|
||
'meta_key' => '_sale_price',
|
||
'zone' => 'hot',
|
||
'data_type' => 'decimal(15,4) NULL',
|
||
'column' => '_sale_price',
|
||
),
|
||
array(
|
||
'post_type' => 'product',
|
||
'meta_key' => '_sku',
|
||
'zone' => 'hot',
|
||
'data_type' => "varchar(100) NOT NULL DEFAULT ''",
|
||
'column' => '_sku',
|
||
'indexed' => true,
|
||
),
|
||
array(
|
||
'post_type' => 'product',
|
||
'meta_key' => '_stock',
|
||
'zone' => 'hot',
|
||
'data_type' => 'decimal(15,4) NULL',
|
||
'column' => '_stock',
|
||
),
|
||
array(
|
||
'post_type' => 'product',
|
||
'meta_key' => '_stock_status',
|
||
'zone' => 'hot',
|
||
'data_type' => "varchar(20) NOT NULL DEFAULT 'instock'",
|
||
'column' => '_stock_status',
|
||
'indexed' => true,
|
||
),
|
||
array(
|
||
'post_type' => 'product',
|
||
'meta_key' => '_visibility',
|
||
'zone' => 'hot',
|
||
'data_type' => "varchar(20) NOT NULL DEFAULT 'visible'",
|
||
'column' => '_visibility',
|
||
),
|
||
array(
|
||
'post_type' => 'product',
|
||
'meta_key' => '_featured',
|
||
'zone' => 'hot',
|
||
'data_type' => "varchar(3) NOT NULL DEFAULT 'no'",
|
||
'column' => '_featured',
|
||
'indexed' => true,
|
||
),
|
||
array(
|
||
'post_type' => 'product',
|
||
'meta_key' => '_tax_status',
|
||
'zone' => 'hot',
|
||
'data_type' => "varchar(20) NOT NULL DEFAULT 'taxable'",
|
||
'column' => '_tax_status',
|
||
),
|
||
array(
|
||
'post_type' => 'product',
|
||
'meta_key' => '_virtual',
|
||
'zone' => 'hot',
|
||
'data_type' => "varchar(3) NOT NULL DEFAULT 'no'",
|
||
'column' => '_virtual',
|
||
),
|
||
array(
|
||
'post_type' => 'product',
|
||
'meta_key' => '_downloadable',
|
||
'zone' => 'hot',
|
||
'data_type' => "varchar(3) NOT NULL DEFAULT 'no'",
|
||
'column' => '_downloadable',
|
||
),
|
||
)
|
||
);
|
||
|
||
// Cold zone — large display fields rarely searched but always shown.
|
||
$schema->register_many(
|
||
self::PROVIDER,
|
||
array(
|
||
array(
|
||
'post_type' => 'product',
|
||
'meta_key' => '_product_attributes',
|
||
'zone' => 'cold',
|
||
'cache_group' => 'wpdo_cold_product',
|
||
'cache_ttl' => HOUR_IN_SECONDS,
|
||
),
|
||
array(
|
||
'post_type' => 'product',
|
||
'meta_key' => '_purchase_note',
|
||
'zone' => 'cold',
|
||
'cache_group' => 'wpdo_cold_product',
|
||
'cache_ttl' => HOUR_IN_SECONDS,
|
||
),
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Order hot fields (legacy postmeta path; only registered when HPOS off).
|
||
*
|
||
* @param TMDO_Schema_Registry $schema Registry.
|
||
* @return void
|
||
*/
|
||
private static function register_order_fields( $schema ): void {
|
||
$schema->register_many(
|
||
self::PROVIDER,
|
||
array(
|
||
array(
|
||
'post_type' => 'shop_order',
|
||
'meta_key' => '_order_total',
|
||
'zone' => 'hot',
|
||
'data_type' => 'decimal(15,4) NOT NULL DEFAULT 0',
|
||
'column' => '_order_total',
|
||
'indexed' => true,
|
||
),
|
||
array(
|
||
'post_type' => 'shop_order',
|
||
'meta_key' => '_order_currency',
|
||
'zone' => 'hot',
|
||
'data_type' => "varchar(8) NOT NULL DEFAULT ''",
|
||
'column' => '_order_currency',
|
||
),
|
||
array(
|
||
'post_type' => 'shop_order',
|
||
'meta_key' => '_billing_email',
|
||
'zone' => 'hot',
|
||
'data_type' => "varchar(254) NOT NULL DEFAULT ''",
|
||
'column' => '_billing_email',
|
||
'indexed' => true,
|
||
),
|
||
array(
|
||
'post_type' => 'shop_order',
|
||
'meta_key' => '_payment_method',
|
||
'zone' => 'hot',
|
||
'data_type' => "varchar(60) NOT NULL DEFAULT ''",
|
||
'column' => '_payment_method',
|
||
'indexed' => true,
|
||
),
|
||
array(
|
||
'post_type' => 'shop_order',
|
||
'meta_key' => '_customer_user',
|
||
'zone' => 'hot',
|
||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
||
'column' => '_customer_user',
|
||
'indexed' => true,
|
||
),
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Customer usermeta hot fields — registered to Entity_Registry so Hook Bus can
|
||
* route them to `wp_wpdo_user_hot` once `entity_user` mode advances past `disabled`.
|
||
*
|
||
* Replaces the v2.1.5 `register_customer_fields()` which targeted Schema_Registry
|
||
* (zone-based, no user interceptor) — that path was inert. See
|
||
* PLAN-entity-user-cutover.md for the migration playbook.
|
||
*
|
||
* Type mapping (Entity_Registry uses fixed-precision DECIMAL(18,6) / BIGINT(20),
|
||
* which is a superset of the prior `decimal(15,4)` / `bigint(20)` declarations —
|
||
* no precision loss):
|
||
* - _money_spent → decimal (DECIMAL(18,6))
|
||
* - _order_count → integer (BIGINT(20))
|
||
* - _last_order → integer (BIGINT(20))
|
||
*
|
||
* @return void
|
||
*/
|
||
public static function register_user_entity_fields(): void {
|
||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||
return;
|
||
}
|
||
TMDO_Entity_Registry::register_group(
|
||
'user',
|
||
'hot',
|
||
array(
|
||
array(
|
||
'key' => '_money_spent',
|
||
'type' => 'decimal',
|
||
'searchable' => true,
|
||
'label' => 'WC customer cumulative spend',
|
||
),
|
||
array(
|
||
'key' => '_order_count',
|
||
'type' => 'integer',
|
||
'searchable' => true,
|
||
'label' => 'WC customer order count',
|
||
),
|
||
array(
|
||
'key' => '_last_order',
|
||
'type' => 'integer',
|
||
'label' => 'WC customer last order ID',
|
||
),
|
||
// WC runtime stats — written on order/session events, high-frequency
|
||
// on busy stores. Unregistered they land back in wp_usermeta.
|
||
array(
|
||
'key' => 'wc_last_active',
|
||
'type' => 'integer',
|
||
'label' => 'WC last customer activity timestamp (Unix)',
|
||
),
|
||
array(
|
||
// WooCommerce stores this per-site via Users::update_site_user_meta(),
|
||
// which appends the de-suffixed blog prefix ('wc_order_count' + '_wp'
|
||
// on the main site, '_wp_2' on a subsite). Compute it the same way
|
||
// rather than hardcoding the main-site form.
|
||
'key' => 'wc_order_count_' . rtrim( $GLOBALS['wpdb']->get_blog_prefix(), '_' ),
|
||
'type' => 'integer',
|
||
'label' => 'WC order count for the current site (blog-aware key)',
|
||
),
|
||
array(
|
||
'key' => 'last_update',
|
||
'type' => 'integer',
|
||
'label' => 'WC customer last profile update timestamp (Unix)',
|
||
),
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Subscription product fields (wc-subscriptions or wc-linepay-subscription).
|
||
*
|
||
* @param TMDO_Schema_Registry $schema Registry.
|
||
* @return void
|
||
*/
|
||
private static function register_subscription_fields( $schema ): void {
|
||
$schema->register_many(
|
||
self::PROVIDER,
|
||
array(
|
||
array(
|
||
'post_type' => 'product',
|
||
'meta_key' => '_subscription_price',
|
||
'zone' => 'hot',
|
||
'data_type' => 'decimal(15,4) NULL',
|
||
'column' => '_subscription_price',
|
||
),
|
||
array(
|
||
'post_type' => 'product',
|
||
'meta_key' => '_subscription_period',
|
||
'zone' => 'hot',
|
||
'data_type' => 'varchar(20) NULL',
|
||
'column' => '_subscription_period',
|
||
),
|
||
array(
|
||
'post_type' => 'product',
|
||
'meta_key' => '_subscription_period_interval',
|
||
'zone' => 'hot',
|
||
'data_type' => 'int(11) NULL',
|
||
'column' => '_subscription_period_interval',
|
||
),
|
||
array(
|
||
'post_type' => 'product',
|
||
'meta_key' => '_subscription_length',
|
||
'zone' => 'hot',
|
||
'data_type' => 'int(11) NULL',
|
||
'column' => '_subscription_length',
|
||
),
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Subscription extension detection — WC native or wc-linepay-subscription.
|
||
*
|
||
* @return bool
|
||
*/
|
||
private static function has_subscriptions(): bool {
|
||
return class_exists( 'WC_Subscriptions' )
|
||
|| defined( 'WCS_INIT_TIMESTAMP' )
|
||
|| file_exists( WP_PLUGIN_DIR . '/wc-linepay-subscription/wc-linepay-subscription.php' );
|
||
}
|
||
|
||
/**
|
||
* WPDO doctor probe for any wc_* table — verifies existence + reports row count.
|
||
*
|
||
* @param string $table_suffix Suffix without prefix.
|
||
* @return array{ok:bool, message:string}
|
||
*/
|
||
public static function doctor_check( string $table_suffix ): array {
|
||
global $wpdb;
|
||
$full = $wpdb->prefix . $table_suffix;
|
||
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||
$wpdb->prepare( 'SHOW TABLES LIKE %s', $full )
|
||
);
|
||
if ( ! $exists ) {
|
||
$is_hpos_table = in_array( $table_suffix, array( 'wc_orders', 'wc_orders_meta', 'wc_order_addresses', 'wc_order_operational_data' ), true );
|
||
$msg = $is_hpos_table
|
||
? "{$full} 不存在 — HPOS 尚未啟用或 WC 版本 < 8.2。"
|
||
: "{$full} 不存在 — 請確認 WooCommerce 已啟用。";
|
||
return array(
|
||
'ok' => false,
|
||
'message' => $msg,
|
||
);
|
||
}
|
||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$full}`" );
|
||
return array(
|
||
'ok' => true,
|
||
'message' => "{$full}: {$count} rows",
|
||
);
|
||
}
|
||
}
|