Files
2meet-data-optimizer-woocom…/includes/class-tmdo-woocommerce.php
T
wpdev 2f9ed57390 chore(wc): 同步 A v3.4.6 的 WooCommerce 整合 + TablePrefix header(F4/F5)
4 個整合檔(woocommerce / wc-orders-interceptor / wc-term-count-filter /
admin-wc)對齊 A v3.4.6;主檔補 TablePrefix header。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 06:07:39 +08:00

673 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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&section=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_orderpostmeta-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',
),
)
);
}
/**
* 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",
);
}
}