WooCommerce 物流串接:超商取貨整合區塊結帳

台灣電商的最後一塊拼圖:物流。這篇分兩段,前半是自訂運送方式的通用做法,後半是超商取貨 最難搞的選擇「門市電子地圖」。

自訂運送方式的骨架

跟金流很像,物流也是繼承一個抽象類別:

// ========== 檔案一:my-plugin.php(主外掛檔)==========
// WC_Shipping_Method 要等 WooCommerce 載入後才存在,所以類別檔不能在
// 檔案頂層 require,否則載入順序一變就 fatal error.
add_action(
	'plugins_loaded',
	function () {
		if ( ! class_exists( 'WooCommerce' ) ) {
			return;
		}

		// woocommerce_shipping_init 正好在 WooCommerce 組運送方式清單之前,
		// 這時候 WC_Shipping_Method 一定已經載入了.
		add_action(
			'woocommerce_shipping_init',
			function () {
				require_once __DIR__ . '/includes/class-my-shipping-cvs.php';
			}
		);

		add_filter(
			'woocommerce_shipping_methods',
			function ( $methods ) {
				$methods['my_cvs'] = 'My_Shipping_CVS';
				return $methods;
			}
		);
	},
	11
);

// ========== 檔案二:includes/class-my-shipping-cvs.php ==========
// 這是獨立的檔案,不要跟上面那段貼在一起;只有上面那個 hook 會載入它.
class My_Shipping_CVS extends WC_Shipping_Method {

	public function __construct( $instance_id = 0 ) {
		$this->id                 = 'my_cvs';
		$this->instance_id        = absint( $instance_id );
		$this->method_title       = __( '超商取貨', 'my-plugin' );
		$this->method_description = __( '讓顧客選擇超商門市取貨。', 'my-plugin' );

		// 宣告這個方法可以加進運送區域,並支援區域層級的設定.
		$this->supports = array(
			'shipping-zones',
			'instance-settings',
			'instance-settings-modal',
		);

		$this->init();
	}

	public function init() {
		$this->init_form_fields();
		$this->init_settings();

		$this->title = $this->get_option( 'title', __( '超商取貨', 'my-plugin' ) );

		add_action( 'woocommerce_update_options_shipping_' . $this->id, array( $this, 'process_admin_options' ) );
	}

	public function init_form_fields() {
		$this->instance_form_fields = array(
			'title' => array(
				'title'   => __( '顯示名稱', 'my-plugin' ),
				'type'    => 'text',
				'default' => __( '超商取貨', 'my-plugin' ),
			),
			'cost'  => array(
				'title'   => __( '運費', 'my-plugin' ),
				'type'    => 'price',
				'default' => '60',
			),
			'free_over' => array(
				'title'       => __( '免運門檻', 'my-plugin' ),
				'type'        => 'price',
				'description' => __( '訂單金額達此數字免運,留空表示不提供免運。', 'my-plugin' ),
				'default'     => '',
			),
		);
	}

	/**
	 * 這個方法決定「結帳頁要出現哪些運送選項、各多少錢」.
	 */
	public function calculate_shipping( $package = array() ) {
		$cost      = (float) $this->get_option( 'cost', 0 );
		$free_over = $this->get_option( 'free_over' );

		if ( '' !== $free_over && $package['contents_cost'] >= (float) $free_over ) {
			$cost = 0;
		}

		$this->add_rate(
			array(
				'id'      => $this->get_rate_id(),
				'label'   => $this->title,
				'cost'    => $cost,
				'package' => $package,
			)
		);
	}
}

完成後就能看到 WooCommerce 的運送方式多了以下頁面:

三個重點:

instance_form_fields 而不是 form_fields 加了 shipping-zones 支援之後,設定是掛在「運送區域」底下的,每個區域可以有不同的運費。用錯的話設定會出現在錯的地方。

calculate_shipping() 每次購物車變動都會跑。 使用者改數量、換地址都會重算,所以這裡面不要打外部 API — 如果運費要跟物流商即時查詢,一定要快取,否則購物車會慢到不能用。

add_rate() 的 meta_data 這是很好用但少人知道的參數:

$this->add_rate(
	array(
		'id'        => $this->get_rate_id(),
		'label'     => $this->title,
		'cost'      => $cost,
		'meta_data' => array(
			'store_id'   => $store_id,
			'store_name' => $store_name,
		),
	)
);

放在這裡的資料會跟著運送方式存進訂單,這正是等一下超商門市資訊的去處。

超商取貨:那個電子地圖

台灣的超商取貨流程長這樣,跟一般運送方式完全不同:

結帳頁                     物流商電子地圖              你的網站
  │                             │                       │
  ├─ 點「選擇門市」──────────────→ 開啟門市地圖          │
  │                             │                       │
  │                             ├── 使用者選好門市 ─────→ POST 門市資料到你的 callback
  │                             │                       │
  │←──────────────── 導回結帳頁,帶著門市資訊 ───────────┤

關鍵在於:門市資料是物流商用 POST 打回你的網站的,不是前端 JavaScript 給你的。所以你需要一個接收端點,做法跟金流的背景通知一樣:

// 產生 callback 網址:https://your-site.com/wc-api/my_cvs_map/
$callback = WC()->api_request_url( 'my_cvs_map' );

add_action( 'woocommerce_api_my_cvs_map', 'my_plugin_handle_cvs_map' );

/**
 * 把物流商回傳的 EncryptData 解回陣列.
 *
 * 回傳只有 EncryptData 一個欄位,內容是 AES-256-CBC 加密後再轉成的
 * 十六進位字串,沒有明文的門市欄位可以讀.
 */
function my_plugin_decrypt( $encrypted, $key, $iv ) {
	// hex2bin() 收到非十六進位字串會發警告並回傳 false,先擋掉.
	if ( 0 !== strlen( $encrypted ) % 2 || ! preg_match( '/^[0-9a-fA-F]+$/', $encrypted ) ) {
		return array();
	}

	$raw = openssl_decrypt(
		hex2bin( $encrypted ),
		'AES-256-CBC',
		$key,
		// 關掉 PHP 的自動 padding 處理,改成照技術文件自己拆.
		OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING,
		$iv
	);

	if ( false === $raw || '' === $raw ) {
		return array();
	}

	// 最後一個位元組的 ord 值就是填充長度,不拆掉 JSON 會解不開.
	$pad = ord( substr( $raw, -1 ) );

	// 區塊長度是 16,填充長度超出這個範圍表示解出來的東西不對.
	if ( $pad < 1 || $pad > 16 || $pad >= strlen( $raw ) ) {
		return array();
	}

	$data = json_decode( substr( $raw, 0, -$pad ), true );

	return is_array( $data ) ? $data : array();
}

function my_plugin_handle_cvs_map() {
	$encrypted = isset( $_POST['EncryptData'] ) ? sanitize_text_field( wp_unslash( $_POST['EncryptData'] ) ) : '';
	$payload   = my_plugin_decrypt( $encrypted, MY_PLUGIN_HASH_KEY, MY_PLUGIN_HASH_IV );

	$store_id   = isset( $payload['StoreID'] ) ? sanitize_text_field( $payload['StoreID'] ) : '';
	$store_name = isset( $payload['StoreName'] ) ? sanitize_text_field( $payload['StoreName'] ) : '';
	$store_addr = isset( $payload['StoreAddr'] ) ? sanitize_text_field( $payload['StoreAddr'] ) : '';

	if ( '' === $store_id ) {
		wp_safe_redirect( wc_get_checkout_url() );
		exit;
	}

	// 暫存到 session,結帳建立訂單時再寫進訂單.
	WC()->session->set(
		'my_plugin_cvs_store',
		array(
			'id'      => $store_id,
			'name'    => $store_name,
			'address' => $store_addr,
		)
	);

	wp_safe_redirect( wc_get_checkout_url() );
	exit;
}

有幾個實務上會踩到的點:

一、資料要先進 session,不能直接進訂單。 使用者選門市的時候訂單還不存在(訂單是按下單才建立的)。所以先存 session,等訂單建立時再搬過去:

add_action( 'woocommerce_checkout_create_order', function ( $order ) {
	$store = WC()->session->get( 'my_plugin_cvs_store' );
	if ( $store ) {
		$order->update_meta_data( '_cvs_store_id', $store['id'] );
		$order->update_meta_data( '_cvs_store_name', $store['name'] );
		$order->update_meta_data( '_cvs_store_address', $store['address'] );
	}
} );

二、選完門市會離開結帳頁再回來,購物車不能掉。 因為是整頁跳轉到物流商再跳回來,區塊結帳頁填到一半的資料會消失。這是超商取貨在區塊結帳頁最惱人的地方。目前的解法是在跳轉前把已填的資料存起來(Store API 的 update-customer 端點會把地址存進 session),或者用彈出視窗而不是整頁跳轉。

三、SameSite cookie 的坑。 物流商 POST 回來是跨站請求,如果你的 session cookie 是 SameSite=Strict,那個請求會拿不到購物車。WooCommerce 預設是 Lax,POST 跨站在 Lax 下也不會帶 cookie — 所以上面那段程式碼裡的 WC()->session 有可能是空的。

實務的做法是在 callback 網址帶一個自己產生的一次性 token,用它找回對應的 session 或直接存進 transient。這個坑很隱蔽,因為在同站測試(自己模擬 POST)完全正常,只有接上真的物流商才會出事。

顯示與出貨

門市資訊存進訂單之後,至少要出現在三個地方:後台訂單頁、訂單確認頁、通知信。

// 後台訂單頁顯示.
add_action( 'woocommerce_admin_order_data_after_shipping_address', function ( $order ) {
	$store_name = $order->get_meta( '_cvs_store_name' );
	if ( $store_name ) {
		echo '<p><strong>' . esc_html__( '取貨門市', 'my-plugin' ) . ':</strong> ' . esc_html( $store_name ) . '</p>';
	}
} );

出貨階段則是反過來:把訂單資料送去物流商建立托運單,拿回物流編號存進訂單:

add_action( 'woocommerce_order_status_processing', function ( $order_id ) {
	if ( ! as_next_scheduled_action( 'my_plugin_create_shipment', array( $order_id ) ) ) {
		as_schedule_single_action( time() + 5, 'my_plugin_create_shipment', array( $order_id ) );
	}
} );

寫到這裡你應該有感覺了:金流、發票、物流三者的程式結構高度相似,都是「訂單狀態變化 → 呼叫外部 API → 結果寫回訂單」,差別只在 API 規格與觸發時機。掌握了這個模式,換一家金流、換一家發票商,改的只是最裡面那層。

這一篇 AI 幫得上與幫不上的

幫得上WC_Shipping_Method 的骨架、instance_form_fields 設定、運費計算邏輯、後台顯示的 hook。這些都是標準結構。

幫不上:超商電子地圖那一整段。這是台灣特有的流程,國外的訓練資料裡沒有,AI 通常會給你一個「用 JavaScript 開一個視窗然後接收 postMessage」的想像版本 — 那不是物流商實際的運作方式。這一段只能照物流商的技術文件自己寫,或參考現成的台灣物流外掛。

而 SameSite 那個坑,我沒看過任何 AI 主動提醒。你要自己知道,然後在提示裡明講:

callback 是物流商跨站 POST 回來的,session cookie 可能拿不到,用一次性 token 對應 transient 來取回購物車。

台灣電商三件套到這裡都齊了。最後一篇我們把這些散落的客製收成一個外掛,處理相依宣告、HPOS 相容性,並用 everyting-wp:review 來做程式碼審核。

AI 文章延伸

讓 AI 幫你讀這篇文章

選擇平台後會自動帶入閱讀脈絡,快速整理重點、補齊盲點,並延伸到同站相關文章。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

這個網站採用 Akismet 服務減少垃圾留言。進一步了解 Akismet 如何處理網站訪客的留言資料