iT邦幫忙

2026 iThome 鐵人賽

0

通常台灣電商的發票欄位長得大概像這樣:先選發票類型,選完個人還要再選載具,選了手機條碼才要填那串以斜線開頭的號碼;選捐贈則是填愛心碼;選公司才是抬頭加統編,這一篇要把這要的邏輯用 Additional Checkout Fields API 完整實作出來,全程不寫任何 JavaScript。

先把欄位關係整理出來

寫程式之前先把規格列清楚,這張表就是後面每個 hiddenrequired 規則的來源:

欄位 型別 顯示條件 格式
發票類型 select 永遠顯示、必填 personal/company/donation
發票抬頭 text 發票類型 = 公司 最長 60 字
統一編號 text 發票類型 = 公司 8 位數字且檢查碼正確
載具類型 select 發票類型 = 個人 member/mobile/certificate
手機條碼 text 個人 載具 = 手機條碼 斜線加 7 碼大寫英數
自然人憑證條碼 text 個人 載具 = 自然人憑證 2 碼大寫英文加 14 碼數字
愛心碼 text 發票類型 = 捐贈 3 到 7 位數字

七個欄位、五種顯示條件,其中兩個是兩層條件,用傳統佈景主題的思維,這時候大概已經開始想 jQuery 的 change 事件與 slideToggle() 了;區塊結帳頁不用,這些條件全部寫在 PHP 的註冊參數裡。

先複習欄位是怎麼註冊的

一個欄位就是一次 woocommerce_register_additional_checkout_field(),把 id、label、location、type 丟進去,前端表單、Store API、驗證、訂單儲存就都有了:

woocommerce_register_additional_checkout_field(
	array(
		'id'       => 'block-theme/invoice-tax-id',
		'label'    => __( '統一編號', 'block-theme' ),
		'location' => 'order',
		'type'     => 'text',
		'required' => true,    // 除了 true / false,還可以給一段條件規則.
		'hidden'   => false,   // 同上.
	)
);

機制的全部就在 requiredhidden 這兩個參數:它們接受布林值,也接受一段條件規則。給布林值就是永遠必填、永遠顯示;給規則,就變成「符合某個狀態時才必填、才顯示」。

那規則要拿什麼來比對?結帳區塊在每次資料變動時,都會把當下的購物車與表單狀態整理成一個叫 document object 的 JSON,結構大致是這樣:

{
  cart:     { items, items_count, coupons, totals, needs_shipping, ... },
  customer: { id, billing_address, shipping_address, additional_fields },
  checkout: { additional_fields, payment_method, customer_note, ... }
}

顧客在發票類型選了「公司電子發票」,checkout.additional_fields 裡的 block-theme/invoice-type 就會變成 'company'。所謂條件規則,就是一段描述「這個 JSON 要長什麼樣」的 JSON Schema,比對成立,欄位就顯示或變成必填,比對不成立就消失。

條件規則是一段 JSON Schema

JSON Schema 不是資料,是**描述資料要長什麼樣的規格,**這裡拿它去問 document object 一個是非題 —「目前的狀態,發票類型是不是 company?」是的話欄位就顯示,不是就隱藏。

先看被問的那份資料。顧客在發票類型選了「公司電子發票」的當下,document object 是這樣:

{
  "cart": { "items_count": 1, "needs_shipping": true },
  "customer": { "id": 0, "billing_address": { } },
  "checkout": {
    "additional_fields": {
      "block-theme/invoice-type": "company"
    }
  }
}

要問的值躲在三層裡面:checkoutadditional_fieldsblock-theme/invoice-type,而 JSON Schema 的規定是每往下指一層,就要寫一個 properties 把下一層包起來,所以規則的形狀會跟資料的結構一模一樣,只是每層中間多插一個 properties

資料(document object) 規則(JSON Schema)
checkout propertiescheckout
checkout.additional_fields propertiescheckoutpropertiesadditional_fields
checkout.additional_fields['block-theme/invoice-type'] 再一層 properties,然後才是欄位 ID

把最後一層的值換成條件 array( 'const' => 'company' )const 就是「必須等於這個值」,另外還有 enum 可以多選一),完整的規則長這樣:

array(
	'type'       => 'object',
	'properties' => array(
		'checkout' => array(
			'properties' => array(
				'additional_fields' => array(
					'properties' => array(
						'block-theme/invoice-type' => array( 'const' => 'company' ),
					),
				),
			),
		),
	),
);

讀法是由外往內:「這份資料是個物件,它的 checkout 屬性裡面的 additional_fields 屬性裡面的 block-theme/invoice-type,值必須是 company」。看起來很囉唆,但這就是 JSON Schema 描述巢狀結構的標準寫法。

而 WooCommerce 允許省略最外層的 typeproperties,只要規則最上層的 key 是 cartcheckoutcustomer 其中之一,它會自己補上外殼,所以同一個條件可以縮成這樣:

array(
	'checkout' => array(
		'properties' => array(
			'additional_fields' => array(
				'properties' => array(
					'block-theme/invoice-type' => array( 'const' => 'company' ),
				),
			),
		),
	),
);

這段就是接下來每個欄位的 hiddenrequired 要填的東西。

路徑要記得跟 location 對應,order 的欄位在 checkout.additional_fieldscontactaddress 的欄位則在 customer.additional_fieldscustomer.billing_address 底下。

既然七個欄位都是同一個規則,我們可以把它寫一個產生器:

/**
 * 產生一段比對訂單區欄位值的 schema rule。
 *
 * @param array $conditions 欄位 ID 對應的期待值,值為陣列時視為多選一。
 * @return array
 */
function block_theme_invoice_rule( array $conditions ) {
	$properties = array();

	foreach ( $conditions as $field_id => $expected ) {
		$properties[ $field_id ] = is_array( $expected )
			? array( 'enum' => $expected )
			: array( 'const' => $expected );
	}

	return array(
		'checkout' => array(
			'type'       => 'object',
			'required'   => array( 'additional_fields' ),
			'properties' => array(
				'additional_fields' => array(
					'type'       => 'object',
					'required'   => array_keys( $properties ),
					'properties' => $properties,
				),
			),
		),
	);
}

傳一個條件就是單層、傳兩個就是 AND(同一個 object 裡的多個 property 本來就是全部都要成立),要多選一就把值傳成陣列變成 enum

顯示條件與必填條件是同一件事的正反面,所以再寫一個反向的:

function block_theme_invoice_rule_not( array $rule ) {
	return array(
		'not' => array( 'properties' => $rule ),
	);
}

之後每個欄位都是這組固定寫法:

'required' => $is_company,
'hidden'   => block_theme_invoice_rule_not( $is_company ),

一個 select 帶出兩層分支

主選單就是普通的 select,requiredtrue

woocommerce_register_additional_checkout_field(
	array(
		'id'       => 'block-theme/invoice-type',
		'label'    => __( '發票類型', 'block-theme' ),
		'location' => 'order',
		'type'     => 'select',
		'required' => true,
		'options'  => array(
			array(
				'value' => 'personal',
				'label' => __( '個人電子發票(存入載具)', 'block-theme' ),
			),
			array(
				'value' => 'company',
				'label' => __( '公司電子發票(三聯式)', 'block-theme' ),
			),
			array(
				'value' => 'donation',
				'label' => __( '捐贈發票', 'block-theme' ),
			),
		),
	)
);

接著把五個條件先準備好,重複使用:

$is_company  = block_theme_invoice_rule( array( 'block-theme/invoice-type' => 'company' ) );
$is_donation = block_theme_invoice_rule( array( 'block-theme/invoice-type' => 'donation' ) );
$is_personal = block_theme_invoice_rule( array( 'block-theme/invoice-type' => 'personal' ) );

// 兩層條件:發票類型是個人,而且載具類型是手機條碼。
$is_mobile = block_theme_invoice_rule(
	array(
		'block-theme/invoice-type'    => 'personal',
		'block-theme/invoice-carrier' => 'mobile',
	)
);

公司分支的統編欄位:

woocommerce_register_additional_checkout_field(
	array(
		'id'                => 'block-theme/invoice-tax-id',
		'label'             => __( '統一編號', 'block-theme' ),
		'location'          => 'order',
		'type'              => 'text',
		'required'          => $is_company,
		'hidden'            => block_theme_invoice_rule_not( $is_company ),
		'attributes'        => array(
			'autocomplete' => 'off',
			'maxLength'    => '8',
			'pattern'      => '[0-9]{8}',
			'title'        => __( '8 位數字', 'block-theme' ),
		),
		'sanitize_callback' => 'block_theme_sanitize_digits',
		'validate_callback' => 'block_theme_validate_invoice_tax_id',
	)
);

手機條碼欄位只是把條件換成兩層的那組:

'required' => $is_mobile,
'hidden'   => block_theme_invoice_rule_not( $is_mobile ),

載具類型 select 本身也是條件欄位($is_personal),於是就有了兩層連動:選「個人」出現載具類型,載具類型選「手機條碼」再出現號碼欄位。切回「公司」時,這兩個欄位一起從 DOM 消失,換成抬頭與統編。

順帶提兩個註冊參數的細節:

  • attributes 有白名單,只有 maxLengthreadOnlypatternautocompleteautocapitalizetitle 以及 aria- / data- 開頭的屬性會被保留,其他(例如 placeholderinputMode)會被丟掉並留下一則 _doing_it_wrong 警告。
  • 欄位順序就是註冊順序,附加欄位沒有 index 參數可以調(那是核心地址欄位才有的)。

格式錯誤要在後端驗證

pattern 屬性只是瀏覽器層的提示,真正的把關在 validate_callback,統編除了 8 位數字,還有檢查碼可以驗:

function block_theme_is_valid_tax_id( $tax_id ) {
	if ( ! preg_match( '/^\d{8}$/', $tax_id ) || '00000000' === $tax_id ) {
		return false;
	}

	$weights = array( 1, 2, 1, 2, 1, 2, 4, 1 );
	$sum     = 0;

	foreach ( str_split( $tax_id ) as $position => $digit ) {
		$product = (int) $digit * $weights[ $position ];

		// 乘積的十位數與個位數相加。
		$sum += intdiv( $product, 10 ) + ( $product % 10 );
	}

	if ( 0 === $sum % 5 ) {
		return true;
	}

	// 第 7 碼是 7 的號碼允許差 1,這是財政部規則裡的例外。
	return '7' === $tax_id[6] && 0 === ( $sum + 1 ) % 5;
}

搭配 sanitize_callback 先把非數字清掉,客人貼上「12345675」以外的格式(例如帶空格)也不會誤判:

'sanitize_callback' => 'block_theme_sanitize_digits',   // preg_replace( '/\D/', '', $value )

載具的兩個欄位同樣各有格式:手機條碼是 #^/[0-9A-Z.+-]{7}$#、自然人憑證是 /^[A-Z]{2}\d{14}$/,兩個都先用 strtoupper() 正規化再驗,因為客人很習慣打小寫。

實際送出時,錯誤會顯示在欄位旁邊:

另外,註冊參數其實還有一個 validation 可以用 schema 描述格式規則,但它跑出來的錯誤訊息是固定的 Please provide a valid %s,而且一旦提供 validation,你的 validate_callback 就會被它取代(CheckoutFields::get_validate_callback())。要給客人看得懂的中文訊息,還是用 callback。

不用寫 JS,但要知道它做了什麼

條件欄位的比對是在瀏覽器端跑的,WooCommerce 為此準備了一支 wc-schema-parser,而且只有在真的有欄位註冊了 schema rule 時才會載入,沒用到條件欄位的網站不會多下載這支腳本。

伺服器端則是 Store API 收到請求時會重建 document object,把隱藏欄位整個排除在驗證之外,所以「隱藏的必填欄位」不會擋住結帳,這點不用自己處理。

如果請 AI 開發這段

直接說「加台灣電子發票欄位」,AI 大機率給你 woocommerce_checkout_fields 的舊寫法,再配一段 jQuery 顯示隱藏,要它一次到位條件要明確:

用 woocommerce_register_additional_checkout_field 在區塊結帳頁註冊台灣電子發票欄位,location 用 order。發票類型是 select(個人/公司/捐贈),其餘欄位用 hidden 與 required 的 schema rule 做條件顯示,規則要包含 required 與 type object 避免欄位在沒有值時全部展開,不要寫任何 JavaScript。

驗收時盯這幾點:

  1. **有沒有寫 JS,**看到 addEventListener('change') 就是走錯路。
  2. 規則裡有沒有 required,沒有的話條件全部形同虛設。
  3. document object 的路徑對不對order 位置的欄位在 checkout.additional_fields
  4. type 是不是只用了 text/select/checkboxnumberemail 會安靜地不出現。
  5. 驗證有沒有在後端,只有 pattern 屬性等於沒有驗。

欄位到這裡就完整了,下一篇我們繼續討論物流串接,也就是自訂 Shipping Method 與超商取貨門市電子地圖。

文章目錄:https://oberonlai.blog/category/2026-ithome/


Hi, 我是 Oberon Lai,十多年前我從一個不懂程式的平面設計師,一頭栽進 WordPress 的世界。從佈景主題到外掛開發,從接案到自研產品,這段旅程讓我深刻理解:好的技術不只是寫出能跑的程式碼,而是真正解決人的問題。

我積極投入參與社群,公開演講紀錄如下:

我專精 WordPress 開發,從企業形象網站的設計與開發、佈景主題客製化,到既有網站的改版升級,提供完整的 WordPress 建置服務。開發面涵蓋外掛開發與維護、區塊編輯器(Gutenberg)客製區塊、ACF 與 Custom Post Type 的資料架構設計,以及 REST API 整合與 Multisite 多站架構建置。同時也協助網站效能改善與 SEO 調校、安全性檢測與強化,並導入自動化部署與版本控制流程,搭配長期的技術顧問與維運支援,讓網站上線後也能穩定運作。

亦提供 WooCommerce 商店的建置與設定,並串接綠界、LINE Pay、藍新等台灣主流金流。可依需求進行結帳頁面客製化、訂單狀態自動化流程設計,以及商品管理與庫存系統的客製開發;也支援 WooCommerce Subscription 定期定額、REST API 應用開發、報表與數據匯出等進階需求。此外,透過購物流程 UX 改善、電商網站效能調校與 HPOS 高效能訂單儲存相容開發,全面提升營運效率,並提供電商營運技術顧問服務。

AI 浪潮席捲而來,我選擇擁抱而非恐懼,我把 AI 融入開發工作流以及客戶的產品中,也持續累積「AI 看不見的部分」:真實踩坑經驗、最新漏洞情報那些只有第一線工程師才看得見的細節,如果你有任何 WordPress 的客製化需求或是 AI 開發相關的問題非常歡迎加入 LINE 官方帳號與我聯繫:

https://page.line.me/vrf7844t?oat_content=url&openQrModal=true

如果想要獲取 AI 開發實戰經驗也能訂閱我的電子報,每週五上午準時出刊:

https://oberonlai.blog/wordpress-newsletter/


上一篇
讓金流支援 WooCommerce Checkout Block
系列文
從一句話到一個網站:用 Vibe Coding 開發 WordPress Block Theme 的 30 天38
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言