GHTK API sandbox: tích hợp API Giao Hàng Tiết Kiệm bằng PHP trong WordPress

Chào anh em! Ở bài tích hợp Giao Hàng Tiết Kiệm vào WordPress mình mới đi lướt qua phần tự code: hàm lấy phí ship và một endpoint nhận webhook. Bài này dành cho anh em dev muốn tích hợp API Giao Hàng Tiết Kiệm (GHTK) tới nơi: đọc xong là có một client PHP trong WordPress đăng đơn, tra trạng thái, huỷ đơn, in nhãn được, chạy thử hết trên sandbox nên không đụng vào vận đơn thật.

Tích hợp GHTK API bằng PHP trong WordPress, chạy thử trên môi trường sandbox

Mọi endpoint, tham số và mã lỗi trong bài lấy từ tài liệu chính thức tại api.ghtk.vn. Mấy “bẫy” không có trong tài liệu thì mình rút ra từ plugin GHTK do mình viết, đang chạy cho khách từ 2018, có nói rõ chỗ nào là kinh nghiệm.

GHTK API sandbox: môi trường test trước khi chạy thật

GHTK có hai môi trường tách biệt. Code giống nhau, chỉ khác domain và token:

Để duy trì blog nên mình có làm aff cho 1 số bên hosting. Nhưng dù aff mình cũng chọn 1 số nhà cung cấp uy tín về chất lượng và support nên các bạn cứ yên tâm nhé.

Nếu có mua hosting mà có trong list dưới đây các bạn click vào link trước khi mua để ủng hộ mình nhé. Mình cảm ơn nhiều

  • Sandbox (staging) có API ở https://services-staging.ghtklab.com và trang khách hàng ở https://khachhang-staging.ghtklab.com.
  • Môi trường thật (production) có API ở https://services.giaohangtietkiem.vn và trang khách hàng ở https://khachhang.giaohangtietkiem.vn.
  • Sandbox dùng tài khoản và token riêng, đăng ký tài khoản ở trang khách hàng staging rồi tạo token ngay ở đó. Token thật gọi vào domain staging (hoặc ngược lại) đều bị từ chối.
  • Token tạo ở Thông tin shop → Cấu hình API → Tạo Token, lúc tạo thì đặt tên, ngày hết hạn và chọn quyền (tạo đơn, tính phí, lấy trạng thái…). Thiếu quyền nào thì GHTK từ chối các request cần quyền đó. Từng bước có ảnh ở bài cách lấy API token GHTK.
  • Mỗi request gửi kèm hai header, Token là API token và X-Client-Source là mã shop (dạng S kèm dãy số). Dữ liệu gửi đi dạng JSON hoặc form đều được, miễn khai đúng Content-Type.

Mình để cấu hình trong wp-config.php, đổi qua lại giữa sandbox và thật thì sửa hằng số sandbox kèm token của môi trường đó. Thông tin kho lấy hàng gom vào một mảng để lúc đăng đơn ghép thẳng vào payload:

define( 'DEVVN_GHTK_SANDBOX', true );
define( 'DEVVN_GHTK_TOKEN', 'token-sandbox-cua-ban' );
define( 'DEVVN_GHTK_SHOP_CODE', 'S123456' );
define(
	'DEVVN_GHTK_PICK',
	array(
		'pick_name'     => 'Tên người giao hàng cho shipper',
		'pick_tel'      => '0900000000',
		'pick_address'  => 'Số nhà, ngõ, đường',
		'pick_province' => 'Tên tỉnh/thành',
		'pick_ward'     => 'Tên phường/xã',
	)
);

Viết client gọi API GHTK bằng PHP

Không cần thư viện ngoài, HTTP API có sẵn của WordPress là đủ. Lớp dưới đặt trong một plugin riêng hoặc file mu-plugin (ví dụ wp-content/mu-plugins/devvn-ghtk.php). Nó lo ba việc chung cho mọi endpoint: chọn domain theo môi trường, gắn header, và đổi lỗi của GHTK thành WP_Error để phía gọi xử lý một kiểu.

/**
 * Client gọi Open API của GHTK (sandbox hoặc thật) bằng HTTP API của WordPress.
 */
class DevVN_GHTK_Client {

	const URL_PRODUCTION = 'https://services.giaohangtietkiem.vn';
	const URL_SANDBOX    = 'https://services-staging.ghtklab.com';
	const TIMEOUT        = 15;
	const NOTE_MAX       = 120;

	/**
	 * Gửi request và trả về JSON đã giải mã; lỗi của GHTK thành WP_Error (kèm dữ liệu gốc).
	 *
	 * @param string     $method GET hoặc POST.
	 * @param string     $path   Đường dẫn endpoint, ví dụ /services/shipment/order.
	 * @param array      $query  Tham số trên URL.
	 * @param array|null $body   Dữ liệu gửi dạng JSON.
	 * @return array|WP_Error
	 */
	public function request( $method, $path, $query = array(), $body = null ) {
		$response = $this->raw( $method, $path, $query, $body );
		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$data = json_decode( wp_remote_retrieve_body( $response ), true );
		if ( ! is_array( $data ) ) {
			return new WP_Error( 'ghtk_bad_response', 'GHTK trả về dữ liệu không đọc được' );
		}
		if ( empty( $data['success'] ) ) {
			return new WP_Error( 'ghtk_error', isset( $data['message'] ) ? $data['message'] : 'GHTK báo lỗi', $data );
		}

		return $data;
	}

	/**
	 * Gửi request thô tới GHTK; HTTP 403 (token sai/hết hạn/thiếu quyền) trả về WP_Error.
	 *
	 * @param string     $method GET hoặc POST.
	 * @param string     $path   Đường dẫn endpoint.
	 * @param array      $query  Tham số trên URL.
	 * @param array|null $body   Dữ liệu gửi dạng JSON.
	 * @return array|WP_Error Response của wp_remote_request().
	 */
	public function raw( $method, $path, $query = array(), $body = null ) {
		$base = DEVVN_GHTK_SANDBOX ? self::URL_SANDBOX : self::URL_PRODUCTION;
		$args = array(
			'method'  => $method,
			'timeout' => self::TIMEOUT,
			'headers' => array(
				'Token'           => DEVVN_GHTK_TOKEN,
				'X-Client-Source' => DEVVN_GHTK_SHOP_CODE,
				'Content-Type'    => 'application/json',
			),
		);
		if ( null !== $body ) {
			$args['body'] = wp_json_encode( $body );
		}

		$response = wp_remote_request( $base . $path . ( $query ? '?' . http_build_query( $query ) : '' ), $args );
		if ( ! is_wp_error( $response ) && 403 === wp_remote_retrieve_response_code( $response ) ) {
			return new WP_Error( 'ghtk_forbidden', 'Token sai, hết hạn, thiếu quyền hoặc nhầm môi trường' );
		}

		return $response;
	}

	/**
	 * Kiểm tra token và kết nối.
	 *
	 * @return array|WP_Error
	 */
	public function authenticated() {
		return $this->request( 'GET', '/services/authenticated' );
	}

	/**
	 * Đăng một đơn sang GHTK.
	 *
	 * @param array $payload Gồm products và order.
	 * @return array|WP_Error
	 */
	public function create_order( $payload ) {
		return $this->request( 'POST', '/services/shipment/order', array(), $payload );
	}

	/**
	 * Lấy trạng thái hiện tại của vận đơn.
	 *
	 * @param string $label Mã vận đơn GHTK hoặc order.id đã gửi lúc đăng.
	 * @return array|WP_Error
	 */
	public function status( $label ) {
		return $this->request( 'GET', '/services/shipment/v2/' . $label );
	}

	/**
	 * Huỷ vận đơn (chỉ được khi GHTK chưa lấy hàng).
	 *
	 * @param string $label Mã vận đơn GHTK.
	 * @return array|WP_Error
	 */
	public function cancel( $label ) {
		return $this->request( 'POST', '/services/shipment/cancel/' . $label );
	}

	/**
	 * Lấy nhãn dán gói hàng dạng PDF.
	 *
	 * @param string $label     Mã vận đơn GHTK.
	 * @param string $page_size A5 hoặc A6.
	 * @param string $original  portrait (dọc) hoặc landscape (ngang).
	 * @return string|WP_Error Nội dung file PDF.
	 */
	public function label( $label, $page_size = 'A6', $original = 'portrait' ) {
		$response = $this->raw( 'GET', '/services/label/' . $label, array( 'page_size' => $page_size, 'original' => $original ) );
		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$body  = wp_remote_retrieve_body( $response );
		$error = json_decode( $body, true );
		if ( is_array( $error ) && empty( $error['success'] ) ) {
			return new WP_Error( 'ghtk_label', isset( $error['message'] ) ? $error['message'] : 'Không lấy được nhãn' );
		}

		return $body;
	}
}

Kiểm tra token và kết nối trước tiên

Endpoint /services/authenticated chỉ để kiểm tra token, không tạo gì cả. Chạy bằng WP-CLI cho nhanh:

wp eval 'var_dump( ( new DevVN_GHTK_Client() )->authenticated() );'

Ra mảng có "success" => true là thông. Ra lỗi ghtk_forbidden thì GHTK đã trả HTTP 403 với body rỗng: token sai, hết hạn, thiếu quyền, hoặc anh em đang dùng token thật gọi vào sandbox. Thông báo lỗi của GHTK không nói rõ là trường hợp nào, nên kiểm lần lượt cả bốn.

Đăng đơn: payload và những trường hay sai

Endpoint là POST /services/shipment/order, body gồm hai phần: products (danh sách hàng) và order (người gửi, người nhận, tiền thu hộ). Hàm dưới dựng payload từ một đơn WooCommerce theo địa chỉ 2 cấp. Tên tỉnh và phường/xã của người nhận lấy từ plugin địa chỉ anh em đang dùng, nên mình nhận vào qua tham số $to:

/**
 * Số lần đăng của đơn, dùng làm hậu tố order.id (tăng sau mỗi lần huỷ vận đơn).
 *
 * @param WC_Order $order Đơn hàng.
 * @return int
 */
function devvn_ghtk_attempt( WC_Order $order ) {
	return max( 1, (int) $order->get_meta( '_devvn_ghtk_attempt' ) );
}

/**
 * Dựng payload đăng đơn GHTK từ một đơn WooCommerce (địa chỉ 2 cấp).
 *
 * @param WC_Order $order Đơn hàng.
 * @param array    $to    Địa chỉ nhận đã chuẩn hoá: province, ward, address, hamlet.
 * @return array
 */
function devvn_ghtk_order_payload( WC_Order $order, $to ) {
	$products = array();
	foreach ( $order->get_items() as $item ) {
		$product    = $item->get_product();
		$products[] = array(
			'name'     => $item->get_name(),
			'weight'   => $product ? (float) wc_get_weight( (float) $product->get_weight(), 'kg' ) : 0,
			'quantity' => $item->get_quantity(),
			'price'    => (int) $order->get_item_subtotal( $item, false, true ),
		);
	}

	return array(
		'products' => $products,
		'order'    => array_merge(
			DEVVN_GHTK_PICK,
			array(
				'id'          => $order->get_id() . '_' . devvn_ghtk_attempt( $order ),
				'name'        => $order->get_formatted_billing_full_name(),
				'tel'         => $order->get_billing_phone(),
				'address'     => $to['address'],
				'province'    => $to['province'],
				'ward'        => $to['ward'],
				'hamlet'      => empty( $to['hamlet'] ) ? 'Khác' : $to['hamlet'],
				'pick_money'  => 'cod' === $order->get_payment_method() ? (int) $order->get_total() : 0,
				'is_freeship' => 1,
				'value'       => (int) $order->get_subtotal(),
				'transport'   => 'road',
				'note'        => mb_substr( $order->get_customer_note(), 0, DevVN_GHTK_Client::NOTE_MAX ),
			)
		),
	);
}

Mấy trường trong payload trên mà mình thấy hay sai:

  • Hai API dùng hai đơn vị khối lượng khác nhau: API tính phí nhận weight bằng gram, còn API đăng đơn nhận products[].weight bằng kg (mặc định weight_option là kilogram). WooCommerce lại cho shop chọn g hoặc kg, nên luôn quy đổi bằng wc_get_weight().
  • pick_money đi cặp với is_freeship, vì is_freeship = 1 nghĩa là shop trả phí GHTK và shipper chỉ thu đúng pick_money. Bằng 0 thì shipper thu pick_money cộng thêm phí ship. Tổng đơn WooCommerce đã gồm phí ship khách trả lúc đặt hàng, nên mình để is_freeship = 1 và thu đúng tổng đơn; đơn chuyển khoản trước thì thu hộ bằng 0.
  • hamlet hoặc street là bắt buộc vì GHTK cần địa chỉ tới cấp thôn/ấp/tổ hoặc tên đường. Không có thì tài liệu bảo điền "Khác".
  • Với địa chỉ 2 cấp, province và ward bắt buộc, còn district không bắt buộc nên mình bỏ hẳn. Kinh nghiệm từ plugin: một số tên phường viết bằng số La Mã như “Phường III” phải đổi thành “Phường 3” thì GHTK mới nhận.
  • note tối đa 120 ký tự (áp dụng từ 24/02/2020), ghi chú của khách dài hơn thì cắt bớt.
  • value (giá trị khai giá) là bắt buộc vì đây là căn cứ để tính phí khai giá và bồi thường khi có sự cố.

Đăng đơn mà không sợ tạo trùng vận đơn

Có một quy định trong tài liệu GHTK quyết định cả cách thiết kế: một order.id đã đăng thành công thì không đẩy lại được nữa, kể cả khi vận đơn đó đã huỷ. Gửi trùng, GHTK trả lỗi ORDER_ID_EXIST kèm mã vận đơn cũ trong error.ghtk_label.

Quy định này hoá ra lại có lợi. Request đăng đơn bị timeout không có nghĩa là GHTK chưa tạo vận đơn. Nếu mỗi lần thử lại anh em sinh một id mới thì rất dễ ra hai vận đơn cho cùng một đơn hàng. Cách mình làm: id = mã đơn WooCommerce + hậu tố lần đăng, hậu tố chỉ tăng sau khi huỷ. Thử lại với cùng id thì GHTK trả ORDER_ID_EXIST, mình lấy luôn mã vận đơn cũ mà lưu:

/**
 * Đăng đơn WooCommerce sang GHTK và lưu mã vận đơn; gọi lại an toàn khi lần trước bị timeout.
 *
 * @param WC_Order $order Đơn hàng.
 * @param array    $to    Địa chỉ nhận đã chuẩn hoá.
 * @return string|WP_Error Mã vận đơn GHTK.
 */
function devvn_ghtk_post_order( WC_Order $order, $to ) {
	$result = ( new DevVN_GHTK_Client() )->create_order( devvn_ghtk_order_payload( $order, $to ) );

	if ( is_wp_error( $result ) ) {
		$data = $result->get_error_data();
		if ( empty( $data['error']['code'] ) || 'ORDER_ID_EXIST' !== $data['error']['code'] ) {
			return $result;
		}
		$label = $data['error']['ghtk_label'];
	} else {
		$label = $result['order']['label'];
	}

	$order->update_meta_data( '_devvn_ghtk_label', $label );
	$order->add_order_note( 'Đã đăng GHTK, mã vận đơn ' . $label );
	$order->save();

	return $label;
}

/**
 * Huỷ vận đơn GHTK của đơn và tăng hậu tố để lần đăng sau dùng order.id mới.
 *
 * @param WC_Order $order Đơn hàng.
 * @return true|WP_Error
 */
function devvn_ghtk_cancel_order( WC_Order $order ) {
	$label  = $order->get_meta( '_devvn_ghtk_label' );
	$result = ( new DevVN_GHTK_Client() )->cancel( $label );
	if ( is_wp_error( $result ) ) {
		return $result;
	}

	$order->update_meta_data( '_devvn_ghtk_attempt', devvn_ghtk_attempt( $order ) + 1 );
	$order->delete_meta_data( '_devvn_ghtk_label' );
	$order->add_order_note( 'Đã huỷ vận đơn GHTK ' . $label );
	$order->save();

	return true;
}

Đăng thành công, GHTK trả về order.label (mã vận đơn dạng S1.A1.2001297581), phí ship, phí khai giá, thời gian dự kiến lấy và giao hàng. Vì id có hậu tố như 1234_1, phía nhận webhook nhớ tách lấy phần mã đơn: absint( '1234_1' ) trả về 1234.

Tra trạng thái, huỷ đơn và in nhãn

  • Tra trạng thái bằng GET /services/shipment/v2/{mã}, endpoint này nhận cả mã vận đơn GHTK lẫn order.id đã gửi. Kết quả có status, status_text, phí ship, tiền thu hộ, ngày lấy và giao. Để ý weight ở đây tính bằng gram, còn trong webhook lại là kg.
  • Huỷ đơn bằng POST /services/shipment/cancel/{mã vận đơn}, chỉ huỷ được khi đơn đang ở trạng thái 1 (chưa tiếp nhận), 2 (đã tiếp nhận) hoặc 12 (đang lấy hàng). Shipper lấy hàng rồi thì API trả “Đơn đã lấy hàng, không thể hủy đơn.”, phải liên hệ GHTK.
  • In nhãn bằng GET /services/label/{mã vận đơn}, kết quả là file PDF. Tham số page_size là A5 hoặc A6 (mặc định A6), original là portrait hoặc landscape. Lỗi thì GHTK trả JSON thay vì PDF, client ở trên đã tách sẵn hai trường hợp.

PDF nhãn nên đi qua một handler trong trang quản trị có kiểm nonce và quyền, đừng để lộ ra ngoài vì nhãn có tên, số điện thoại, địa chỉ khách:

add_action( 'admin_post_devvn_ghtk_label', 'devvn_ghtk_print_label' );

/**
 * Xuất nhãn GHTK (PDF) của một đơn cho người có quyền quản lý đơn hàng.
 * Link gọi: wp_nonce_url( admin_url( 'admin-post.php?action=devvn_ghtk_label&order_id=' . $id ), 'devvn_ghtk_label_' . $id ).
 *
 * @return void
 */
function devvn_ghtk_print_label() {
	$order_id = isset( $_GET['order_id'] ) ? absint( $_GET['order_id'] ) : 0;
	check_admin_referer( 'devvn_ghtk_label_' . $order_id );
	if ( ! current_user_can( 'edit_shop_orders' ) ) {
		wp_die( 'Bạn không có quyền in nhãn.' );
	}

	$order = wc_get_order( $order_id );
	$pdf   = $order ? ( new DevVN_GHTK_Client() )->label( $order->get_meta( '_devvn_ghtk_label' ) ) : new WP_Error( 'ghtk_no_order', 'Không tìm thấy đơn hàng.' );
	if ( is_wp_error( $pdf ) ) {
		wp_die( esc_html( $pdf->get_error_message() ) );
	}

	nocache_headers();
	header( 'Content-Type: application/pdf' );
	header( 'Content-Disposition: inline; filename="ghtk-' . $order_id . '.pdf"' );
	echo $pdf; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- dữ liệu PDF nhị phân.
	exit;
}

Webhook: đừng chốt đơn theo mọi tin báo

Đoạn code nhận webhook (REST route, kiểm mã bí mật, trả HTTP 200) mình đã viết ở bài tổng quan. Ở đây chỉ thêm ba điều có ghi trong tài liệu GHTK nhưng dễ bỏ sót:

  • Endpoint không trả 200 (lỗi PHP, server bận, tường lửa chặn) thì GHTK chỉ gửi lại đúng một lần nữa rồi thôi. Nên chạy thêm một tác vụ định kỳ (Action Scheduler hoặc WP-Cron) gọi API tra trạng thái cho các đơn chưa xong, phòng khi cả hai lần webhook đều không tới.
  • GHTK ghi rõ các mã 123, 127, 128, 45, 49, 410 chỉ là “shipper báo”, tức là thông báo, không phải trạng thái chính thức, và có thể bị sửa lại (shipper báo 123 đã lấy hàng rồi sau đó lại báo 127 không lấy được). Muốn chốt đơn chắc chắn thì dựa vào mã 5 (đã giao hàng) và 6 (đã đối soát).
  • Ví dụ webhook trong tài liệu gửi dữ liệu dạng form (x-www-form-urlencoded), nhưng tuỳ cấu hình tài khoản có thể là JSON. Dùng WP_REST_Request::get_param() thì đọc được cả hai.

Lỗi thường gặp khi gọi API GHTK

  • HTTP 403 với body rỗng là do token sai, hết hạn, thiếu quyền, hoặc token sandbox gọi vào domain thật (và ngược lại).
  • Lỗi ORDER_ID_EXIST nghĩa là order.id đã từng đăng, cứ lấy error.ghtk_label mà dùng; muốn đăng lại sau khi huỷ thì đổi hậu tố id.
  • Gặp thông báo “Đơn đã lấy hàng, không thể hủy đơn.” nghĩa là đơn đã qua trạng thái 1, 2, 12, lúc này phải liên hệ GHTK để xử lý.
  • fee.delivery là false nghĩa là GHTK chưa giao tới địa chỉ đó, nên ẩn phương thức GHTK cho địa chỉ này thay vì hiện phí.
  • GHTK báo địa chỉ không hợp lệ khi tên tỉnh, phường/xã lệch dữ liệu của GHTK, ví dụ còn dùng tên trước sáp nhập, gửi 3 cấp lẫn 2 cấp, hoặc có số La Mã trong tên phường.

Khi nào không nên tự code

Toàn bộ code trên mới là phần lõi gọi API. Đưa vào bán hàng thật còn thiếu: phương thức vận chuyển ở trang thanh toán, ô chọn tỉnh và phường/xã đúng dữ liệu GHTK, hộp thoại đăng đơn cho nhân viên, bảng ánh xạ trạng thái, email báo mã vận đơn, nhiều kho lấy hàng. Mỗi lần GHTK đổi API hay dữ liệu địa chỉ đổi lại phải sửa theo.

Nếu không cần luồng đơn riêng thì plugin kết nối GHTK với WooCommerce của mình đã làm sẵn những phần đó. Vẫn muốn can thiệp sâu thì plugin có filter để sửa dữ liệu trước khi gửi, như devvn_ghtk_data_before_create_order_to_ghtk cho payload đăng đơn và devvn_ghtk_data_before_shipment_fee cho request tính phí. Danh sách đầy đủ ở trang hook và filter của plugin GHTK.

Câu hỏi thường gặp

GHTK có môi trường sandbox để test API không?

Có. API sandbox ở services-staging.ghtklab.com, tài khoản và token tạo ở khachhang-staging.ghtklab.com. Đơn đăng trên sandbox không được xử lý thật nên cứ thử thoải mái.

Dùng token thật gọi vào sandbox được không?

Không. Hai môi trường dùng hai bộ tài khoản và token riêng. Chuyển từ sandbox sang chạy thật thì đổi cả domain lẫn token, với cách cấu hình ở trên là đổi DEVVN_GHTK_SANDBOX và DEVVN_GHTK_TOKEN.

Gửi dữ liệu dạng JSON hay form?

Cả hai đều được. GHTK nhận application/json và application/x-www-form-urlencoded (mặc định là form). Payload đăng đơn có mảng lồng nhau nên gửi JSON gọn hơn, nhớ khai đúng header Content-Type.

Đăng đơn lỗi ORDER_ID_EXIST là sao?

Mã đơn anh em gửi trong order.id đã từng đăng thành công trên GHTK, kể cả khi vận đơn đó đã huỷ. Lỗi trả về kèm mã vận đơn cũ (ghtk_label). Muốn đăng lại sau khi huỷ thì gửi id mới, ví dụ thêm hậu tố lần đăng như phần trên.

Anh em làm theo mà vướng chỗ nào cứ để lại bình luận, nhớ che token trước khi dán log. Ai cần plugin làm sẵn thì xem bài so sánh tự code và dùng plugin trước khi quyết định. Chúc anh em đăng thử đơn trên sandbox là ra mã vận đơn ngay! 🚀

Bài viết trong series

Kết nối WooCommerce với Giao Hàng Tiết Kiệm

0/4đã đọc
  1. 1 Tích hợp Giao Hàng Tiết Kiệm vào WordPress, WooCommerce: tự code API hay dùng plugin? Đang đọc Đã đọc
  2. 2 GHTK API sandbox: tích hợp API Giao Hàng Tiết Kiệm bằng PHP trong WordPress Đang đọc Đã đọc
  3. 3 Webhook GHTK: đăng ký, xử lý và sửa lỗi GHTK không cập nhật đơn hàng Đang đọc Đã đọc
  4. 4 Cách lấy API token GHTK theo giao diện mới: tạo token, chọn quyền, ngày hết hạn Đang đọc Đã đọc
Sản phẩm nổi bật của Toản
x