├── .env.example ├── .gitignore ├── README.md ├── composer.json ├── config └── odoo-api.php └── src ├── HasModelDataTrait.php ├── Model.php ├── ModelInterface.php ├── OdooClient.php ├── OdooFacade.php ├── OdooService.php └── OdooServiceProvider.php /.env.example: -------------------------------------------------------------------------------- 1 | # Odoo Production credentials 2 | ODOO_API_URL=https://example.com 3 | ODOO_API_PORT=443 4 | ODOO_API_DATABASE="database-name" 5 | ODOO_API_USER="user-name" 6 | ODOO_API_PASSWORD="password" 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | .idea 3 | .composer.lock 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Consilience Laravel Odoo XML RPC Client 2 | 3 | Tested against Laravel 5.7 and Odoo 7 _(OpenERP 7)_. 4 | 5 | [![Latest Stable Version](https://poser.pugx.org/consilience/laravel-odoo-api-client/v/stable)](https://packagist.org/packages/consilience/laravel-odoo-api-client) 6 | [![Total Downloads](https://poser.pugx.org/consilience/laravel-odoo-api-client/downloads)](https://packagist.org/packages/consilience/laravel-odoo-api-client) 7 | [![Latest Unstable Version](https://poser.pugx.org/consilience/laravel-odoo-api-client/v/unstable)](https://packagist.org/packages/consilience/laravel-odoo-api-client) 8 | [![License](https://poser.pugx.org/consilience/laravel-odoo-api-client/license)](https://packagist.org/packages/consilience/laravel-odoo-api-client) 9 | 10 | # Introduction 11 | 12 | The aim of this package is to provide easy access to the 13 | OpenERP/Odoo XML-RPC API from within Laravel. 14 | Just set up some config, get a client from the `OdooApi` 15 | facade, and throw it some data. 16 | 17 | Ideally this would be in two parts: an API package and the 18 | Laravel wrapper. 19 | That can still be done later, but for now this meets our 20 | requirements and some helpers provided by laravel make things 21 | run a lot smoother (collections, array/object dot-notation access). 22 | 23 | # Installation 24 | 25 | Through composer: 26 | 27 | composer require consilience/laravel-odoo-api-client 28 | 29 | Note: pending release to packagist, the following entry in `composer.json` 30 | is needed to locate this package: 31 | 32 | ```json 33 | "repositories": [ 34 | { 35 | "type": "vcs", 36 | "url": "https://github.com/consilience/laravel-odoo-api-client.git" 37 | } 38 | ... 39 | ] 40 | ... 41 | ``` 42 | 43 | # Publishing the Configuration 44 | 45 | Publish `config\odoo-api.php` using the Laravel artisan command: 46 | 47 | artisan vendor:publish --provider="Consilience\OdooApi\OdooServiceProvider" 48 | 49 | A sample set of entries for `.env` can be found in `.env.example`. 50 | 51 | You can add multiple sets of configuration to `config\odoo-api.php` and 52 | use them all at once in your application. 53 | The configuration set name is passed in to `OdooApi::getClient('config-name')`. 54 | 55 | # Example 56 | 57 | A very simple example: 58 | 59 | ```php 60 | 61 | // This facade is auto-discovered for Laravel 5.6+ 62 | 63 | use OdooApi; 64 | 65 | // The default config. 66 | // getClient() will take other configuration names. 67 | 68 | $client = OdooApi::getClient(); 69 | 70 | // Note the criteria is a nested list of scalar values. 71 | // The datatypes will converted to the appropriate objects internally. 72 | // You can mix scalars and objects here to force the datatype, for example 73 | // ['name', $client->stringValue('ilike'), 'mich'] 74 | 75 | $criteria = [ 76 | ['name', 'ilike', 'mich'], 77 | ]; 78 | 79 | // First 10 matching IDs 80 | 81 | $client->search('res.partner', $criteria, 0, 10, 'id desc')->value()->me['array'] 82 | 83 | // Total count for the criteria. 84 | 85 | $client->searchCount('res.partner', $criteria); 86 | 87 | // Read the complete details of two specific partners. 88 | 89 | $client->read('res.partner', [17858, 17852])->value()->me['array'] 90 | ``` 91 | 92 | If you have specific requirements for the XML-RPC client, such as an SSL 93 | certificate to add, then you can get the client instance using: 94 | 95 | $xmlRpcClient = $client->getXmlRpcClient($type); 96 | 97 | where `$type` will typically be 'db', 'common' or 'object'. 98 | 99 | You have the ability to construct your own messages from scratch like this, 100 | and there are helper methods in the `$client` to convert native PHP data types 101 | to and from XML RPC value objects. 102 | However, you should be able to leave all that conversion to be handled in the 103 | background by the client - just give it array/string/int/etc. data and get 104 | models and arrays back. 105 | 106 | # Search Criteria 107 | 108 | The search criteria is an array of search terms and logic operators, 109 | expressed in Polish Notation. 110 | 111 | The logic operators, for comparing search terms, are: 112 | 113 | * `&` - logical AND 114 | * `|` - logical OR 115 | * `!` - logical NOT 116 | 117 | Each search term is a tuple of the form: 118 | 119 | [field_name, operator, value] 120 | 121 | The search term operators are: 122 | 123 | * = 124 | * != 125 | * \> 126 | * \>= 127 | * < 128 | * <= 129 | * like 130 | * ilike 131 | * in 132 | * not in 133 | * child_of - records who are children or grand-children of a given record, 134 | * parent_left 135 | * parent_right 136 | 137 | Example: search for a record where the name is like 'Fred%' or 'Jane%' 138 | and the partner ID is 1 or 2, would look like this: 139 | 140 | ```php 141 | [ 142 | '&', 143 | '|', 144 | ['name', 'like', 'Fred%'], 145 | ['name', 'like', 'Jane%'], 146 | ['partner_id', 'in', [1, 2]], 147 | ] 148 | ``` 149 | 150 | The Polish Notation works inner-most to outer-most. 151 | The first `&` operator takes the next two terms and 'AND's them. 152 | The first of the two terms is a `|` operator. 153 | The `|` operator then takes the next two terms and 'OR`s them, 154 | making a single condition as a result, which is fed to the 'AND'. 155 | The final term is fed to the 'AND' condition. 156 | The result is equivalent to: 157 | 158 | ```sql 159 | (name like 'Fred%' or name like 'Jane%') and partner_id in (1, 2) 160 | ``` 161 | 162 | # Query methods 163 | 164 | The following methods are supported and will return a collection: 165 | 166 | * search() - collection of integers 167 | * searchRead() - collection of models 168 | * read() - collection of models 169 | * getResourceIds - collection of integers 170 | * fieldsGet() - collection of arrays 171 | 172 | The following helper functions return a native PHP type instead: 173 | 174 | * searchCount - integer 175 | * getResourceId - integer 176 | * unlink - boolean 177 | * create - integer 178 | * write - boolean 179 | 180 | All `read()` and `searchRead()` methods will return a collection of models. 181 | The default model will be `Consilience\OdooApi\Model`, but other models can be specified. 182 | The `odoo-api.php` config provides an array setting to map OpenERP model names 183 | to model class names for instantiation. Further mappings can be added to the client 184 | using `$client->addMapping('odoo.model.name', \FQDN\Class\name::class)`. 185 | 186 | Note that `searchRead` will emulate the server's `search_read` for 187 | Odoo versions less than 8.0 (OpenERP) but use the native `search_read` 188 | for Odoo 8.0 upwards. 189 | 190 | # Read Options 191 | 192 | The `read()` method takes an options array that varies significantly 193 | between OpenERP/Odoo versions. 194 | This package does not attempt to deal with that at this time. 195 | 196 | For example, to restrict the read to named attributes, the following 197 | formats are used: 198 | 199 | * OpenERP 7: $client->read('account.invoice', [123], ['type', 'partner_id']); 200 | * OpenERP 8: $client->read('account.invoice', [123], ['fields' => ['type', 'partner_id']]); 201 | * OpenERP 10: $client->read('account.invoice', [123], ['attributes' => ['type', 'partner_id']]); 202 | 203 | This makes finding help on the API difficult, since many articles 204 | fail to make the OpenERP/Odoo version number clear. 205 | 206 | # Setting Relationships 207 | 208 | There are helpers to create the relationships data. 209 | Just a simple example, replacing all invoices belonging to a 210 | partner witn a new set of invoices: 211 | 212 | ```php 213 | $invoiceIds = ... // array or collection of resource IDs for the invoices to link 214 | 215 | $response = $client->write( 216 | 'res.partner', 217 | $partnerResourceId, 218 | [ 219 | 'invoice_ids' => $client->relationReplaceAllLinks($invoiceIds), 220 | 221 | // other optional fields and relations can be set here as nornmal 222 | ] 223 | ); 224 | ``` 225 | 226 | The general way to set a relationship is to set the relation (`invoice_ids` in this 227 | case) to a data structure which contains a list of IDs and instructions on 228 | what to do with those IDs. 229 | 230 | The `relationReplaceAllLinks()` here generates the data structure to instruct Odoo 231 | to replace all links between the `res.partner` and any invoices they have, with 232 | the new list of `$invoiceIds` (an array). 233 | You can construct those data structures yourself, or use the following helpers: 234 | 235 | ```php 236 | // Relate a resource. 237 | $client->relationCreate(array $resourceIds) 238 | 239 | // Update a related resource. 240 | // e.g. change the product on an invoice line for an invoice 241 | relationUpdate(int $resourceId, array $values) 242 | 243 | // Delete a related resource completely. 244 | // e.g. delete an invoice line on an invoice 245 | relationDelete(int $resourceId) 246 | 247 | // Remove the relation to a related resource, but leave the resource intact. 248 | // e.g. remove an invoice from a contact so it can be adde to a new contact 249 | relationRemoveLink(int $resourceId) 250 | 251 | // Add a resource to a relation, leaving existing relations intact. 252 | // e.g. add an additional line to an invoice. 253 | relationAddLink(int $resourceId) 254 | 255 | // Remove all relations to a resource type. 256 | // e.g. remove all invoices from a contact, before the contatc can is deleted. 257 | relationRemoveAllLinks() 258 | 259 | // Replace all relations with a new set of relations. 260 | // e.g. remove all invoices from contact, and give them a new bunch of invoices 261 | // to be responsible for. 262 | relationReplaceAllLinks(iterable $resourceIds) 263 | ``` 264 | 265 | # Non-CRUD Requests 266 | 267 | There are helper functions to provide `read`, `write`, `unlink`, `search` functionality, 268 | but you also have access to other API methods at a lower level. 269 | For example, a note can be added to a sales invoice using the `message_post` function 270 | for a sales order. 271 | The example below shows how. 272 | 273 | ```php 274 | use OdooApi; 275 | 276 | $client = OdooApi::getClient(); 277 | 278 | // Resource and action, the remote RPC function. 279 | // Note that the message_post() function for each resource type is 280 | // different, i.e. this is not something that can be genereralised 281 | // in the API. 282 | // This starts to build the request message and addes the first 283 | // few positional parameters and authentication details. 284 | 285 | $msg = $client->getBaseObjectRequest('sale.order', 'message_post'); 286 | 287 | // Further positional parameters. 288 | // This is for an Odoo 7.0 installation. Other versions may differ. 289 | 290 | $msg->addParam($client->nativeToValue([$orderId])); // Resource(s) ID 291 | $msg->addParam($client->nativeToValue($text_message)); // Body 292 | $msg->addParam($client->nativeToValue(false)); // Subject 293 | $msg->addParam($client->nativeToValue('comment')); // Subtype 294 | $msg->addParam($client->nativeToValue(false)); // Partner IDs to send a copy to 295 | 296 | // Send the message. 297 | 298 | $response = $client->getXmlRpcClient('object')->send($msg); 299 | 300 | // If you want to inspect the result, then this will give you 301 | // what the Odoo message_post() function returns. 302 | 303 | $result = $client->valueToNative($response->value()); 304 | ``` 305 | 306 | # Load Function 307 | 308 | Odoo offers a loader API that handles resource loading easily. 309 | This package offers the `load()` and `loadOne()` methods to 310 | access that API. 311 | 312 | The loader uses `id` as the external ID. 313 | It will find the resource if it already exists and update it, 314 | otherwise it will create the resource if it does not exist. 315 | 316 | Each resource in the list can be specified with different fields, 317 | but all must be for the same resource model. 318 | 319 | ```php 320 | // Load one or more partners. 321 | 322 | $loadResult = $client->load('res.partner', [ 323 | [ 324 | "name" => "JJ Test", 325 | "active" => "TRUE", 326 | "type" => "developer", 327 | "id" => "external.partner_12345", 328 | ], 329 | // Further records for this model... 330 | ]); 331 | ``` 332 | 333 | The response will be an array with two elements, `ids` and `messages`, 334 | both collections. 335 | 336 | The `ids` collection will contain the *internal* IDs of any resources updated 337 | or created. 338 | The `messages` collection will contain any validation errors for failed 339 | updates or resource creation. 340 | There may be multiple messages for a single failed record. 341 | 342 | ```php 343 | // Example response with no errors and two resources updated or created. 344 | 345 | array:2 [ 346 | "ids" => Collection { 347 | #items: array:2 [ 348 | 0 => 7252 349 | 1 => 7251 350 | ] 351 | } 352 | "messages" => Collection { 353 | #items: [] 354 | } 355 | ] 356 | 357 | // Example with oen validation error. 358 | // Note no records are loaded at all if any record fails validation. 359 | 360 | array:2 [ 361 | "ids" => Collection { 362 | #items: [] 363 | } 364 | "messages" => Collection { 365 | #items: array:1 [ 366 | 0 => array:5 [ 367 | "field" => "year" 368 | "rows" => array:2 [ 369 | "to" => 1 370 | "from" => 1 371 | ] 372 | "record" => 1 373 | "message" => "'2019x' does not seem to be an integer for field 'My Year'" 374 | "type" => "error" 375 | ] 376 | ] 377 | } 378 | ] 379 | ``` 380 | 381 | Although the record keys can vary between records, 382 | the Odoo API does not support that internally. 383 | This package works around that by grouping the records with 384 | identical keys and loading them in groups. 385 | This means that a validation error in one group will not 386 | prevent records loading from another group, so the result 387 | can be a mix of failed and loaded records. 388 | 389 | An exception will be thrown on unrecoverable errors in Odoo, 390 | such as a database integrity constraint violation. 391 | 392 | The `loadOne()` method works in a similar way, 393 | but accepts just one record to load. 394 | It will return an `id` element with the integer `internal ID` 395 | or `null` if the record failed to load, along with a collection 396 | for the messages. 397 | 398 | # TODO 399 | 400 | * Conversion of `date` types have not been tested. 401 | Ideally we would support Carbon 2 for sending dates in and getting 402 | dates back out again. 403 | * Tests. It's always the tests that get left behind when time gets in 404 | the way. They need to run on a Laravel context, so helpers needed for 405 | that. 406 | * Would be nice to split this up into a non-laravel package and then 407 | add a separate laravel wrapper for it. But collections are just too 408 | nice, so this may not happen. 409 | * Helper methods for some of the Odoo version specific data structures. 410 | For example, specifying the list of fields to retrieve for a `read` 411 | has new structures introduced for versions 7, 8 and 10. 412 | The client class is also going to star getting a bit cumbersome at this 413 | point, so moving some of the XML-RPC specific stuff (message creation, data 414 | conversion) would be best moved to a separate connection class). 415 | * Positional parameter builder helper. 416 | * Some more explicit exceptions. 417 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "consilience/laravel-odoo-api-client", 3 | "description": "Laravel provider for the Odoo API", 4 | "authors": [ 5 | { 6 | "name": "Jason Judge", 7 | "email": "jason@consil.co.uk" 8 | } 9 | ], 10 | "license": "MIT", 11 | "require": { 12 | "php": ">= 7.0.0", 13 | "phpxmlrpc/phpxmlrpc": "~4.3" 14 | }, 15 | "autoload": { 16 | "psr-4": { 17 | "Consilience\\OdooApi\\": "src/" 18 | } 19 | }, 20 | "extra": { 21 | "laravel": { 22 | "providers": [ 23 | "Consilience\\OdooApi\\OdooServiceProvider" 24 | ], 25 | "aliases": { 26 | "OdooApi": "Consilience\\OdooApi\\OdooFacade" 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /config/odoo-api.php: -------------------------------------------------------------------------------- 1 | 'default', 7 | 8 | // The connection configuration sets. 9 | 10 | 'connections' => [ 11 | 'default' => [ 12 | 'url' => env('ODOO_API_URL'), 13 | 'port' => env('ODOO_API_PORT', '443'), 14 | 'database' => env('ODOO_API_DATABASE'), 15 | 'username' => env('ODOO_API_USER'), 16 | 'password' => env('ODOO_API_PASSWORD'), 17 | 18 | 'model_map' => [ 19 | ], 20 | ], 21 | ], 22 | 23 | // Map OpenERP model names to local model classes. 24 | 25 | 'model_map' => [ 26 | //'account.invoice' => Foo\Bar\Invoice::class, 27 | ], 28 | ]; 29 | -------------------------------------------------------------------------------- /src/HasModelDataTrait.php: -------------------------------------------------------------------------------- 1 | setData($data); 24 | } 25 | 26 | protected function setData($data) 27 | { 28 | $this->data = $data; 29 | } 30 | 31 | public function getData() 32 | { 33 | return $this->data; 34 | } 35 | 36 | /** 37 | * Get a data field using a "dot notation" path. 38 | * 39 | * @inherit 40 | */ 41 | public function get(string $key, $default = null) 42 | { 43 | // Since we are running under laravel, use laravel's helper. 44 | 45 | return data_get($this->data, $key, $default); 46 | } 47 | 48 | public function __get($name) 49 | { 50 | return $this->get($name); 51 | } 52 | 53 | public function jsonSerialize() 54 | { 55 | return $this->data; 56 | } 57 | 58 | /** 59 | * Supports ArrayAccess 60 | */ 61 | public function offsetExists($offset): bool 62 | { 63 | return $this->get($offset) !== null; 64 | } 65 | 66 | /** 67 | * Supports ArrayAccess 68 | */ 69 | public function offsetGet($offset) 70 | { 71 | return $this->get($offset); 72 | } 73 | 74 | /** 75 | * Supports ArrayAccess 76 | */ 77 | public function offsetSet($offset, $value): void 78 | { 79 | data_set($this->data, $offset, $value); 80 | } 81 | 82 | /** 83 | * Supports ArrayAccess 84 | */ 85 | public function offsetUnset($offset): void 86 | { 87 | $this->offsetSet($offset, null); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Model.php: -------------------------------------------------------------------------------- 1 | config = $config; 122 | 123 | $this->url = $config['url']; 124 | $this->database = $config['database']; 125 | $this->username = $config['username']; 126 | $this->password = $config['password']; 127 | } 128 | 129 | /** 130 | * Get an XML RRC client singleton of a particular type. 131 | * TODO: turn this into a factory for a connector that wraps 132 | * the XML RPC client, injected into OdooClient. 133 | * 134 | * @param string $type One of: 'common', 'object', 'db' 135 | * @return Client 136 | */ 137 | public function getXmlRpcClient(string $type) 138 | { 139 | $type = strtolower($type); 140 | 141 | if (array_key_exists($type, $this->xmlRpcClients)) { 142 | return $this->xmlRpcClients[$type]; 143 | } 144 | 145 | $endpoint = str_replace( 146 | ['{uri}', '{type}'], 147 | [$this->url, $type], 148 | $this->endpointTemplate 149 | ); 150 | 151 | $xmlRpcClient = new Client($endpoint); 152 | 153 | $this->xmlRpcClients[$type] = $xmlRpcClient; 154 | 155 | return $xmlRpcClient; 156 | } 157 | 158 | /** 159 | * Get the user ID, fetching from the Odoo server if necessary. 160 | */ 161 | public function getUserId() 162 | { 163 | if ($this->userId !== null) { 164 | return $this->userId; 165 | } 166 | 167 | // Fetch the user ID from the server. 168 | 169 | $xmlRpcClient = $this->getXmlRpcClient(static::API_TYPE_COMMON); 170 | 171 | // Build login parameters array. 172 | 173 | $params = [ 174 | $this->stringValue($this->database), 175 | $this->stringValue($this->username), 176 | $this->stringValue($this->password), 177 | ]; 178 | 179 | // Build a Request object. 180 | 181 | $msg = new Request('login', new Value($params, static::TYPE_ARRAY)); 182 | 183 | // Send the request. 184 | 185 | try { 186 | $this->response = $xmlRpcClient->send($msg); 187 | } catch (Exception $e) { 188 | // Some connection problem. 189 | 190 | throw new Exception(sprintf( 191 | 'Cannot connect to Odoo database "%s"', 192 | $this->config['database'] 193 | ), null, $e); 194 | } 195 | 196 | // Grab the User ID. 197 | 198 | $this->userId = $this->responseAsNative(); 199 | 200 | // Get the server version for capabilities. 201 | 202 | $version = $this->version(); 203 | 204 | $this->serverVersion = $version['server_version'] ?? ''; 205 | 206 | if ($this->userId > 0) { 207 | return $this->userId; 208 | } 209 | 210 | throw new Exception(sprintf( 211 | 'Cannot find Odoo user ID for username "%s"', 212 | $this->config['username'] 213 | )); 214 | } 215 | 216 | /** 217 | * 218 | */ 219 | public function value($data, $type) 220 | { 221 | return new Value($data, $type); 222 | } 223 | 224 | public function stringValue(string $data) 225 | { 226 | return $this->value($data, static::TYPE_STRING); 227 | } 228 | 229 | public function arrayValue(iterable $data) 230 | { 231 | return $this->value($data, static::TYPE_ARRAY); 232 | } 233 | 234 | public function intValue(int $data) 235 | { 236 | return $this->value($data, static::TYPE_INT); 237 | } 238 | 239 | public function structValue(array $data) 240 | { 241 | return $this->value($data, static::TYPE_STRUCT); 242 | } 243 | 244 | public function booleanValue(bool $data) 245 | { 246 | return $this->value($data, static::TYPE_BOOLEAN); 247 | } 248 | 249 | public function doubleValue(float $data) 250 | { 251 | return $this->value($data, static::TYPE_DOUBLE); 252 | } 253 | 254 | public function nullValue() 255 | { 256 | return $this->value(null, static::TYPE_NULL); 257 | } 258 | 259 | /** 260 | * Example: 261 | * OdooApi::getClient()->search('res.partner', $criteria, 0, 10) 262 | * 263 | * @param string $modelName example res.partner 264 | * @param array $criteria nested array of search criteria (Polish notation logic) 265 | * @param int $offset 266 | * @param int $limit 267 | * @param string $order comma-separated list of fields 268 | * @return mixed 269 | */ 270 | public function search( 271 | string $modelName, 272 | array $criteria = [], 273 | $offset = 0, 274 | $limit = self::DEFAULT_LIMIT, 275 | $order = '' 276 | ) { 277 | $msg = $this->getBaseObjectRequest($modelName, 'search'); 278 | 279 | $msg->addParam($this->nativeToValue($criteria)); 280 | 281 | $msg->addParam($this->intValue($offset)); 282 | $msg->addParam($this->intValue($limit)); 283 | $msg->addParam($this->stringValue($order)); 284 | 285 | $this->response = $this->getXmlRpcClient(static::API_TYPE_OBJECT)->send($msg); 286 | 287 | return collect($this->responseAsNative()); 288 | } 289 | 290 | /** 291 | * Example: 292 | * OdooApi::getClient()->searchCount('res.partner', $criteria) 293 | * 294 | * @return integer 295 | */ 296 | public function searchCount( 297 | string $modelName, 298 | array $criteria = [] 299 | ) { 300 | $msg = $this->getBaseObjectRequest($modelName, 'search_count'); 301 | 302 | $msg->addParam($this->nativeToValue($criteria)); 303 | 304 | $this->response = $this->getXmlRpcClient(static::API_TYPE_OBJECT)->send($msg); 305 | 306 | return $this->responseAsNative(); 307 | } 308 | 309 | /** 310 | * Example: 311 | * OdooApi::getClient()->search('res.partner', $criteria, 0, 10) 312 | */ 313 | public function searchRead( 314 | string $modelName, 315 | array $criteria = [], 316 | $offset = 0, 317 | $limit = self::DEFAULT_LIMIT, 318 | $order = '' 319 | ) { 320 | if (version_compare('8.0', $this->serverVersion) === 1) { 321 | // For less than Odoo 8.0, search_read is not supported. 322 | // However, we will emulate it. 323 | 324 | $ids = $this->search( 325 | $modelName, 326 | $criteria, 327 | $offset, 328 | $limit, 329 | $order 330 | ); 331 | 332 | return $this->read($modelName, $ids); 333 | } else { 334 | $msg = $this->getBaseObjectRequest($modelName, 'search_read'); 335 | 336 | $msg->addParam($this->nativeToValue($criteria)); 337 | 338 | $msg->addParam($this->intValue($offset)); 339 | $msg->addParam($this->intValue($limit)); 340 | $msg->addParam($this->stringValue($order)); 341 | 342 | $this->response = $this->getXmlRpcClient(static::API_TYPE_OBJECT)->send($msg); 343 | 344 | // FIXME: these need to be mapped onto models instead of 345 | // being returned as native arrays. 346 | 347 | return $this->responseAsNative(); 348 | } 349 | } 350 | 351 | /** 352 | * @param string $modelName example res.partner 353 | * @param array $instanceIds list of model instance IDs to read and return 354 | * @param array $options varies with API versions see documentation 355 | * @return Collection of ModelInterface 356 | */ 357 | public function read( 358 | string $modelName, 359 | iterable $instanceIds = [], 360 | array $options = [] 361 | ) { 362 | $msg = $this->getBaseObjectRequest($modelName, 'read'); 363 | 364 | $msg->addParam($this->nativeToValue($instanceIds)); 365 | 366 | if (! empty($options)) { 367 | $msg->addParam($this->nativeToValue($options)); 368 | } 369 | 370 | $this->response = $this->getXmlRpcClient(static::API_TYPE_OBJECT)->send($msg); 371 | 372 | $data = $this->responseAsNative(); 373 | 374 | $modelName = $this->mapModelName($modelName); 375 | 376 | return collect($data)->map(function ($item) use ($modelName) { 377 | return new $modelName($item); 378 | }); 379 | } 380 | 381 | /** 382 | * Get the server version information. 383 | * 384 | * @return array 385 | */ 386 | public function version() 387 | { 388 | $msg = new Request('version'); 389 | 390 | $this->response = $this->getXmlRpcClient(static::API_TYPE_COMMON)->send($msg); 391 | 392 | return $this->responseAsNative(); 393 | } 394 | 395 | /** 396 | * Create a new resource. 397 | * 398 | * @param array $fields 399 | */ 400 | public function create(string $modelName, array $fields) 401 | { 402 | $msg = $this->getBaseObjectRequest($modelName, 'create'); 403 | 404 | $msg->addParam($this->nativeToValue($fields)); 405 | 406 | $this->response = $this->getXmlRpcClient(static::API_TYPE_OBJECT)->send($msg); 407 | 408 | return $this->responseAsNative(); 409 | } 410 | 411 | /** 412 | * Update a resource. 413 | * 414 | * @return bool true if the update was successful. 415 | */ 416 | public function write(string $modelName, int $resourceId, array $fields) 417 | { 418 | $msg = $this->getBaseObjectRequest($modelName, 'write'); 419 | 420 | $msg->addParam($this->nativeToValue([$resourceId])); 421 | $msg->addParam($this->nativeToValue($fields)); 422 | 423 | $this->response = $this->getXmlRpcClient(static::API_TYPE_OBJECT)->send($msg); 424 | 425 | return $this->responseAsNative(); 426 | } 427 | 428 | /** 429 | * Remove a resource. 430 | * 431 | * @return bool true if the removal was successful. 432 | */ 433 | public function unlink(string $modelName, int $resourceId) 434 | { 435 | $msg = $this->getBaseObjectRequest($modelName, 'unlink'); 436 | 437 | $msg->addParam($this->nativeToValue([$resourceId])); 438 | 439 | $this->response = $this->getXmlRpcClient(static::API_TYPE_OBJECT)->send($msg); 440 | 441 | return $this->responseAsNative(); 442 | } 443 | 444 | /** 445 | * Get a list of fields for a resource. 446 | * TODO: there are some more parameters to define the context more. 447 | * 448 | * @return Collection of arrays (may be collection of models later) 449 | */ 450 | public function fieldsGet(string $modelName) 451 | { 452 | $msg = $this->getBaseObjectRequest($modelName, 'fields_get'); 453 | 454 | $this->response = $this->getXmlRpcClient(static::API_TYPE_OBJECT)->send($msg); 455 | 456 | return $this->responseAsNative(); 457 | } 458 | 459 | /** 460 | * Get the ERP internal resource ID for a given external ID. 461 | * This in thoery kind of belongs in a wrapper to the client, 462 | * but is used so often in data syncs that it makes sense having 463 | * it here. 464 | * 465 | * @param string $externalId either "name" or "module.name" 466 | * @param string $module optional, but recommended 467 | * @return int|null 468 | */ 469 | public function getResourceId(string $externalId, string $model = null) 470 | { 471 | $resourceIds = $this->getResourceIds([$externalId], $model); 472 | 473 | return $resourceIds->first(); 474 | } 475 | 476 | /** 477 | * Get multiple resource IDs at once. 478 | * 479 | * @param array $externalIds each either "name" or "module.name" 480 | * @param string $module optional, but recommended 481 | * @return collection 482 | * 483 | * FIXME: all external IDs must have the same "module" at the moment. 484 | * Will fix this later if needed and if I can find sufficient documentaion. 485 | */ 486 | public function getResourceIds( 487 | iterable $externalIds, 488 | string $model = null, 489 | $offset = 0, 490 | $limit = self::DEFAULT_LIMIT, 491 | $order = '' 492 | ) { 493 | $criteria = []; 494 | 495 | if ($model !== null) { 496 | $criteria[] = ['model', '=', 'res.partner']; 497 | } 498 | 499 | $moduleList = []; 500 | 501 | foreach($externalIds as $externalId) { 502 | if (strpos($externalId, '.') !== false) { 503 | list ($module, $name) = explode('.', $externalId, 2); 504 | } else { 505 | $name = $externalId; 506 | $module = '{none}'; 507 | } 508 | 509 | if (! array_key_exists($module, $moduleList)) { 510 | $moduleList[$module] = []; 511 | } 512 | 513 | $moduleList[$module][] = $name; 514 | } 515 | 516 | // TODO: work out how to represent the boolean OR operator 517 | // for multiple modules fetched at once. 518 | // Each set of conditions in this loop should be ORed with 519 | // every other set of conditions in this loop. 520 | // So we should be able to search for "foo.bar_123" and "fing.bing_456" 521 | // in one query, giving us conceptually: 522 | // ((module = foo and name = bar_123) or (module = fing and name = bing_456)) 523 | 524 | foreach($moduleList as $module => $externalIds) { 525 | if ($module !== '{none}') { 526 | $criteria[] = ['module', '=', $module]; 527 | } 528 | 529 | $criteria[] = ['name', 'in', $externalIds]; 530 | } 531 | 532 | $irModelDataIds = $this->search( 533 | 'ir.model.data', 534 | $criteria, 535 | $offset, 536 | $limit, 537 | $order 538 | ); 539 | 540 | if (empty($irModelDataIds)) { 541 | // No matches found, so give up now. 542 | 543 | return collect(); 544 | } 545 | 546 | // Now read the full records to get the resource IDs. 547 | 548 | $irModelData = $this->read( 549 | 'ir.model.data', 550 | $irModelDataIds 551 | ); 552 | 553 | if ($irModelData === null) { 554 | // We could not find the record. 555 | // (We really should have, since we just looked it up) 556 | 557 | return collect(); 558 | } 559 | 560 | // Return the resource IDs. 561 | 562 | return $irModelData->map(function ($item) { 563 | return $item->get('res_id'); 564 | }); 565 | } 566 | 567 | /** 568 | * Use the load() method to load resources to Odoo. 569 | * Uses the external ID to identify resources, so it is able 570 | * to create or update resources as necessary. 571 | * 572 | * @param string $modelName 573 | * @param iterable $records list of key->value records to load 574 | * @return array of two collections - 'ids' and 'messages' 575 | */ 576 | public function load(string $modelName, iterable $records) 577 | { 578 | // All records loaded in one go must have the same set of 579 | // keys, so group the records into same-key groups. 580 | 581 | $groups = []; 582 | 583 | foreach ($records as $record) { 584 | // Index each group by a hash of the keys. 585 | 586 | $keysHash = md5(implode(':', array_keys($record))); 587 | 588 | if (! array_key_exists($keysHash, $groups)) { 589 | $groups[$keysHash] = [ 590 | 'keys' => array_keys($record), 591 | 'records' => [], 592 | ]; 593 | } 594 | 595 | $groups[$keysHash]['records'][] = array_values($record); 596 | } 597 | 598 | // Now we can load each group in turn, collating the results. 599 | 600 | $results = [ 601 | 'messages' => [], 602 | 'ids' => [], 603 | ]; 604 | 605 | $messages = collect(); 606 | $ids = collect(); 607 | 608 | foreach ($groups as $group) { 609 | $msg = $this->getBaseObjectRequest($modelName, 'load'); 610 | 611 | $msg->addParam($this->nativeToValue($group['keys'])); 612 | $msg->addParam($this->nativeToValue($group['records'])); 613 | 614 | $this->response = $this->getXmlRpcClient(static::API_TYPE_OBJECT)->send($msg); 615 | 616 | $groupResult = $this->responseAsNative(); 617 | 618 | // Add in any ids (successfuly loaded) and messages (indicating 619 | // unsuccessfuly loaded records). 620 | // Arrays are not always returned, e.g. `false` so take care of that. 621 | 622 | if (is_array($groupResult['ids'] ?? null)) { 623 | $ids = $ids->merge($groupResult['ids']); 624 | } 625 | 626 | if (is_array($groupResult['messages'] ?? null)) { 627 | $messages = $messages->merge($groupResult['messages']); 628 | } 629 | } 630 | 631 | return [ 632 | 'ids' => $ids, 633 | 'messages' => $messages, 634 | ]; 635 | } 636 | 637 | /** 638 | * Use the load() method to load resources to Odoo. 639 | * Uses the external ID to identify resources, so it is able 640 | * to create or update resources as necessary. 641 | * 642 | * @param string $modelName 643 | * @param array|mixed $record key->value record to load 644 | * @return array of two values - 'id' and 'messages' 645 | */ 646 | public function loadOne(string $modelName, $record) 647 | { 648 | $result = $this->load($modelName, [$record]); 649 | 650 | return [ 651 | 'id' => $result['ids']->first(), 652 | 'messages' => $result['messages'], 653 | ]; 654 | } 655 | 656 | /** 657 | * Get the last response as a native PHP value. 658 | * 659 | * @param ?Response opional, defaulting to teh last response 660 | * @return mixed 661 | * @throws Exception if no payload could be decoded 662 | */ 663 | public function responseAsNative(?Response $response = null) 664 | { 665 | if ($response === null) { 666 | $response = $this->response; 667 | } 668 | 669 | if ($response === null) { 670 | return $response; 671 | } 672 | 673 | if ($response->value() instanceof Value) { 674 | return $this->valueToNative($response->value()); 675 | } 676 | 677 | $errorMessage = sprintf( 678 | 'Unhandled Odoo API exception code %d: %s', 679 | $response->faultCode(), 680 | $response->faultString() 681 | ); 682 | 683 | throw new Exception ( 684 | $errorMessage, 685 | $response->faultCode() 686 | ); 687 | } 688 | 689 | /** 690 | * Map the model name (e.g. "res.partner") to the model class 691 | * it will be instantiated into. 692 | * TODO: turn this into a factory. 693 | */ 694 | protected function mapModelName(string $modelName) 695 | { 696 | if (array_key_exists($modelName, $this->modelMapping)) { 697 | return $this->modelMapping[$modelName]; 698 | } 699 | 700 | // Default fallback. 701 | 702 | return Model::class; 703 | } 704 | 705 | /** 706 | * Add multiple module name to model class mapping entries. 707 | */ 708 | public function addModelMap(array $modelMap) 709 | { 710 | foreach ($modelMap as $modelName => $className) { 711 | $this->addModelMapping($modelName, $className); 712 | } 713 | 714 | return $this; 715 | } 716 | 717 | /** 718 | * Add a single module name to model class mapping entry 719 | */ 720 | public function addModelMapping(string $modelName, string $className = null) 721 | { 722 | if ($className !== null) { 723 | $this->modelMapping[$modelName] = $className; 724 | } else { 725 | $this->removeModelMapping($modelName); 726 | } 727 | 728 | return $this; 729 | } 730 | 731 | /** 732 | * Remove a module name to model class mapping entry. 733 | */ 734 | public function removeModelMapping(string $modelName) 735 | { 736 | unset($this->modelMapping[$modelName]); 737 | 738 | return $this; 739 | } 740 | 741 | /** 742 | * Return a message with the base parameters for any object call. 743 | * Identified the login credentials, model and action. 744 | * TODO: this should go in the connector factory. 745 | * 746 | * @param string|null $modelName 747 | * @param string|null $action will be used only the $modelName is provided 748 | * @return Request 749 | */ 750 | public function getBaseObjectRequest( 751 | string $modelName = null, 752 | string $action = null 753 | ) { 754 | $msg = new Request('execute'); 755 | 756 | $msg->addParam($this->stringValue($this->database)); 757 | $msg->addParam($this->intValue($this->getUserId())); 758 | $msg->addParam($this->stringValue($this->password)); 759 | 760 | if ($modelName !== null) { 761 | $msg->addParam($this->stringValue($modelName)); 762 | 763 | if ($action !== null) { 764 | $msg->addParam($this->stringValue($action)); 765 | } 766 | } 767 | 768 | return $msg; 769 | } 770 | 771 | /** 772 | * Adds a new record created from the provided value dict. 773 | * 774 | * @param array $values 775 | * @return array 776 | */ 777 | public function relationCreate(array $values) 778 | { 779 | return [[ 780 | static::RELATION_CREATE, 0, $values 781 | ]]; 782 | } 783 | 784 | /** 785 | * Updates an existing record of id `id` with the values in values. 786 | * Can not be used in create(). 787 | * 788 | * @param int $resourceId the resource to update 789 | * @param array $values 790 | * @return array 791 | * 792 | * TODO: as well as an array of values, accept a model. 793 | * The model, ideally, would be able to provide a list of all the 794 | * fields that have changed so that only those are updated. 795 | * Extending that into relations within the field list would be 796 | * a bit more involved though. 797 | */ 798 | public function relationUpdate(int $resourceId, array $values) 799 | { 800 | return [[ 801 | static::RELATION_UPDATE, $resourceId, $values 802 | ]]; 803 | } 804 | 805 | /** 806 | * Removes the record of id `id` from the set, then deletes it 807 | * (from the database). 808 | * Can not be used in create(). 809 | * 810 | * @param int $resourceId the resource to be removed from the database 811 | * @return array 812 | */ 813 | public function relationDelete(int $resourceId) 814 | { 815 | return [[ 816 | static::RELATION_DELETE, $resourceId, 0 817 | ]]; 818 | } 819 | 820 | /** 821 | * Removes the record of id `id` from the set, but does not delete it. 822 | * Can not be used on one2many. 823 | * Can not be used in create(). 824 | * 825 | * @param int $resourceId the resource to be removed from the link 826 | * @return array 827 | */ 828 | public function relationRemoveLink(int $resourceId) 829 | { 830 | return [[ 831 | static::RELATION_REMOVE_LINK, $resourceId, 0 832 | ]]; 833 | } 834 | 835 | /** 836 | * Creates data structure for setting relationship details. 837 | * Adds an existing record of id `id` to the set. 838 | * Can not be used on one2many. 839 | * 840 | * @param int $resourceId the resource to be added to the link 841 | * @return array 842 | */ 843 | public function relationAddLink(int $resourceId) 844 | { 845 | return [[ 846 | static::RELATION_ADD_LINK, $resourceId, 0 847 | ]]; 848 | } 849 | 850 | /** 851 | * Removes all records from the set, equivalent to using the 852 | * command 3 on every record explicitly. 853 | * Can not be used on one2many. 854 | * Can not be used in create(). 855 | * 856 | * @return array 857 | */ 858 | public function relationRemoveAllLinks() 859 | { 860 | return [[ 861 | static::RELATION_REMOVE_ALL_LINKS, 0, 0 862 | ]]; 863 | } 864 | 865 | /** 866 | * Replaces all existing records in the set by the ids list, 867 | * equivalent to using the command 5 followed by a command 4 868 | * for each id in ids. 869 | * Can not be used on one2many. 870 | * 871 | * @return array 872 | */ 873 | public function relationReplaceAllLinks(iterable $resourceIds) 874 | { 875 | return [[ 876 | static::RELATION_REPLACE_ALL_LINKS, 0, $resourceIds 877 | ]]; 878 | } 879 | 880 | /** 881 | * Walk through the criteria array and convert scalar values to 882 | * XML-RPC objects, and nested arrays to array and struct objects. 883 | * 884 | * @param mixed $item 885 | * @return mixed 886 | */ 887 | public function nativeToValue($item) 888 | { 889 | // If already converted, then don't try to convert it again. 890 | // Checked early as some Value types are also iterable. 891 | 892 | if ($item instanceof Value) { 893 | return $item; 894 | } 895 | 896 | // If a scalar, then map to the appropriate object. 897 | 898 | if (! is_iterable($item)) { 899 | if (gettype($item) === 'integer') { 900 | return $this->intValue($item); 901 | } elseif (gettype($item) === 'string') { 902 | return $this->stringValue($item); 903 | } elseif (gettype($item) === 'double') { 904 | return $this->doubleValue($item); 905 | } elseif (gettype($item) === 'boolean') { 906 | return $this->booleanValue($item); 907 | } elseif ($item === null) { 908 | return $this->nullValue(); 909 | } else { 910 | // No idea what it is, so don't know how to handle it. 911 | throw new Exception(sprintf( 912 | 'Unrecognised data type %s', 913 | gettype($item) 914 | )); 915 | } 916 | } 917 | 918 | // If an iterable, then deal with the children first. 919 | 920 | // Clone the item if it is an object. 921 | // If we don't, then collections get changed in-situ, i.e. your 922 | // collection of IDs submitted for the criteria is converted to 923 | // XML-RPC Value objects. 924 | // Arrays are automatically cloned by PHP on first change. 925 | 926 | if (is_object($item)) { 927 | $item = clone($item); 928 | } 929 | 930 | foreach ($item as $key => $element) { 931 | $item[$key] = $this->nativeToValue($element); 932 | } 933 | 934 | // Map to an array or a struct, depending on whether a numeric 935 | // keyed array or an associative array is to be encoded. 936 | 937 | if ($item === [] 938 | || (is_array($item) && array_keys($item) === range(0, count($item) - 1)) 939 | || (is_iterable($item) && ! is_array($item)) 940 | ) { 941 | return $this->arrayValue($item); 942 | } else { 943 | return $this->structValue($item); 944 | } 945 | } 946 | 947 | /** 948 | * Convert a Value object into native PHP types. 949 | * Basically the reverse of nativeToValue(). 950 | * 951 | * @param Value the object to convert, which may contain nested objects 952 | * @return mixed a null, an array, a scalar, and may be nested 953 | */ 954 | public function valueToNative(Value $value) 955 | { 956 | switch ($value->kindOf()) { 957 | case 'array': 958 | $result = []; 959 | foreach ($value->getIterator() as $element) { 960 | $result[] = $this->valueToNative($element); 961 | } 962 | break; 963 | case 'struct': 964 | $result = []; 965 | foreach ($value->getIterator() as $key => $element) { 966 | $result[$key] = $this->valueToNative($element); 967 | } 968 | break; 969 | case 'scalar': 970 | return $value->scalarval(); 971 | break; 972 | default: 973 | throw new Exception(sprintf( 974 | 'Unexpected data type %s', 975 | $value->kindOf() 976 | )); 977 | } 978 | 979 | return $result; 980 | } 981 | 982 | /** 983 | * The last response, in case it needs to be inspected for 984 | * error reasons. 985 | * 986 | * @return Response|null 987 | */ 988 | public function getLastResponse() 989 | { 990 | return $this->response; 991 | } 992 | } 993 | -------------------------------------------------------------------------------- /src/OdooFacade.php: -------------------------------------------------------------------------------- 1 | clients)) { 24 | return $this->clients[$configName]; 25 | } 26 | 27 | // Get the connection data set. 28 | 29 | if ($configName === null) { 30 | $configName = config('odoo-api.default'); 31 | } 32 | 33 | $config = config('odoo-api.connections.' . $configName); 34 | 35 | $client = $this->clients[$configName] = new OdooClient($config); 36 | 37 | // Start with the top level mappings. 38 | 39 | $modelMap = config('odoo-api.model_map', []); 40 | 41 | $client->addModelMap($modelMap); 42 | 43 | // Override with any mappings specific for the connection. 44 | 45 | $modelMap = config('odoo-api.connections.' . $configName . '.model_map', []); 46 | 47 | $client->addModelMap($modelMap); 48 | 49 | return $client; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/OdooServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 33 | __DIR__ . '/../config/' . $configFilename => config_path($configFilename), 34 | ]); 35 | } 36 | 37 | public function register() 38 | { 39 | $configFilename = static::PROVIDES . '.php'; 40 | 41 | $this->mergeConfigFrom( 42 | __DIR__ . '/../config/' . $configFilename, static::PROVIDES 43 | ); 44 | 45 | $this->app->singleton(static::PROVIDES, function ($app) { 46 | $odooService = new OdooService(); 47 | 48 | return $odooService; 49 | }); 50 | } 51 | } 52 | --------------------------------------------------------------------------------