chore: initial snapshot of 2meet-data-optimizer-woocommerce-addon v0.1.0

Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
2026-07-31 05:06:36 +08:00
commit 47288db826
17 changed files with 3903 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
vendor/
node_modules/
.phpunit.result.cache
.phpcs-cache
*.log
*.bak
*.swp
.DS_Store
Thumbs.db
.idea/
.vscode/
.full-review/
.gstack/
.playwright-mcp/
.claude/
dist/
@@ -0,0 +1,37 @@
<?php
/**
* Plugin Name: 2meet Data Optimizer — Woocommerce AddOn
* Plugin URI: https://2meet.io/2meet-data-optimizer/woocommerce
* Description: WooCommerce 整合:20 表 schema 註冊 + commission tracking (wp_wpdo_wc_commissions) + product/order zone + HPOS notice
* Version: 0.1.0
* Requires at least: 6.0
* Tested up to: 6.9.4
* Requires PHP: 8.1
* Requires Plugins: 2meet-data-optimizer, woocommerce
* Author: 2meet
* Author URI: https://2meet.io
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: tmdo-woocommerce
* Domain Path: /languages
*
* @package TMDO_WOOCOMMERCE
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'TMDO_WOOCOMMERCE_VERSION', '0.1.0' );
define( 'TMDO_WOOCOMMERCE_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_WOOCOMMERCE_URL', plugin_dir_url( __FILE__ ) );
define( 'TMDO_WOOCOMMERCE_FILE', __FILE__ );
require_once TMDO_WOOCOMMERCE_PATH . 'includes/class-tmdo-woocommerce-bootstrap.php';
/**
* Phase 3 任務:完成本 AddOn 的 includes/ 實作。
*
* @see ~/.claude/plans/wp-data-optimizer-2meet-data-optimizer-zesty-liskov.md
*/
add_action( 'plugins_loaded', array( 'TMDO_Woocommerce_Bootstrap', 'init' ), 6 );
+22
View File
@@ -0,0 +1,22 @@
# Changelog
All notable changes to this AddOn will be documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Versioning follows [Semantic Versioning](https://semver.org/).
---
## [Unreleased]
- Phase 3 implementation in progress.
---
## [0.1.0] — TBD
### Added (initial release, scaffold)
- Plugin scaffold from `wp-data-optimizer v2.16.0` extraction plan.
- Plugin Header + bootstrap stub + 7 MD + composer.json.
- Requires Plugins: `2meet-data-optimizer`, `woocommerce`.
+74
View File
@@ -0,0 +1,74 @@
# CLAUDE.md — 2meet Data Optimizer Woocommerce AddOn
> 父外掛:`../2meet-data-optimizer/CLAUDE.md`
> 父環境:`/var/www/Studio/wp-local-dev30/CLAUDE.md`
> 全域:`~/.claude/CLAUDE.md`(含 Karpathy Guidelines
---
## Reset recovery
1. 讀本檔
2. 讀 [PLAN.md](PLAN.md) 看 Phase 3 進度
3. 接續下一個未完成步驟
---
## Plugin 概述
**2meet-data-optimizer-woocommerce-addon** 是 2meet-data-optimizer 的 Woocommerce 整合 AddOn。
- **目標外掛**: `woocommerce`
- **Phase**: 3
- **預估行數**: ~1316
- **Slug 短碼**: `woocommerce`
簡述:WooCommerce 整合:20 表 schema 註冊 + commission tracking + product/order zone + HPOS notice
完整拆分計畫見 `~/.claude/plans/wp-data-optimizer-2meet-data-optimizer-zesty-liskov.md`
---
## 邊界
- ✅ 本 AddOn 觸碰:woocommerce 相關 schema 註冊(postmeta zone fields / custom tables / entity groups+ 可選 admin / CLI
- ❌ 本 AddOn **不**觸碰:2meet-data-optimizer 核心、其他 AddOn、目標外掛 runtime 邏輯
- ❌ 本 AddOn **不**繞過:Hook Bus / TMDO_API(必須走公開合約)
---
## 命名前綴
- 類別:`TMDO_Woocommerce_*`
- 函式:`tmdo_woocommerce_*`
- 常數:`TMDO_WOOCOMMERCE_VERSION` / `TMDO_WOOCOMMERCE_PATH` / `TMDO_WOOCOMMERCE_URL` / `TMDO_WOOCOMMERCE_FILE`
- Text-domain`tmdo-woocommerce`
---
## 公開合約消費(從核心)
```php
// 安全消費 API(必加 class_exists guard
if ( class_exists( 'TMDO_API' ) ) {
TMDO_API::set_entity( 'user', $id, 'key', $value );
}
// Schema 註冊
add_action( 'tmdo_register_entity_fields', function ( $registry_class ) {
$registry_class::register_group( '...', '...', [ ... ] );
} );
add_action( 'tmdo_register_custom_tables', function ( $registry ) {
$registry->register( 'woocommerce-addon', [ ... ] );
} );
```
---
## Phase 進度
```
Phase 0 ✅ 骨架建立 (2026-05-15)
Phase 3 ⬜ Implementation v0.1.0
```
+43
View File
@@ -0,0 +1,43 @@
# 部署流程
## Prerequisites
1. `2meet-data-optimizer` 核心 ≥ 0.1.0 已安裝並啟用
2. `woocommerce` 已安裝並啟用
> 本 AddOn 透過 WordPress `Requires Plugins:` 強制相依,先決條件未滿足時無法啟用。
---
## Steps
```bash
# 上傳 + 啟用
wp plugin install /path/to/2meet-data-optimizer-woocommerce-addon-v0.1.0.zip --activate
# 驗證
wp tmdo doctor --provider=woocommerce-addon
wp tmdo custom-tables --provider=woocommerce-addon
```
---
## Rollback
```bash
wp plugin deactivate 2meet-data-optimizer-woocommerce-addon
# 目標外掛 (woocommerce) 仍可正常運作,
# 讀寫 fall back 到 native meta(失去 zone 加速但功能不受影響)
```
---
## 整體部署順序建議
依 Phase 順序:
1. `2meet-data-optimizer` 核心
2. Phase 2: `2meet-data-optimizer-hub-addon``2meet-data-optimizer-spoke-addon`
3. Phase 3: HivePress / WooCommerce / LatePoint AddOn
4. Phase 4: 6 個 2meet 家族 AddOn
詳見核心外掛 [DEPLOY.md](../2meet-data-optimizer/DEPLOY.md)。
+44
View File
@@ -0,0 +1,44 @@
# DESIGN — 2meet Data Optimizer Woocommerce AddOn
> 父外掛架構決策見 [`../2meet-data-optimizer/DESIGN.md`](../2meet-data-optimizer/DESIGN.md)
---
## 角色定位
本 AddOn 是 `2meet-data-optimizer` 的整合層,負責將 `woocommerce` 的 EAV 模式(postmeta / 自訂表)橋接到核心反 EAV 引擎。
- **不**修改 `woocommerce` 本身
- **不**繞過 `2meet-data-optimizer` 核心
- 只透過公開合約(`TMDO_API``tmdo_register_*` hook)參與
---
## 啟動順序
```
plugins_loaded:4 2meet-data-optimizer (core) 啟動
plugins_loaded:5-6 本 AddOn bootstrap → 註冊 hook callback
plugins_loaded:6+ woocommerce 本體啟動
init:0 核心 fire tmdo_register_* → 本 AddOn 的 callback 執行
```
詳見 plan 文件第 3 章「載入順序」。
---
## 為什麼是獨立 AddOn 而非塞進核心
- 核心 `2meet-data-optimizer` 對外承諾「零依存任何業務外掛」
- 整合層發版節奏應跟隨 `woocommerce` 而非核心
- 站台不用 `woocommerce` 時不需被迫載入這段程式碼
---
## 邊界檢查清單
- [ ] 不直接 `SELECT FROM wp_*meta`
- [ ] 不繞過 Hook Bus
- [ ] 所有 SQL 走 `$wpdb->prepare()`
- [ ] DB 表名走 `TMDO_DB::table()`
- [ ] 對外只 expose `TMDO_Woocommerce_*` 命名空間
+43
View File
@@ -0,0 +1,43 @@
# PLAN — 2meet Data Optimizer Woocommerce AddOn
> 完整四階段拆分計畫見 `~/.claude/plans/wp-data-optimizer-2meet-data-optimizer-zesty-liskov.md` (本 AddOn 屬 **Phase 3**)
---
## Current state
- **Version**: 0.1.0 (in development, scaffold complete 2026-05-15)
- **Phase**: Phase 0 → Phase 3 pending
- **Source**: 從 `wp-data-optimizer v2.16.0` 提煉
- **預估行數**: ~1316 行
- **目標外掛**: `woocommerce`
---
## Phase 0 ✅ — 骨架建立 (2026-05-15)
- [x] 目錄結構
- [x] Plugin Header
- [x] Bootstrap stub
- [x] 7 件 MD
- [x] composer.json / .gitignore / uninstall.php
---
## Phase 3 ⬜ — Implementation v0.1.0
詳細任務見上層 plan 第 5 章。簡述:
- [ ] 從 wp-data-optimizer 對應檔案搬入 includes/
- [ ] WPDO_ → TMDO_Woocommerce_ 重命名
- [ ] 移除對 wp-data-optimizer 內部 class 的依存(改走 TMDO_API 公開合約)
- [ ] 完善 bootstrap:實作 init() 載入順序
- [ ] 補完 7 件 MD 的細節
- [ ]`scripts/package-plugin.sh 2meet-data-optimizer-woocommerce-addon` 通過 10 終檢
- [ ] dev30 e2e
---
## Lessons learned
- (Phase 3 起記錄)
+65
View File
@@ -0,0 +1,65 @@
# 2meet Data Optimizer — WooCommerce AddOn
> 為 **WooCommerce** 提供 schema 註冊、commission tracking 與 product/order 反 EAV 加速。
---
## What it does
| 項目 | 內容 |
|---|---|
| Custom tables | WC 20 張 schema 註冊到 TMDO Custom Table Registry`wc_admin_notes` / `wc_orders` (HPOS) / `wc_product_meta_lookup` 等)|
| Product zone | `_price` / `_sku` / `_stock_status` / `_featured` 等 11 欄位 → Hot zone (indexed) |
| Cold zone | `_product_attributes` / `_purchase_note` |
| Subscription | WC Subscriptions 偵測 + 對應欄位 |
| Order zone | `_order_total` / `_billing_email` / `_payment_method` 等(HPOS 關閉時)|
| Customer entity | `_money_spent` / `_order_count` / `_last_order` |
| Commission tracking | `wp_wpdo_wc_commissions` 表(marketplace `_hp_vendor` / `_hp_commission` 等)|
| Term count filter | `product_count_*` termmeta → wp_options transient(避免 termmeta 脹大)|
| HPOS notice | 偵測 HPOS 是否啟用,未啟用時 admin notice 推薦升級 |
---
## Requirements
- WordPress ≥ 6.0
- PHP ≥ 8.1
- 2meet-data-optimizer ≥ 0.1.0
- WooCommerce ≥ 3.3HPOS 表本身需 8.2+
- **軟相依**`2meet-data-optimizer-hivepress-addon` — commission tracking 需要 HP marketplace 提供 `_hp_vendor` / `_hp_listing` 等 metaWC-only 站台 commission interceptor 會自動 skip
---
## Installation
```bash
wp plugin install /path/to/2meet-data-optimizer-woocommerce-addon-v0.1.0.zip --activate
# 驗證
wp tmdo custom-tables --provider=woocommerce
wp tmdo doctor --table=wpdo_wc_commissions
```
---
## HPOSHigh Performance Order Storage
WooCommerce 8.2+ 引入 HPOS 後,order 自有 custom table,本 AddOn 偵測到 HPOS 啟用時會:
- ✅ 仍註冊 20 張 WC 表到 Custom Table Registrydoctor 健診)
- ✅ 仍處理 `_hp_vendor` / `_hp_commission` 等 marketplace meta
- ❌ 不再為 order 建獨立 zone(HPOS 已是反 EAV,無需重複)
未啟用 HPOS 的舊站台:本 AddOn 為 order 建 Hot zone5 欄位)作為 legacy 加速。
---
## Phase
Phase 3 — 與 [HivePress AddOn](../2meet-data-optimizer-hivepress-addon/) 與 [LatePoint AddOn](../2meet-data-optimizer-latepoint-addon/) 同期 ship。
---
## License
GPL-2.0-or-later
+36
View File
@@ -0,0 +1,36 @@
# Security Policy
## Supported versions
| Version | Supported |
|---|:-:|
| 0.1.x | ✅ |
---
## Reporting a vulnerability
請參考核心外掛 [SECURITY.md](../2meet-data-optimizer/SECURITY.md)。
簡述:
- Email: security@2meet.io
- 請勿在 public GitHub issue 揭露
- 24h 內初步回應、7 天內提供修補時程
---
## 安全設計
本 AddOn 完全遵守 2meet-data-optimizer 核心安全設計:
- SQL prepared statements
- Capability check (`manage_options`)
- Nonce 驗證
- Output escaping
- 反序列化走 `TMDO_Safe_Unserialize`
---
## 已知 Risk
- (本版本暫無已知安全 riskPhase 3 ship 時補)
+238
View File
@@ -0,0 +1,238 @@
<?php
/**
* TMDO_Admin_WC — admin dashboard panel for WooCommerce integration.
*
* URL: Tools → WP Data Optimizer (parent menu) → WooCommerce sub-page.
*
* Shows:
* - HPOS status + recommendation
* - WC tables registered (count + health)
* - Top vendors by commission (from wp_wpdo_wc_commissions)
*
* @package WP_Data_Optimizer
* @since 2.1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WC admin panel.
*/
final class TMDO_Admin_WC {
private const MENU_SLUG = 'wpdo-wc';
private const CAP = 'manage_options';
/**
* Wire the menu item — only when WC is active.
*
* @return void
*/
public static function register(): void {
if ( ! class_exists( 'TMDO_WooCommerce' ) || ! TMDO_WooCommerce::is_active() ) {
return;
}
add_action( 'admin_menu', array( __CLASS__, 'register_menu' ), 11 );
}
/**
* Add submenu under "WP Data Optimizer" (which lives under Tools).
*
* @return void
*/
public static function register_menu(): void {
add_submenu_page(
'tools.php',
__( 'WPDO WooCommerce', 'tmdo-woocommerce' ),
__( 'WPDO WC', 'tmdo-woocommerce' ),
self::CAP,
self::MENU_SLUG,
array( __CLASS__, 'render' )
);
}
/**
* Render the dashboard.
*
* @return void
*/
public static function render(): void {
$hpos = class_exists( 'TMDO_WooCommerce' ) ? TMDO_WooCommerce::is_hpos_enabled() : false;
$wc_ver = class_exists( 'TMDO_WooCommerce' ) ? TMDO_WooCommerce::wc_version() : 'unknown';
$tables = class_exists( 'TMDO_Custom_Table_Registry' )
? TMDO_Custom_Table_Registry::instance()->for_provider( 'woocommerce' )
: array();
$vendors = self::top_vendors_by_commission( 10 );
$totals = self::global_commission_totals();
?>
<div class="wrap">
<h1><?php esc_html_e( 'WPDO × WooCommerce', 'tmdo-woocommerce' ); ?></h1>
<h2><?php esc_html_e( 'WC 環境', 'tmdo-woocommerce' ); ?></h2>
<table class="widefat" style="max-width:520px;">
<tr>
<th><?php esc_html_e( 'WC 版本', 'tmdo-woocommerce' ); ?></th>
<td><?php echo esc_html( $wc_ver ); ?></td>
</tr>
<tr>
<th><?php esc_html_e( 'HPOS 啟用', 'tmdo-woocommerce' ); ?></th>
<td>
<?php if ( $hpos ) : ?>
<span style="color:#2a5a2a;">✅ <?php esc_html_e( '是 — orders 走 wp_wc_orders 不污染 postmeta', 'tmdo-woocommerce' ); ?></span>
<?php else : ?>
<span style="color:#c44;">⚠️ <?php esc_html_e( '否 — orders 仍存於 wp_postmeta(建議啟用以加速)', 'tmdo-woocommerce' ); ?></span>
&nbsp;<a href="<?php echo esc_url( admin_url( 'admin.php?page=wc-settings&tab=advanced&section=features' ) ); ?>" class="button button-small"><?php esc_html_e( '前往設定', 'tmdo-woocommerce' ); ?></a>
<?php endif; ?>
</td>
</tr>
<tr>
<th><?php esc_html_e( 'WPDO 註冊的 WC 表', 'tmdo-woocommerce' ); ?></th>
<td><?php echo (int) count( $tables ); ?> tables</td>
</tr>
</table>
<h2><?php esc_html_e( 'Vendor Commissionsmarketplace 訂單)', 'tmdo-woocommerce' ); ?></h2>
<?php if ( $totals['order_count'] > 0 ) : ?>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;max-width:720px;margin:1rem 0;">
<div class="wpdo-card">
<div class="wpdo-card__num"><?php echo esc_html( number_format_i18n( $totals['order_count'] ) ); ?></div>
<div class="wpdo-card__lbl"><?php esc_html_e( '總訂單數', 'tmdo-woocommerce' ); ?></div>
</div>
<div class="wpdo-card">
<div class="wpdo-card__num">$<?php echo esc_html( number_format_i18n( $totals['total_subtotal'], 2 ) ); ?></div>
<div class="wpdo-card__lbl"><?php esc_html_e( '總交易額', 'tmdo-woocommerce' ); ?></div>
</div>
<div class="wpdo-card">
<div class="wpdo-card__num">$<?php echo esc_html( number_format_i18n( $totals['total_commission'], 2 ) ); ?></div>
<div class="wpdo-card__lbl"><?php esc_html_e( '平台抽成', 'tmdo-woocommerce' ); ?></div>
</div>
<div class="wpdo-card">
<div class="wpdo-card__num">$<?php echo esc_html( number_format_i18n( $totals['total_payout'], 2 ) ); ?></div>
<div class="wpdo-card__lbl"><?php esc_html_e( 'Vendor 應得', 'tmdo-woocommerce' ); ?></div>
</div>
</div>
<h3><?php esc_html_e( 'Top 10 Vendors', 'tmdo-woocommerce' ); ?></h3>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e( 'Vendor', 'tmdo-woocommerce' ); ?></th>
<th><?php esc_html_e( '訂單數', 'tmdo-woocommerce' ); ?></th>
<th><?php esc_html_e( '交易額', 'tmdo-woocommerce' ); ?></th>
<th><?php esc_html_e( '平台抽成', 'tmdo-woocommerce' ); ?></th>
<th><?php esc_html_e( 'Vendor 應得', 'tmdo-woocommerce' ); ?></th>
</tr>
</thead>
<tbody>
<?php
foreach ( $vendors as $v ) :
$user = get_userdata( (int) $v['vendor_id'] );
?>
<tr>
<td><?php echo $user ? esc_html( $user->display_name ) : '#' . (int) $v['vendor_id']; ?></td>
<td><?php echo (int) $v['order_count']; ?></td>
<td>$<?php echo esc_html( number_format_i18n( (float) $v['subtotal'], 2 ) ); ?></td>
<td>$<?php echo esc_html( number_format_i18n( (float) $v['commission'], 2 ) ); ?></td>
<td>$<?php echo esc_html( number_format_i18n( (float) $v['payout'], 2 ) ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else : ?>
<p style="opacity:.6;"><?php esc_html_e( '尚未有 marketplace 訂單。當客戶下訂並進入 processing/completed 狀態,commission 會自動寫入 wp_wpdo_wc_commissions。', 'tmdo-woocommerce' ); ?></p>
<?php endif; ?>
<h2><?php esc_html_e( '已註冊的 WC 表', 'tmdo-woocommerce' ); ?></h2>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e( '表名', 'tmdo-woocommerce' ); ?></th>
<th><?php esc_html_e( '描述', 'tmdo-woocommerce' ); ?></th>
<th><?php esc_html_e( '健康', 'tmdo-woocommerce' ); ?></th>
</tr>
</thead>
<tbody>
<?php
foreach ( $tables as $t ) :
$probe = TMDO_WooCommerce::doctor_check( $t['table_name'] );
?>
<tr>
<td><code><?php echo esc_html( $t['table_name'] ); ?></code></td>
<td><?php echo esc_html( $t['description'] ?? '' ); ?></td>
<td>
<?php
// Sec-3 hardening: wrap literal ternary in wp_kses_post for pattern hygiene.
echo wp_kses_post( $probe['ok'] ? '✅ ' : '⚠️ ' );
echo esc_html( $probe['message'] );
?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<style>
.wpdo-card { background:#fff; padding:1rem; border-radius:8px; border:1px solid #dcdcde; text-align:center; }
.wpdo-card__num { font-size:1.75rem; font-weight:700; color:#c08a5b; }
.wpdo-card__lbl { font-size:.8rem; opacity:.7; margin-top:.25rem; }
</style>
</div>
<?php
}
/**
* Top N vendors by total commission.
*
* @param int $limit Limit.
* @return array
*/
private static function top_vendors_by_commission( int $limit = 10 ): array {
global $wpdb;
$table = TMDO_DB::table( 'wpdo_wc_commissions' );
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table from TMDO_DB::table() (sanitised); aggregate query.
return $wpdb->get_results(
$wpdb->prepare(
"SELECT vendor_id, COUNT(*) AS order_count,
SUM(subtotal) AS subtotal,
SUM(commission) AS commission,
SUM(vendor_payout) AS payout
FROM `{$table}`
GROUP BY vendor_id
ORDER BY commission DESC
LIMIT %d",
$limit
),
ARRAY_A
) ?: array();
// phpcs:enable
}
/**
* Global commission summary across all vendors.
*
* @return array
*/
private static function global_commission_totals(): array {
global $wpdb;
$table = TMDO_DB::table( 'wpdo_wc_commissions' );
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- $table from TMDO_DB::table() (sanitised); aggregate query.
$row = $wpdb->get_row(
"SELECT
COUNT(*) AS order_count,
SUM(subtotal) AS total_subtotal,
SUM(commission) AS total_commission,
SUM(vendor_payout) AS total_payout
FROM `{$table}`",
ARRAY_A
);
// phpcs:enable
return array(
'order_count' => (int) ( $row['order_count'] ?? 0 ),
'total_subtotal' => (float) ( $row['total_subtotal'] ?? 0 ),
'total_commission' => (float) ( $row['total_commission'] ?? 0 ),
'total_payout' => (float) ( $row['total_payout'] ?? 0 ),
);
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "2meet/2meet-data-optimizer-woocommerce-addon",
"description": "WooCommerce 整合:20 表 schema 註冊 + commission tracking + product/order zone + HPOS notice",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"keywords": ["wordpress", "2meet-data-optimizer", "addon", "woocommerce"],
"require": {
"php": ">=8.1"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"wp-coding-standards/wpcs": "^3.0"
},
"scripts": {
"phpcs": "phpcs --standard=WordPress includes/ 2meet-data-optimizer-woocommerce-addon.php"
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
},
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
}
}
Generated
+2106
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,170 @@
<?php
/**
* TMDO_WC_Orders_Interceptor — vendor commission tracking for WC marketplaces.
*
* V2.1.0 rewrite: replaces legacy `hpct_wc_orders` write path with own
* `wp_wpdo_wc_commissions` table. HPOS-aware (uses `wc_get_order()`).
*
* Trigger: `woocommerce_order_status_changed` → on `processing`/`completed`
* read order's vendor meta (`_hp_vendor`, `_hp_commission`, etc.)
* and INSERT/UPDATE `wp_wpdo_wc_commissions`.
*
* Reads from order via `wc_get_order()` — works for both HPOS and legacy.
*
* @package WP_Data_Optimizer
* @since 1.0.0 (legacy hpct_wc_orders write path)
* @since 2.1.0 (wp_wpdo_wc_commissions custom table; HPOS-aware)
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WC Orders interceptor — modern v2 model.
*
* Operates on `wp_wpdo_wc_commissions` (own custom table). HPCT legacy code removed.
*/
class TMDO_WC_Orders_Interceptor extends TMDO_Interceptor_Base {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'wc_orders';
/**
* Order statuses that trigger commission recording.
*
* @var array<string>
*/
private const TRACKED_STATUSES = array( 'completed', 'processing' );
/**
* Registers WordPress hooks for this interceptor.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'woocommerce_order_status_changed', array( $this, 'action_order_status_changed' ), 10, 3 );
}
/**
* Sync order data when WC status changes to a tracked state.
*
* @param int $order_id Order ID.
* @param string $old_status Old status (unused).
* @param string $new_status New status.
* @return void
*/
public function action_order_status_changed( int $order_id, string $old_status, string $new_status ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
if ( ! $this->is_active() ) {
return;
}
if ( ! in_array( $new_status, self::TRACKED_STATUSES, true ) ) {
return;
}
try {
$this->sync_commission( $order_id, $new_status );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'woocommerce_order_status_changed', $e->getMessage() );
}
}
/**
* INSERT/UPDATE wp_wpdo_wc_commissions for the given order.
*
* Skips orders without a `_hp_vendor` meta — only marketplace orders count.
*
* @param int $wc_order_id WooCommerce order ID.
* @param string $status Order status.
* @return void
*/
private function sync_commission( int $wc_order_id, string $status ): void {
if ( ! function_exists( 'wc_get_order' ) ) {
return;
}
$order = wc_get_order( $wc_order_id );
if ( ! $order ) {
return;
}
$vendor_id = (int) $order->get_meta( '_hp_vendor' );
if ( $vendor_id <= 0 ) {
// Not a marketplace order — skip.
return;
}
$listing_id = (int) $order->get_meta( '_hp_listing' );
$subtotal = (float) ( $order->get_meta( '_hp_subtotal' ) ?: 0 );
$commission = (float) ( $order->get_meta( '_hp_commission' ) ?: 0 );
$vendor_payout = (float) ( $order->get_meta( '_hp_vendor_payout' ) ?: 0 );
$commission_rate = (float) ( $order->get_meta( '_hp_commission_rate' ) ?: 0 );
$hpos_enabled = class_exists( 'TMDO_WooCommerce' ) && TMDO_WooCommerce::is_hpos_enabled() ? 1 : 0;
$table = TMDO_DB::table( 'wpdo_wc_commissions' );
$now = TMDO_DB::now();
// v2.1.2 race-condition fix: atomic upsert via UNIQUE KEY ui_order_vendor.
// Replaces SELECT-then-INSERT/UPDATE which could race two concurrent
// status-change events (e.g. gateway IPN + admin click) and trigger
// duplicate-key DB errors. Single round-trip; on conflict only updates
// the mutable columns (status / updated_at), preserves financials.
TMDO_DB::upsert(
$table,
array(
'wc_order_id' => $wc_order_id,
'vendor_id' => $vendor_id,
'listing_id' => $listing_id,
'subtotal' => $subtotal,
'commission' => $commission,
'vendor_payout' => $vendor_payout,
'commission_rate' => $commission_rate,
'status' => $status,
'hpos_enabled' => $hpos_enabled,
'created_at' => $now,
'updated_at' => $now,
),
array( 'status', 'updated_at' ), // columns to update on conflict.
array( 'wc_order_id', 'vendor_id' ), // composite unique key.
array( '%d', '%d', '%d', '%f', '%f', '%f', '%f', '%s', '%d', '%s', '%s' )
);
}
/**
* Aggregate commission summary for a vendor.
*
* @param int $vendor_id Vendor user ID.
* @param string $status Optional status filter ('processing' / 'completed' / 'all').
* @return array{total_subtotal:float, total_commission:float, total_payout:float, order_count:int}
*/
public static function vendor_summary( int $vendor_id, string $status = 'all' ): array {
global $wpdb;
$table = TMDO_DB::table( 'wpdo_wc_commissions' );
$where = $wpdb->prepare( ' WHERE vendor_id = %d ', $vendor_id );
if ( 'all' !== $status ) {
$where .= $wpdb->prepare( ' AND status = %s ', $status );
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- {$table} via TMDO_DB::table(); {$where} is a pre-validated SQL fragment.
$row = $wpdb->get_row(
"SELECT
SUM(subtotal) AS total_subtotal,
SUM(commission) AS total_commission,
SUM(vendor_payout) AS total_payout,
COUNT(*) AS order_count
FROM `{$table}` {$where}", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} via TMDO_DB::table(); {$where} is a pre-validated SQL fragment.
ARRAY_A
);
return array(
'total_subtotal' => (float) ( $row['total_subtotal'] ?? 0 ),
'total_commission' => (float) ( $row['total_commission'] ?? 0 ),
'total_payout' => (float) ( $row['total_payout'] ?? 0 ),
'order_count' => (int) ( $row['order_count'] ?? 0 ),
);
}
}
@@ -0,0 +1,236 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform integration with WooCommerce: term count cache transform
/**
* TMDO_WC_Term_Count_Filter — Reroute WooCommerce term count cache from
* wp_termmeta to wp_options (v2.12.3 Phase 3).
*
* Background
* ----------
* WooCommerce caches the number of products per term as a wp_termmeta row:
*
* wp_termmeta(term_id=5, meta_key='product_count_product_cat', meta_value='42')
*
* Each `product_count_<taxonomy>` row is a transient-like cache — WC
* recalculates and writes whenever a product is added/removed from a term.
* Pattern is identical to HivePress's per-post TTL cache anti-pattern that
* v2.11.5 solved: the data is genuine cache, but storage location is wrong.
*
* Strategy
* --------
* Mirror v2.11.5 `TMDO_Hivepress_Transient_Filter`. Intercept term metadata
* writes/reads where meta_key starts with `product_count_` and route to
* wp_options as native transient (no per-key TTL — WC manages its own
* invalidation; we just provide indistinguishable storage).
*
* Translation key:
* wp_options('_transient_wpdo_wc_termcount_<term_id>_<md5(meta_key)>')
*
* Namespacing by term_id keeps caches scoped to the right term; md5 of the
* meta_key handles arbitrary-length taxonomy slugs (`product_count_my_long_taxonomy_with_many_chars`).
*
* Default: enabled. Toggle: `wpdo_wc_term_count_filter_enabled` option.
*
* 🔒 Lessons applied (v2.11.7): added to TMDO_Hook_Bus_Bridge::COEXIST_WHITELIST
* so `wp wpdo conflict-scan` does not report a false positive.
*
* @package WP_Data_Optimizer
* @since 2.12.3
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Routes WooCommerce term count cache out of wp_termmeta into wp_options.
*/
final class TMDO_WC_Term_Count_Filter {
/** Option toggle key. */
public const OPT_ENABLED = 'wpdo_wc_term_count_filter_enabled';
/** Prefix that identifies a WooCommerce term count cache row. */
public const PREFIX = 'product_count_';
/** Namespace prefix for translated wp_options entries. */
public const TRANSLATED_NAMESPACE = 'wpdo_wc_termcount_';
/**
* Register the four metadata filters on `init` priority 5.
* Idempotent — safe to call multiple times.
*
* @return void
*/
public static function init(): void {
if ( ! self::is_enabled() ) {
return;
}
// Filter priority 9 (before Hook Bus at 10), same as HivePress filter.
add_filter( 'get_term_metadata', array( self::class, 'on_read' ), 9, 4 );
add_filter( 'add_term_metadata', array( self::class, 'on_add' ), 9, 5 );
add_filter( 'update_term_metadata', array( self::class, 'on_update' ), 9, 5 );
add_filter( 'delete_term_metadata', array( self::class, 'on_delete' ), 9, 5 );
}
/**
* Check the admin toggle. Defaults to enabled.
*
* @return bool
*/
public static function is_enabled(): bool {
return (bool) get_option( self::OPT_ENABLED, '1' );
}
/**
* Test whether a meta_key is a WC term count cache row.
*
* @param mixed $meta_key Candidate meta_key.
* @return bool
*/
public static function is_target_key( $meta_key ): bool {
return is_string( $meta_key ) && str_starts_with( $meta_key, self::PREFIX );
}
/**
* Translate (term_id, meta_key) → namespaced wp_options option_name root.
*
* @param int $term_id Term ID.
* @param string $meta_key Original `product_count_*` meta_key.
* @return string Translated option_name root (without `_transient_` prefix).
*/
public static function translate_key( int $term_id, string $meta_key ): string {
return self::TRANSLATED_NAMESPACE . $term_id . '_' . md5( $meta_key );
}
/**
* Filter callback: get_term_metadata.
*
* @param mixed $pre Filter accumulator (null at our priority).
* @param int $object_id Term ID.
* @param string $meta_key Meta key being read.
* @param bool $single Whether single value was requested.
* @return mixed
*/
public static function on_read( $pre, $object_id, $meta_key, $single ) {
unset( $single );
if ( ! self::is_target_key( $meta_key ) ) {
return $pre;
}
$translated = self::translate_key( (int) $object_id, (string) $meta_key );
$option_name = '_transient_' . $translated;
$value = get_option( $option_name, null );
if ( null === $value ) {
// Cache miss — fall through to wp_termmeta (back-compat).
return $pre;
}
// WP unwraps [0] for single=true callers; return array of values.
return array( $value );
}
/**
* Filter callback: add_term_metadata.
*
* @param mixed $check Filter accumulator (null at our priority).
* @param int $object_id Term ID.
* @param string $meta_key Meta key being written.
* @param mixed $meta_value Value being written.
* @param bool $unique Whether the unique flag was set (unused).
* @return mixed
*/
public static function on_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
unset( $unique );
if ( ! self::is_target_key( $meta_key ) ) {
return $check;
}
self::write_translated( (int) $object_id, (string) $meta_key, $meta_value );
return true;
}
/**
* Filter callback: update_term_metadata.
*
* @param mixed $check Filter accumulator (null at our priority).
* @param int $object_id Term ID.
* @param string $meta_key Meta key being written.
* @param mixed $meta_value Value being written.
* @param mixed $prev_value Previous value scope (unused).
* @return mixed
*/
public static function on_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
unset( $prev_value );
if ( ! self::is_target_key( $meta_key ) ) {
return $check;
}
self::write_translated( (int) $object_id, (string) $meta_key, $meta_value );
return true;
}
/**
* Filter callback: delete_term_metadata.
*
* @param mixed $check Filter accumulator (null at our priority).
* @param int $object_id Term ID.
* @param string $meta_key Meta key being deleted.
* @param mixed $meta_value Value-scoped delete (unused).
* @param bool $delete_all Whether to delete from all objects (unused).
* @return mixed
*/
public static function on_delete( $check, $object_id, $meta_key, $meta_value, $delete_all ) {
unset( $meta_value, $delete_all );
if ( ! self::is_target_key( $meta_key ) ) {
return $check;
}
$translated = self::translate_key( (int) $object_id, (string) $meta_key );
$option_name = '_transient_' . $translated;
delete_option( $option_name );
return true;
}
/**
* Internal: persist value to wp_options as a no-expiry transient.
*
* `autoload=no` because WC reads on demand only — never via wp_load_alloptions().
*
* @param int $term_id Term ID.
* @param string $meta_key Original `product_count_*` meta_key.
* @param mixed $meta_value Value to store.
* @return void
*/
private static function write_translated( int $term_id, string $meta_key, $meta_value ): void {
$translated = self::translate_key( $term_id, $meta_key );
$option_name = '_transient_' . $translated;
update_option( $option_name, $meta_value, false );
}
/**
* One-time migration helper: count `product_count_*` rows in wp_termmeta.
*
* @return int
*/
public static function count_legacy_termmeta_rows(): int {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
return (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->termmeta} WHERE meta_key LIKE 'product\\_count\\_%'"
);
// phpcs:enable
}
/**
* One-time migration: DELETE all historical `product_count_*` rows from
* wp_termmeta. Future writes auto-route to wp_options.
*
* @return int Rows deleted.
*/
public static function purge_legacy_termmeta_rows(): int {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
return (int) $wpdb->query(
"DELETE FROM {$wpdb->termmeta} WHERE meta_key LIKE 'product\\_count\\_%'"
);
// phpcs:enable
}
}
@@ -0,0 +1,62 @@
<?php
/**
* Bootstrap for 2meet Data Optimizer WooCommerce AddOn.
*
* @package TMDO_WOOCOMMERCE
* @since 0.1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class TMDO_Woocommerce_Bootstrap {
public static function init(): void {
if ( ! class_exists( 'TMDO_API' ) && ! class_exists( 'WPDO_API' ) ) {
add_action( 'admin_notices', array( __CLASS__, 'missing_core_notice' ) );
return;
}
load_plugin_textdomain(
'tmdo-woocommerce',
false,
dirname( plugin_basename( TMDO_WOOCOMMERCE_FILE ) ) . '/languages'
);
// 載入 4 個 WC 整合檔案
require_once TMDO_WOOCOMMERCE_PATH . 'includes/class-tmdo-woocommerce.php';
require_once TMDO_WOOCOMMERCE_PATH . 'includes/class-tmdo-wc-term-count-filter.php';
require_once TMDO_WOOCOMMERCE_PATH . 'includes/class-tmdo-wc-orders-interceptor.php';
if ( is_admin() ) {
require_once TMDO_WOOCOMMERCE_PATH . 'admin/class-tmdo-admin-wc.php';
if ( class_exists( 'TMDO_Admin_WC' ) && method_exists( 'TMDO_Admin_WC', 'register' ) ) {
add_action( 'plugins_loaded', array( 'TMDO_Admin_WC', 'register' ), 30 );
}
}
// WPDO_WooCommerce::register() 會掛 wpdo_register_fields / custom_tables / entity_fields
if ( class_exists( 'TMDO_WooCommerce' ) && method_exists( 'TMDO_WooCommerce', 'register' ) ) {
TMDO_WooCommerce::register();
}
// WC orders commission interceptor
if ( class_exists( 'TMDO_WooCommerce' )
&& TMDO_WooCommerce::is_active()
&& class_exists( 'TMDO_WC_Orders_Interceptor' ) ) {
( new TMDO_WC_Orders_Interceptor() )->register_hooks();
}
// WC term count filter
if ( class_exists( 'TMDO_WC_Term_Count_Filter' ) ) {
add_action( 'init', array( 'TMDO_WC_Term_Count_Filter', 'init' ), 5 );
}
}
public static function missing_core_notice(): void {
echo '<div class="notice notice-error"><p>';
echo esc_html__( '2meet Data Optimizer WooCommerce AddOn 需要 2meet-data-optimizer 核心外掛,請先安裝並啟用。', 'tmdo-woocommerce' );
echo '</p></div>';
}
}
+672
View File
@@ -0,0 +1,672 @@
<?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 WP_Data_Optimizer
* @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 永久隱藏此通知。', 'tmdo-woocommerce' ),
(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",
);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
/**
* Uninstall handler for 2meet Data Optimizer Woocommerce AddOn.
*
* 僅清理本 AddOn 自有 schema(核心 wp_wpdo_* 系統表保留給 core)。
*
* @package TMDO_WOOCOMMERCE
*/
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
// Phase 3 完整實作。Phase 0 stub:保留所有資料以避免誤刪。