- Odoo JSON-RPC client (no Composer, uses wp_remote_post) - Admin settings page under WooCommerce with connection test - Customer linking: search Odoo partners from WP user profile - My Account: Odoo Invoices tab with PDF proxy download - My Account: Book a Meeting tab (slot calculator + calendar.event) - WC order → Odoo sale.order auto-sync on processing status - Products matched by SKU; partner auto-created from billing info - Uninstall cleanup (options, user meta, order meta, DB table) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
260 lines
8.8 KiB
PHP
260 lines
8.8 KiB
PHP
<?php
|
||
/**
|
||
* Odoo JSON-RPC API client.
|
||
*
|
||
* Communicates with Odoo 19 via the /jsonrpc endpoint using the
|
||
* "common" (authenticate) and "object" (execute_kw) services.
|
||
* Uses WordPress's built-in HTTP API – no Composer required.
|
||
*/
|
||
|
||
defined( 'ABSPATH' ) || exit;
|
||
|
||
class WooDoo_API {
|
||
|
||
private string $url;
|
||
private string $db;
|
||
private string $username;
|
||
private string $api_key;
|
||
private ?int $uid = null;
|
||
|
||
/** Cache results for one minute to reduce round-trips */
|
||
private const CACHE_TTL = 60;
|
||
|
||
public function __construct(
|
||
string $url,
|
||
string $db,
|
||
string $username,
|
||
string $api_key
|
||
) {
|
||
$this->url = $url;
|
||
$this->db = $db;
|
||
$this->username = $username;
|
||
$this->api_key = $api_key;
|
||
}
|
||
|
||
// ── Low-level JSON-RPC ────────────────────────────────────────────────
|
||
|
||
/**
|
||
* POST to /jsonrpc.
|
||
*
|
||
* @param string $service "common" | "object"
|
||
* @param string $method e.g. "authenticate" | "execute_kw"
|
||
* @param array $args positional arguments
|
||
* @return mixed decoded result or WP_Error
|
||
*/
|
||
public function jsonrpc( string $service, string $method, array $args ): mixed {
|
||
$body = wp_json_encode( [
|
||
'jsonrpc' => '2.0',
|
||
'method' => 'call',
|
||
'id' => wp_rand( 1, 999999999 ),
|
||
'params' => [
|
||
'service' => $service,
|
||
'method' => $method,
|
||
'args' => $args,
|
||
],
|
||
] );
|
||
|
||
$response = wp_remote_post(
|
||
$this->url . '/jsonrpc',
|
||
[
|
||
'headers' => [ 'Content-Type' => 'application/json' ],
|
||
'body' => $body,
|
||
'timeout' => 30,
|
||
'sslverify' => apply_filters( 'woodoo_ssl_verify', true ),
|
||
]
|
||
);
|
||
|
||
if ( is_wp_error( $response ) ) {
|
||
return $response;
|
||
}
|
||
|
||
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
||
|
||
if ( isset( $data['error'] ) ) {
|
||
$msg = $data['error']['data']['message']
|
||
?? $data['error']['message']
|
||
?? 'Unknown Odoo error';
|
||
return new WP_Error( 'odoo_error', $msg, $data['error'] );
|
||
}
|
||
|
||
return $data['result'] ?? null;
|
||
}
|
||
|
||
// ── Authentication ────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Authenticate and cache the uid for this request lifecycle.
|
||
*/
|
||
public function authenticate(): ?int {
|
||
if ( $this->uid ) return $this->uid;
|
||
|
||
$result = $this->jsonrpc( 'common', 'authenticate', [
|
||
$this->db,
|
||
$this->username,
|
||
$this->api_key,
|
||
[],
|
||
] );
|
||
|
||
if ( is_wp_error( $result ) || ! is_int( $result ) || $result <= 0 ) {
|
||
return null;
|
||
}
|
||
|
||
$this->uid = $result;
|
||
return $this->uid;
|
||
}
|
||
|
||
// ── ORM execute_kw wrapper ────────────────────────────────────────────
|
||
|
||
/**
|
||
* Call any ORM method via execute_kw.
|
||
*
|
||
* @param string $model e.g. 'res.partner'
|
||
* @param string $method e.g. 'search_read'
|
||
* @param array $args positional args (list of lists usually)
|
||
* @param array $kwargs keyword args (fields, limit, offset, etc.)
|
||
*/
|
||
public function execute_kw( string $model, string $method, array $args = [], array $kwargs = [] ): mixed {
|
||
$uid = $this->authenticate();
|
||
if ( ! $uid ) {
|
||
return new WP_Error( 'woodoo_auth', 'Could not authenticate with Odoo.' );
|
||
}
|
||
|
||
return $this->jsonrpc( 'object', 'execute_kw', [
|
||
$this->db,
|
||
$uid,
|
||
$this->api_key,
|
||
$model,
|
||
$method,
|
||
$args,
|
||
$kwargs,
|
||
] );
|
||
}
|
||
|
||
// ── Convenience helpers ───────────────────────────────────────────────
|
||
|
||
public function search_read(
|
||
string $model,
|
||
array $domain = [],
|
||
array $fields = [],
|
||
int $limit = 0,
|
||
int $offset = 0,
|
||
string $order = ''
|
||
): array {
|
||
$kwargs = [ 'fields' => $fields ];
|
||
if ( $limit > 0 ) $kwargs['limit'] = $limit;
|
||
if ( $offset > 0 ) $kwargs['offset'] = $offset;
|
||
if ( $order !== '' ) $kwargs['order'] = $order;
|
||
|
||
$result = $this->execute_kw( $model, 'search_read', [ $domain ], $kwargs );
|
||
return is_array( $result ) ? $result : [];
|
||
}
|
||
|
||
public function search(
|
||
string $model,
|
||
array $domain = [],
|
||
int $limit = 0,
|
||
int $offset = 0,
|
||
string $order = ''
|
||
): array {
|
||
$kwargs = [];
|
||
if ( $limit > 0 ) $kwargs['limit'] = $limit;
|
||
if ( $offset > 0 ) $kwargs['offset'] = $offset;
|
||
if ( $order !== '' ) $kwargs['order'] = $order;
|
||
|
||
$result = $this->execute_kw( $model, 'search', [ $domain ], $kwargs );
|
||
return is_array( $result ) ? $result : [];
|
||
}
|
||
|
||
public function read( string $model, array $ids, array $fields = [] ): array {
|
||
$kwargs = $fields ? [ 'fields' => $fields ] : [];
|
||
$result = $this->execute_kw( $model, 'read', [ $ids ], $kwargs );
|
||
return is_array( $result ) ? $result : [];
|
||
}
|
||
|
||
public function create( string $model, array $values ): ?int {
|
||
$result = $this->execute_kw( $model, 'create', [ $values ] );
|
||
return is_int( $result ) ? $result : null;
|
||
}
|
||
|
||
public function write( string $model, array $ids, array $values ): bool {
|
||
$result = $this->execute_kw( $model, 'write', [ $ids, $values ] );
|
||
return $result === true;
|
||
}
|
||
|
||
public function search_count( string $model, array $domain = [] ): int {
|
||
$result = $this->execute_kw( $model, 'search_count', [ $domain ] );
|
||
return is_int( $result ) ? $result : 0;
|
||
}
|
||
|
||
public function unlink( string $model, array $ids ): bool {
|
||
$result = $this->execute_kw( $model, 'unlink', [ $ids ] );
|
||
return $result === true;
|
||
}
|
||
|
||
// ── Partner helpers ───────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Find a partner by email or create one.
|
||
* Returns the Odoo partner ID.
|
||
*/
|
||
public function find_or_create_partner( string $email, string $name, array $extra = [] ): ?int {
|
||
$found = $this->search( 'res.partner', [ [ 'email', '=', $email ] ], 1 );
|
||
if ( ! empty( $found ) ) {
|
||
return (int) $found[0];
|
||
}
|
||
|
||
return $this->create( 'res.partner', array_merge( [
|
||
'name' => $name,
|
||
'email' => $email,
|
||
], $extra ) );
|
||
}
|
||
|
||
// ── Product helpers ───────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Find a product.product by its SKU (default_code).
|
||
* Returns product ID or null if not found.
|
||
*/
|
||
public function find_product_by_sku( string $sku ): ?int {
|
||
if ( empty( $sku ) ) return null;
|
||
$found = $this->search( 'product.product', [ [ 'default_code', '=', $sku ] ], 1 );
|
||
return ! empty( $found ) ? (int) $found[0] : null;
|
||
}
|
||
|
||
// ── Diagnostics ───────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Test connectivity and credentials.
|
||
* Returns ['success' => bool, 'message' => string, 'version' => string|null]
|
||
*/
|
||
public function test_connection(): array {
|
||
// Version check (no auth needed)
|
||
$version = $this->jsonrpc( 'common', 'version', [] );
|
||
if ( is_wp_error( $version ) ) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'Cannot reach Odoo: ' . $version->get_error_message(),
|
||
'version' => null,
|
||
];
|
||
}
|
||
|
||
$ver_str = $version['server_version'] ?? 'unknown';
|
||
|
||
// Try authenticate
|
||
$uid = $this->authenticate();
|
||
if ( ! $uid ) {
|
||
return [
|
||
'success' => false,
|
||
'message' => "Reached Odoo {$ver_str} but authentication failed. Check username / API key.",
|
||
'version' => $ver_str,
|
||
];
|
||
}
|
||
|
||
return [
|
||
'success' => true,
|
||
'message' => "Connected to Odoo {$ver_str} as UID {$uid}.",
|
||
'version' => $ver_str,
|
||
];
|
||
}
|
||
}
|