diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100755 index 0000000..1b50467 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Change Log +All notable changes to this project will be documented in this file. + +## 1.0.0 - 2017-03-21 +### Added +- This CHANGELOG file to hopefully serve as an evolving example of a standardized open source project CHANGELOG. +- Added initial Artbox Core extension. \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100755 index 0000000..e98f03d --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,32 @@ +The Yii framework is free software. It is released under the terms of +the following BSD License. + +Copyright © 2008 by Yii Software LLC (http://www.yiisoft.com) +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of Yii Software LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md old mode 100644 new mode 100755 index e69de29..919d79d --- a/README.md +++ b/README.md @@ -0,0 +1,34 @@ +Artbox Core +=============================== + +Artbox Core is a core extension of light-weight CMS developed by Artweb written with +[Yii 2 framework](http://www.yiiframework.com/). + +Core extension includes functionality for app Internationalization, basic SEO optimization, +feedback, image manipulation, user data and static pages control. Also provides events +notifications. + +This extension is enough to develop landings or small websites with static pages. + +To prepare your application you should run migrations: + + php yii migrate --migationPath=vendor/artweb/artbox-core/migrations + +DIRECTORY STRUCTURE +------------------- + +``` +assets contains AssetBundles +behaviors contains Behaviors classes +components contains custom Classes, which don't belong to other groups +controllers contains controllers for core models +helpers contains helper classes to manipulate, for example static files + and HTML +messages contains translations for core strings +migrations contains migrations, which should be applied after extension + installation +models contains core models +views contains views files for core controllers +web contains assets and other files, which should be web available +widgets contains widgets +``` diff --git a/assets/ArtboxOdooAsset.php b/assets/ArtboxOdooAsset.php new file mode 100755 index 0000000..f836cf9 --- /dev/null +++ b/assets/ArtboxOdooAsset.php @@ -0,0 +1,32 @@ +db = $connection; + parent::__construct($config); + } + + /** + * Select ID of model, which will be modified or deleted + * + * @param int $id + * + * @return \artbox\odoo\components\Builder + */ + public function addId(int $id): Builder + { + $this->parameters[ 0 ][] = $id; + return $this; + } + + /** + * Set $param of model, which will be created or modified + * + * @param string $param + * @param string|array $value + * + * @return \artbox\odoo\components\Builder + */ + public function setParam(string $param, $value): Builder + { + $this->parameters[ 1 ][ $param ] = $value; + return $this; + } + + /** + * Generate create command + * + * @param string $model + * + * @return \artbox\odoo\components\Command + */ + public function create(string $model): Command + { + return $this->db->createCommand($model, 'create', [ $this->parameters[ 1 ] ]); + } + + /** + * Generate update model + * + * @param string $model + * + * @return \artbox\odoo\components\Command + */ + public function update(string $model): Command + { + return $this->db->createCommand($model, 'write', $this->parameters); + } + + /** + * Generate delete model + * + * @param string $model + * + * @return \artbox\odoo\components\Command + */ + public function delete(string $model): Command + { + return $this->db->createCommand($model, 'unlink', [ $this->parameters[ 0 ] ]); + } + + /** + * Clear builder + * + * @return \artbox\odoo\components\Builder + */ + public function clear(): Builder + { + $this->parameters = [ + [], + [], + ]; + return $this; + } + } \ No newline at end of file diff --git a/components/Command.php b/components/Command.php new file mode 100644 index 0000000..c1f0493 --- /dev/null +++ b/components/Command.php @@ -0,0 +1,517 @@ +db instanceof Connection) { + throw new InvalidConfigException('$db must be instance of ' . Connection::className()); + } + } + + /** + * Executes this command. + * + * @return array result cursor. + * @throws Exception on failure. + */ + public function execute() + { + $token = $this->log( + [ + $this->model, + 'command', + ], + $this->parameters, + __METHOD__ + ); + try { + $this->beginProfile($token, __METHOD__); + $this->db->open(); + $result = $this->internalExecute(); + $this->endProfile($token, __METHOD__); + } catch (RuntimeException $e) { + $this->endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), $e->getCode(), $e); + } + return $result; + } + + /** + * Executes this command as a Odoo query + * + * @param string $model model + * @param array $parameters + * + * @return array result cursor. + * @throws \artbox\odoo\components\Exception on failure + */ + public function query($model, $parameters = [ [] ]) + { + $this->model = $model; + $this->parameters = $parameters; + $token = $this->log( + 'find', + array_merge( + [ + 'model' => $model, + ], + $parameters + ), + __METHOD__ + ); + try { + $this->beginProfile($token, __METHOD__); + $this->db->open(); + $result = $this->internalExecute(); + $this->endProfile($token, __METHOD__); + } catch (RuntimeException $e) { + $this->endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), $e->getCode(), $e); + } + return $result; + } + + /** + * Executes this command as a Odoo find + * + * @param string $model model + * @param array $parameters + * @param array $mapping + * + * @return array result cursor. + * @throws \artbox\odoo\components\Exception on failure + */ + public function find($model, $parameters = [ [] ], $mapping = []) + { + $this->model = $model; + $this->parameters = $parameters; + $this->mapping = $mapping; + $token = $this->log( + 'find', + array_merge( + [ + 'model' => $model, + ], + $parameters, + $mapping + ), + __METHOD__ + ); + try { + $this->beginProfile($token, __METHOD__); + $this->db->open(); + $result = $this->internalExecute(); + $this->endProfile($token, __METHOD__); + } catch (RuntimeException $e) { + $this->endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), $e->getCode(), $e); + } + return $result; + } + + /** + * Return ids from Odoo by filter parameters + * + * @param string $model model + * @param array $parameters + * @param array $options + * + * @return array result cursor. + * @throws \artbox\odoo\components\Exception on failure + */ + public function search(string $model, array $parameters = [ [] ], array $options = []) + { + $token = $this->log( + 'search', + array_merge( + [ + 'model' => $model, + ], + $parameters, + $options + ), + __METHOD__ + ); + try { + $this->beginProfile($token, __METHOD__); + $this->db->open(); + $result = $this->internalRun($model, 'search', $parameters, $options); + $this->endProfile($token, __METHOD__); + } catch (RuntimeException $e) { + $this->endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), $e->getCode(), $e); + } + return $result; + } + + /** + * Return count of records from Odoo by filter parameters + * + * @param string $model model + * @param array $parameters + * @param array $options + * + * @return integer result cursor. + * @throws \artbox\odoo\components\Exception on failure + */ + public function count(string $model, array $parameters = [ [] ], array $options = []) + { + $token = $this->log( + 'count', + array_merge( + [ + 'model' => $model, + ], + $parameters, + $options + ), + __METHOD__ + ); + try { + $this->beginProfile($token, __METHOD__); + $this->db->open(); + $result = $this->internalRun($model, 'search_count', $parameters, $options); + $this->endProfile($token, __METHOD__); + } catch (RuntimeException $e) { + $this->endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), $e->getCode(), $e); + } + return $result; + } + + /** + * Return records from Odoo by filter parameters + * + * @param string $model model + * @param array $ids + * @param array $fields + * + * @return array result cursor. + * @throws \artbox\odoo\components\Exception on failure + */ + public function read(string $model, array $ids, array $fields = []) + { + $token = $this->log( + 'read', + array_merge( + [ + 'model' => $model, + ], + $ids + ), + __METHOD__ + ); + try { + $this->beginProfile($token, __METHOD__); + $this->db->open(); + $result = $this->internalRun($model, 'read', $ids, $fields); + $this->endProfile($token, __METHOD__); + } catch (RuntimeException $e) { + $this->endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), $e->getCode(), $e); + } + return $result; + } + + /** + * Return fields from Odoo by filter parameters + * + * @param string $model model + * @param array $parameters + * @param array $options + * + * @return array result cursor. + * @throws \artbox\odoo\components\Exception on failure + */ + public function fields(string $model, array $parameters = [], array $options = []) + { + $token = $this->log( + 'fields', + array_merge( + [ + 'model' => $model, + ], + $parameters, + $options + ), + __METHOD__ + ); + try { + $this->beginProfile($token, __METHOD__); + $this->db->open(); + $result = $this->internalRun($model, 'fields_get', $parameters, $options); + $this->endProfile($token, __METHOD__); + } catch (RuntimeException $e) { + $this->endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), $e->getCode(), $e); + } + return $result; + } + + /** + * Search and read records from Odoo by filter parameters + * + * @param string $model model + * @param array $parameters + * @param array $options + * + * @return array result cursor. + * @throws \artbox\odoo\components\Exception on failure + */ + public function searchRead(string $model, array $parameters = [], array $options = []) + { + $token = $this->log( + 'search_read', + array_merge( + [ + 'model' => $model, + ], + $parameters, + $options + ), + __METHOD__ + ); + try { + $this->beginProfile($token, __METHOD__); + $this->db->open(); + $result = $this->internalRun($model, 'search_read', $parameters, $options); + $this->endProfile($token, __METHOD__); + } catch (RuntimeException $e) { + $this->endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), $e->getCode(), $e); + } + return $result; + } + + /** + * Create record in Odoo and return id + * + * @param string $model model + * @param array $parameters + * + * @return array result cursor. + * @throws \artbox\odoo\components\Exception on failure + * @internal param array $options + */ + public function create(string $model, array $parameters = []) + { + $token = $this->log( + 'create', + array_merge( + [ + 'model' => $model, + ], + $parameters + ), + __METHOD__ + ); + try { + $this->beginProfile($token, __METHOD__); + $this->db->open(); + $result = $this->internalRun($model, 'create', $parameters); + $this->endProfile($token, __METHOD__); + } catch (RuntimeException $e) { + $this->endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), $e->getCode(), $e); + } + return $result; + } + + /** + * Update record in Odoo + * + * @param string $model model + * @param array $parameters + * + * @return array result cursor. + * @throws \artbox\odoo\components\Exception on failure + * @internal param array $options + */ + public function update(string $model, array $parameters = []) + { + $token = $this->log( + 'update', + array_merge( + [ + 'model' => $model, + ], + $parameters + ), + __METHOD__ + ); + try { + $this->beginProfile($token, __METHOD__); + $this->db->open(); + $result = $this->internalRun($model, 'write', $parameters); + $this->endProfile($token, __METHOD__); + } catch (RuntimeException $e) { + $this->endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), $e->getCode(), $e); + } + return $result; + } + + /** + * Delete records from Odoo + * + * @param string $model model + * @param array $parameters + * + * @return array result cursor. + * @throws \artbox\odoo\components\Exception on failure + * @internal param array $options + */ + public function delete(string $model, array $parameters = []) + { + $token = $this->log( + 'delete', + array_merge( + [ + 'model' => $model, + ], + $parameters + ), + __METHOD__ + ); + try { + $this->beginProfile($token, __METHOD__); + $this->db->open(); + $result = $this->internalRun($model, 'unlink', $parameters); + $this->endProfile($token, __METHOD__); + } catch (RuntimeException $e) { + $this->endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), $e->getCode(), $e); + } + return $result; + } + + /** + * Logs the command data if logging is enabled at [[db]]. + * + * @param array|string $namespace command namespace. + * @param array $data command data. + * @param string $category log category + * + * @return string|false log token, `false` if log is not enabled. + */ + protected function log($namespace, $data, $category) + { + if ($this->db->enableLogging) { + $token = $this->db->getLogBuilder() + ->generateToken($namespace, $data); + \Yii::info($token, $category); + return $token; + } + return false; + } + /** + * Marks the beginning of a code block for profiling. + * + * @param string $token token for the code block + * @param string $category the category of this log message + * + * @see endProfile() + */ + protected function beginProfile($token, $category) + { + if ($token !== false && $this->db->enableProfiling) { + \Yii::beginProfile($token, $category); + } + } + /** + * Marks the end of a code block for profiling. + * + * @param string $token token for the code block + * @param string $category the category of this log message + * + * @see beginProfile() + */ + protected function endProfile($token, $category) + { + if ($token !== false && $this->db->enableProfiling) { + \Yii::endProfile($token, $category); + } + } + + protected function internalExecute() + { + $client = $this->db->getFetchClient(); + return call_user_func_array( + [ + $client, + 'execute_kw', + ], + [ + $this->db->db, + $this->db->getUid(), + $this->db->password, + $this->model, + $this->method, + $this->parameters, + $this->mapping, + ] + ); + } + + /** + * @param string $model + * @param string $method + * @param array $parameters + * @param array $mapping + * + * @return mixed + */ + protected function internalRun(string $model, string $method, array $parameters, array $mapping = []) + { + $client = $this->db->getFetchClient(); + return call_user_func_array( + [ + $client, + 'execute_kw', + ], + [ + $this->db->db, + $this->db->getUid(), + $this->db->password, + $model, + $method, + $parameters, + $mapping, + ] + ); + } + } \ No newline at end of file diff --git a/components/Connection.php b/components/Connection.php new file mode 100644 index 0000000..aace580 --- /dev/null +++ b/components/Connection.php @@ -0,0 +1,257 @@ +_queryBuilder)) { + $this->_queryBuilder = \Yii::createObject($this->_queryBuilder, [ $this ]); + } + return $this->_queryBuilder; + } + /** + * Sets the query builder for the this Odoo connection. + * + * @param QueryBuilder|array|string|null $queryBuilder the query builder for this Odoo connection. + */ + public function setQueryBuilder($queryBuilder) + { + $this->_queryBuilder = $queryBuilder; + } + /** + * Returns log builder for this connection. + * + * @return LogBuilder the log builder for this connection. + */ + public function getLogBuilder(): LogBuilder + { + if (!is_object($this->_logBuilder)) { + $this->_logBuilder = \Yii::createObject($this->_logBuilder); + } + return $this->_logBuilder; + } + /** + * Sets log builder used for this connection. + * + * @param array|string|LogBuilder $logBuilder the log builder for this connection. + */ + public function setLogBuilder($logBuilder) + { + $this->_logBuilder = $logBuilder; + } + + /** + * Establishes a Odoo connection. + * It does nothing if a Odoo connection has already been established. + * + * @throws Exception if connection fails + */ + public function open() + { + if ($this->client === null) { + if (empty($this->username) || empty($this->db) || empty($this->password) || empty($this->url)) { + throw new InvalidConfigException( + '$username, $db, $password, $url must be set for ' . $this->className() + ); + } + $token = "Opening Odoo connection: " . $this->getDsn(); + try { + \Yii::trace($token, __METHOD__); + \Yii::beginProfile($token, __METHOD__); + $this->client = Ripcord::client("$this->url/xmlrpc/2/common"); + $this->uid = call_user_func_array( + [ + $this->client, + 'authenticate', + ], + [ + $this->db, + $this->username, + $this->password, + [], + ] + ); + if (!$this->uid) { + throw new InvalidConfigException("Incorrect authentication data."); + } + $this->fetchClient = Ripcord::client("$this->url/xmlrpc/2/object"); + $this->initConnection(); + \Yii::endProfile($token, __METHOD__); + } catch (\Exception $e) { + \Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int) $e->getCode(), $e); + } + } + } + + /** + * Closes the currently active DB connection. + * It does nothing if the connection is already closed. + */ + public function close() + { + if ($this->client !== null) { + \Yii::trace('Closing Odoo connection: ' . $this->getDsn(), __METHOD__); + $this->client = null; + $this->uid = null; + } + } + + /** + * Initializes the DB connection. + * This method is invoked right after the DB connection is established. + * The default implementation triggers an [[EVENT_AFTER_OPEN]] event. + */ + protected function initConnection() + { + $this->trigger(self::EVENT_AFTER_OPEN); + } + + /** + * Creates Odoo command. + * + * @param string $model Model name + * @param string $method Method name + * @param array $parameters parameters passed by position + * @param array $mapping mapping of parameters to pass by keyword + * + * @return \artbox\odoo\components\Command command instance. + */ + public function createCommand(string $model, string $method, array $parameters, array $mapping = []): Command + { + return new Command( + [ + 'db' => $this, + 'model' => $model, + 'method' => $method, + 'parameters' => $parameters, + 'mapping' => $mapping, + ] + ); + } + + /** + * Get dsn string + * + * @return string + */ + public function getDsn() + { + return $this->url . ', ' . $this->db . ', ' . $this->username . ', ' . $this->password; + } + + /** + * Get authenticated user ID + */ + public function getUid() + { + return $this->uid; + } + + /** + * Get fetch client + * + * @return \Ripcord\Client\Client + */ + public function getFetchClient() + { + return $this->fetchClient; + } + + /** + * Get common client + * + * @return \Ripcord\Client\Client + */ + public function getClient() + { + return $this->client; + } + } \ No newline at end of file diff --git a/components/Exception.php b/components/Exception.php new file mode 100644 index 0000000..27fa473 --- /dev/null +++ b/components/Exception.php @@ -0,0 +1,17 @@ +encodeData($data) . ')'; + } + /** + * Encodes complex log data into JSON format string. + * + * @param mixed $data raw data. + * + * @return string encoded data string. + */ + public function encodeData($data) + { + return json_encode($this->processData($data)); + } + /** + * Pre-processes the log data before sending it to `json_encode()`. + * + * @param mixed $data raw data. + * + * @return mixed the processed data. + */ + protected function processData($data) + { + if (is_object($data)) { + $result = []; + foreach ($data as $name => $value) { + $result[ $name ] = $value; + } + $data = $result; + if ($data === []) { + return new \stdClass(); + } + } + if (is_array($data)) { + foreach ($data as $key => $value) { + if (is_array($value) || is_object($value)) { + $data[ $key ] = $this->processData($value); + } + } + } + return $data; + } + } \ No newline at end of file diff --git a/components/OdooHelper.php b/components/OdooHelper.php new file mode 100644 index 0000000..5a2a4c8 --- /dev/null +++ b/components/OdooHelper.php @@ -0,0 +1,312 @@ +db = \Yii::$app->get('odoo'); + parent::__construct($config); + } + + /** + * Create new partner for order + * + * @param \artbox\order\models\Order $order + * + * @return int + */ + public function ensurePartner(Order $order): int + { + $builder = new Builder($this->db); + /** + * @var integer $result + */ + $result = $builder->setParam('name', $order->name) + ->setParam('address', $order->address) + ->setParam('phone', $order->phone) + ->setParam('email', $order->email) + ->create('res.partner') + ->execute(); + return $result; + } + + /** + * Return OdooToOrder for current Order + * + * @param \artbox\order\models\Order $order + * + * @return \artbox\odoo\models\OdooToOrder + */ + public function ensureOrder(Order $order): OdooToOrder + { + /** + * @var OdooToOrder $result + */ + $result = OdooToOrder::find() + ->where( + [ + 'order_id' => $order->id, + ] + ) + ->one(); + if ($result && $this->validateOrder($result)) { + return $result; + } + $builder = new Builder($this->db); + $partnerId = $this->ensurePartner($order); + $builder->setParam('partner_id', $partnerId); + $orderId = $builder->create('sale.order') + ->execute(); + $result = new OdooToOrder( + [ + 'order_id' => $order->id, + 'remote_id' => $orderId, + ] + ); + $result->save(false); + return $result; + } + + /** + * Return OdooToProduct for current Product + * + * @param \artbox\catalog\models\Product $product + * + * @return \artbox\odoo\models\OdooToProduct + */ + public function ensureProduct(Product $product): OdooToProduct + { + /** + * @var OdooToProduct $result + */ + $result = OdooToProduct::find() + ->where( + [ + 'product_id' => $product->id, + ] + ) + ->one(); + if ($result && $this->validateProduct($result)) { + return $result; + } + $builder = new Builder($this->db); + $builder->setParam('name', $product->lang->title) + ->setParam('sale_ok', true) + ->setParam('purchase_ok', true) + ->setParam('list_price', $product->variant->price) + ->setParam('default_code', $product->variant->sku); + if (!empty($product->category)) { + $odooToCategory = $this->ensureCategory($product->category); + $categoryId = $odooToCategory->category_id; + $builder->setParam('categ_id[]', $categoryId); + } else { + $categoryId = null; + $builder->setParam('categ_id[]', 1); + } + $productId = $builder->create('product.template') + ->execute(); + $result = new OdooToProduct( + [ + 'product_id' => $product->id, + 'remote_id' => $productId, + ] + ); + $result->save(false); + return $result; + } + + /** + * Return OdooToCategory for current Category + * + * @param \artbox\catalog\models\Category $category + * + * @return \artbox\odoo\models\OdooToCategory + */ + public function ensureCategory(Category $category): OdooToCategory + { + /** + * @var OdooToCategory $result + */ + $result = OdooToCategory::find() + ->where( + [ + 'category_id' => $category->id, + ] + ) + ->one(); + if ($result && $this->validateCategory($result)) { + return $result; + } + $builder = new Builder($this->db); + $builder->setParam('name', $category->lang->title); + $categoryId = $builder->create('product.category') + ->execute(); + $result = new OdooToCategory( + [ + 'category_id' => $category->id, + 'remote_id' => $categoryId, + ] + ); + $result->save(false); + return $result; + } + + /** + * Write products from current Order to sale.order.line + * + * @param \artbox\order\models\Order $order + */ + public function ensureSaleOrderLine(Order $order) + { + $odooToOrder = $this->ensureOrder($order); + $orderProductIds = ArrayHelper::getColumn( + ( new Query() )->from('sale.order.line') + ->where( + [ + 'order_id', + '=', + $odooToOrder->remote_id, + ] + ) + ->all(), + 'id' + ); + foreach ($orderProductIds as $orderProductId) { + $builder = new Builder($this->db); + $builder->addId($orderProductId) + ->delete('sale.order.line') + ->execute(); + } + foreach ($order->orderProducts as $orderProduct) { + $builder = new Builder($this->db); + $odooToProduct = $this->ensureProduct($orderProduct->variant->product); + $productId = ( new Query() )->from('product.product') + ->where( + [ + 'product_tmpl_id', + '=', + $odooToProduct->remote_id, + ] + ) + ->one()[ 'id' ]; + $builder->setParam('order_id', $odooToOrder->remote_id) + ->setParam( + 'product_id', + $productId + )// ->setParam('product_uom', 6) + ->setParam('product_uom_qty', $orderProduct->count) + ->setParam('price_unit', $orderProduct->price) + ->setParam('name', $orderProduct->variant->product->lang->title) + ->create('sale.order.line') + ->execute(); + } + } + + /** + * Validate OdooToOrder in sale.order + * + * @param \artbox\odoo\models\OdooToOrder $order + * @param bool $delete + * + * @return bool + */ + public function validateOrder(OdooToOrder $order, bool $delete = true): bool + { + $result = ( new Query() )->from('sale.order') + ->where( + [ + 'id', + '=', + $order->remote_id, + ] + ) + ->one(); + if ($result) { + return true; + } else { + if ($delete) { + $order->delete(); + } + return false; + } + } + + /** + * Validate OdooToProduct in product.template + * + * @param \artbox\odoo\models\OdooToProduct $product + * @param bool $delete + * + * @return bool + */ + public function validateProduct(OdooToProduct $product, bool $delete = true): bool + { + $result = ( new Query() )->from('product.template') + ->where( + [ + 'id', + '=', + $product->remote_id, + ] + ) + ->one(); + if ($result) { + return true; + } else { + if ($delete) { + $product->delete(); + } + return false; + } + } + + /** + * Validate OdooToCategory in product.category + * + * @param \artbox\odoo\models\OdooToCategory $category + * @param bool $delete + * + * @return bool + */ + public function validateCategory(OdooToCategory $category, bool $delete = true): bool + { + $result = ( new Query() )->from('product.category') + ->where( + [ + 'id', + '=', + $category->remote_id, + ] + ) + ->one(); + if ($result) { + return true; + } else { + if ($delete) { + $category->delete(); + } + return false; + } + } + } \ No newline at end of file diff --git a/components/OdooMapper.php b/components/OdooMapper.php new file mode 100644 index 0000000..0037630 --- /dev/null +++ b/components/OdooMapper.php @@ -0,0 +1,56 @@ +map as $index => $value) { + if (isset($odoo[ $index ])) { + if (is_string($value)) { + $result[ $value ] = $odoo[ $index ]; + } else { + $result[ $value[ 'attribute' ] ] = call_user_func($value[ 'artbox' ], $odoo[ $index ]); + } + } + } + return $result; + } + + /** + * Map artbox array to odoo array + * + * @param array $artbox + * + * @return array + */ + public function toOdoo(array $artbox): array + { + $result = []; + foreach ($this->map as $index => $value) { + if (is_string($value)) { + if (isset($artbox[ $value ])) { + $result[ $index ] = $artbox[ $value ]; + } + } else { + if (isset($artbox[ $value[ 'attribute' ] ])) { + $result[ $index ] = call_user_func($value[ 'odoo' ], $artbox[ $value[ 'attribute' ] ]); + } + } + } + return $result; + } + } \ No newline at end of file diff --git a/components/Query.php b/components/Query.php new file mode 100644 index 0000000..343d3cb --- /dev/null +++ b/components/Query.php @@ -0,0 +1,198 @@ +get('odoo'); + } + return $db->createCommand($this->from, 'search_read', $this->options, $this->mapping) + ->execute(); + } + /** + * Executes the query and returns a single row of result. + * + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * + * @return array|bool the first row (in terms of an array) of the query result. False is returned if the query + * results in nothing. + */ + public function one($db = null) + { + $result = $this->all($db); + if (empty($result)) { + return null; + } else { + return $result[ 0 ]; + } + } + /** + * Returns the number of records. + * + * @param string $q the COUNT expression. Defaults to '*'. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * + * @return int number of records. + */ + public function count($q = '*', $db = null) + { + /** + * @var Connection $db + */ + if (empty($db)) { + $db = \Yii::$app->get('odoo'); + } + return ( new Command( + [ + 'db' => $db, + ] + ) )->count('product.product', $this->options, $this->mapping); + } + /** + * Returns a value indicating whether the query result contains any row of data. + * + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * + * @return bool whether the query result contains any row of data. + */ + public function exists($db = null) + { + if ($this->count('*', $db)) { + return true; + } else { + return false; + } + } + /** + * @param array $fields + * + * @return \artbox\odoo\components\Query + */ + public function select(array $fields) + { + $this->mapping[ 'fields' ] = $fields; + + return $this; + } + + /** + * @param $model + * + * @return \artbox\odoo\components\Query + */ + public function from($model) + { + $this->from = $model; + + return $this; + } + + /** + * @param array $mapping + * + * @return \artbox\odoo\components\Query + */ + public function mapping(array $mapping) + { + $this->mapping = $mapping; + + return $this; + } + + /** + * @param array $mapping + * + * @return \artbox\odoo\components\Query + */ + public function addMapping(array $mapping) + { + if (is_array($this->mapping)) { + $this->mapping = array_merge($this->mapping, $mapping); + } else { + $this->mapping = $mapping; + } + return $this; + } + + /** + * @param int|null $offset + * + * @return \artbox\odoo\components\Query + */ + public function offset($offset) + { + $this->mapping[ 'offset' ] = $offset; + + return $this; + } + + /** + * @param int|null $limit + * + * @return \artbox\odoo\components\Query + */ + public function limit($limit) + { + $this->mapping[ 'limit' ] = $limit; + + return $this; + } + + /** + * @param array|string $condition + * + * @return \artbox\odoo\components\Query + */ + public function where($condition) + { + $this->options[ 0 ][] = $condition; + + return $this; + } + + /** + * @param array|string $condition + * + * @return \artbox\odoo\components\Query + */ + public function andWhere($condition) + { + return $this->where($condition); + } + } \ No newline at end of file diff --git a/components/QueryBuilder.php b/components/QueryBuilder.php new file mode 100644 index 0000000..9db975e --- /dev/null +++ b/components/QueryBuilder.php @@ -0,0 +1,28 @@ +db = $connection; + parent::__construct($config); + } + + public function setConnection(Connection $connection) + { + $this->db = $connection; + } + + public function getConnection(): Connection + { + return $this->db; + } + + } \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100755 index 0000000..b02b02f --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "name": "artweb/artbox-odoo", + "description": "Artbox Odoo extension", + "license": "BSD-3-Clause", + "minimum-stability": "dev", + "type": "yii2-extension", + "require": { + "php": ">=7.0", + "yiisoft/yii2": "~2.0", + "artweb/artbox-core": "~0.0.1", + "darkaonline/ripcord": "~0.1" + }, + "autoload": { + "psr-4": { + "artbox\\odoo\\": "" + } + } +} \ No newline at end of file diff --git a/controllers/OdooController.php b/controllers/OdooController.php new file mode 100644 index 0000000..ab2913a --- /dev/null +++ b/controllers/OdooController.php @@ -0,0 +1,391 @@ + [ + 'class' => AccessControl::className(), + 'rules' => [ + [ + 'actions' => [ + 'login', + 'error', + ], + 'allow' => true, + ], + [ + 'allow' => true, + 'roles' => [ '@' ], + ], + ], + ], + 'verbs' => [ + 'class' => VerbFilter::className(), + 'actions' => [], + ], + ]; + } + + public function actionIndex() + { + $products = OdooToProduct::find() + ->all(); + $orders = OdooToOrder::find() + ->all(); + $categories = OdooToCategory::find() + ->all(); + return $this->render( + 'index', + [ + 'products' => $products, + 'orders' => $orders, + 'categories' => $categories, + ] + ); + } + + public function actionImport() + { + return $this->render('import'); + } + + public function actionProcess($from = 0, $limit = 100) + { + /** + * @var \artbox\odoo\components\OdooMapper $mapper + * @var \artbox\odoo\components\Connection $odoo + */ + $mapper = \Yii::$app->get('odooMapper'); + $odoo = \Yii::$app->get('odoo'); + $count = $odoo->createCommand('product.template', 'search_count', [ [] ]) + ->execute(); + $response = \Yii::$app->response; + $response->format = $response::FORMAT_JSON; + $products = ( new Query() )->from('product.template') + ->offset(intval($from)) + ->limit(intval($limit)) + ->all(); + foreach ($products as $product) { + $category = null; + $artbox = $mapper->toArtbox($product); + if (!empty($artbox[ 'category' ])) { + $category = $this->processCategory($artbox[ 'category' ]); + } + $this->processProduct($artbox, $category); + } + if (count($products) < $limit) { + $end = true; + } else { + $end = false; + } + $percent = round(( $from + $limit ) / $count * 100, 2); + if ($percent > 100) { + $percent = 100; + } + return [ + 'from' => $from, + 'limit' => $limit, + 'end' => $end, + 'percent' => $percent, + ]; + } + + public function actionProcessOrder($from = 0, $limit = 100) + { + /** + * @var \artbox\odoo\components\OdooMapper $mapper + * @var \artbox\odoo\components\Connection $odoo + */ + $mapper = \Yii::$app->get('odooMapper'); + $odoo = \Yii::$app->get('odoo'); + $count = $odoo->createCommand('product.template', 'search_count', [ [] ]) + ->execute(); + $response = \Yii::$app->response; + $response->format = $response::FORMAT_JSON; + $orders = ( new Query() )->from('sale.order') + ->offset(intval($from)) + ->limit(intval($limit)) + ->all(); + foreach ($orders as $order) { + $artbox = array_merge( + $mapper->toArtbox( + ( new Query() )->from('res.partner') + ->where( + [ + 'id', + '=', + $order[ 'partner_id' ][ 0 ], + ] + ) + ->one() + ), + $mapper->toArtbox($order) + ); + $products = ( new Query() )->from('sale.order.line') + ->where( + [ + 'order_id', + '=', + $artbox[ 'remote_id' ], + ] + ) + ->all(); + $artboxProducts = []; + foreach ($products as $product) { + $artboxProducts[] = $mapper->toArtbox($product); + } + $this->processOrder($artbox, $artboxProducts); + } + if (count($orders) < $limit) { + $end = true; + } else { + $end = false; + } + $percent = round(( $from + $limit ) / $count * 100, 2); + if ($percent > 100) { + $percent = 100; + } + return [ + 'from' => $from, + 'limit' => $limit, + 'end' => $end, + 'percent' => $percent, + ]; + } + + public function actionOrders() + { + $dataProvider = new ActiveDataProvider( + [ + 'query' => Order::find() + ->with('odooToOrder'), + ] + ); + return $this->render( + 'orders', + [ + 'dataProvider' => $dataProvider, + ] + ); + } + + public function actionSendOrders($ids) + { + $response = \Yii::$app->response; + $response->format = $response::FORMAT_JSON; + $ids = Json::decode($ids); + $counter = 0; + if (!empty($ids)) { + /** + * @var \artbox\odoo\components\Connection $odoo + */ + $helper = new OdooHelper(); + /** + * @var Order[] $orders + */ + $orders = Order::find() + ->where([ 'id' => $ids ]) + ->all(); + foreach ($orders as $order) { + $helper->ensureSaleOrderLine($order); + $counter++; + } + } + return $counter; + } + + protected function processCategory(array $category): Category + { + $categoryId = $category[ 0 ]; + $categoryName = $category[ 1 ]; + /** + * @var Category $category + */ + $category = Category::find() + ->innerJoin('odoo_to_category', 'category.id = odoo_to_category.category_id') + ->where( + [ + 'remote_id' => $categoryId, + ] + ) + ->with('categoryLangs') + ->one(); + if ($category) { + foreach ($category->categoryLangs as $categoryLang) { + $categoryLang->title = $categoryName; + $categoryLang->save(); + } + return $category; + } else { + $category = new Category( + [ + 'status' => true, + ] + ); + $category->generateLangs(); + foreach ($category->modelLangs as $categoryLang) { + $categoryLang->title = $categoryName; + } + $category->saveWithLangs(); + $categoryLink = new OdooToCategory( + [ + 'category_id' => $category->id, + 'remote_id' => $categoryId, + ] + ); + $categoryLink->save(); + return $category; + } + } + + /** + * @param array $artbox + * @param Category|null $category + * + * @return \artbox\catalog\models\Product + */ + protected function processProduct(array $artbox, $category = null): Product + { + $productModel = Product::find() + ->innerJoin('odoo_to_product', 'product.id = odoo_to_product.product_id') + ->where( + [ + 'remote_id' => $artbox[ 'remote_id' ], + ] + ) + ->with('productLangs') + ->with('variant') + ->one(); + if ($productModel) { + foreach ($productModel->productLangs as $productLang) { + $productLang->load($artbox, ''); + $productLang->save(); + } + $productModel->load($artbox, ''); + $productModel->save(); + $productModel->variant->load($artbox, ''); + $productModel->variant->save(); + } else { + $productModel = new Product(); + $productModel->detachBehavior('defaultVariant'); + $productModel->load($artbox, ''); + $productModel->generateLangs(); + foreach ($productModel->modelLangs as $productLang) { + $productLang->load($artbox, ''); + } + $productModel->saveWithLangs(); + $productLink = new OdooToProduct( + [ + 'product_id' => $productModel->id, + 'remote_id' => $artbox[ 'remote_id' ], + ] + ); + $productLink->save(); + $variant = new Variant(); + $variant->product_id = $productModel->id; + $variant->load($artbox, ''); + $lang = $productModel->modelLangs[ array_keys($productModel->modelLangs)[ 0 ] ]; + if (empty($variant->sku)) { + $variant->sku = $lang->title; + } + $variant->generateLangs(); + foreach ($variant->modelLangs as $variantLang) { + $variantLang->title = $lang->title; + } + $variant->saveWithLangs(); + } + if (!empty($category)) { + new ProductToCategory( + [ + 'category_id' => $category->id, + 'product_id' => $productModel->id, + ] + ); + } + return $productModel; + } + + /** + * @param array $artbox + * @param array $products + * + * @return \artbox\catalog\models\Product|\artbox\odoo\models\Order + * @internal param \artbox\catalog\models\Category|null $category + */ + protected function processOrder(array $artbox, array $products): Order + { + $orderModel = Order::find() + ->innerJoin('odoo_to_order', '[[order]].id = odoo_to_order.order_id') + ->where( + [ + 'remote_id' => $artbox[ 'remote_id' ], + ] + ) + ->one(); + if ($orderModel) { + $orderModel->load($artbox, ''); + $orderModel->save(); + } else { + $orderModel = new Order( + [ + 'label_id' => 1, + 'payment_id' => 1, + 'delivery_id' => 1, + ] + ); + $orderModel->load($artbox, ''); + $orderModel->save(); + ( new OdooToOrder( + [ + 'order_id' => $orderModel->id, + 'remote_id' => $artbox[ 'remote_id' ], + ] + ) )->save(); + } + $orderModel->unlinkAll('orderProducts', true); + foreach ($products as $product) { + $productModel = $this->processProduct($product); + $orderProduct = new OrderProduct( + [ + 'order_id' => $orderModel->id, + 'sku' => $productModel->variant->sku, + ] + ); + $orderProduct->load($product, ''); + $orderProduct->variant_id = $productModel->variant->id; + $orderProduct->save(); + } + return $orderModel; + } + } \ No newline at end of file diff --git a/instruction b/instruction new file mode 100644 index 0000000..4d914f4 --- /dev/null +++ b/instruction @@ -0,0 +1,95 @@ +Установка: +1. Добавляем в компоненты: +'odooMapper' => [ + 'class' => OdooMapper::className(), + 'map' => [ + 'id' => 'remote_id', + 'active' => 'status', + 'create_date' => [ + 'attribute' => 'created_at', + 'artbox' => function ($field) { + return strtotime($field); + }, + 'odoo' => function ($field) { + return date('Y-m-d H:i:s', $field); + }, + ], + '__last_update' => [ + 'attribute' => 'updated_at', + 'artbox' => function ($field) { + return strtotime($field); + }, + 'odoo' => function ($field) { + return date('Y-m-d H:i:s', $field); + }, + ], + 'name' => 'title', + 'default_code' => 'sku', + 'list_price' => 'price', + 'product_id' => [ + 'attribute' => 'variant_id', + 'artbox' => function ($field) { + return $field[ 0 ]; + }, + 'odoo' => function ($field) { + return [ $field ]; + }, + ], + 'price_unit' => 'price', + 'product_uom_qty' => 'count', + 'categ_id' => 'category', + 'contact_address' => [ + 'attribute' => 'address', + 'artbox' => function ($field) { + return strval($field); + }, + 'odoo' => function ($field) { + return boolval($field); + }, + ], + 'phone' => [ + 'attribute' => 'phone', + 'artbox' => function ($field) { + return strval($field); + }, + 'odoo' => function ($field) { + return boolval($field); + }, + ], + 'email' => [ + 'attribute' => 'email', + 'artbox' => function ($field) { + return strval($field); + }, + 'odoo' => function ($field) { + return boolval($field); + }, + ], + 'city' => [ + 'attribute' => 'city', + 'artbox' => function ($field) { + return strval($field); + }, + 'odoo' => function ($field) { + return boolval($field); + }, + ], + 'comment' => [ + 'attribute' => 'comment', + 'artbox' => function ($field) { + return strval($field); + }, + 'odoo' => function ($field) { + return boolval($field); + }, + ], + ], +], +2. Добавляем в компоненты доступы: +'odoo' => [ + 'class' => Connection::className(), + 'url' => 'https://demo.cloudbank.biz', + 'db' => 'odoo', + 'username' => 'admin', + 'password' => 'htcge,kbrf', +] \ No newline at end of file diff --git a/messages/en/odoo.php b/messages/en/odoo.php new file mode 100755 index 0000000..e3b6834 --- /dev/null +++ b/messages/en/odoo.php @@ -0,0 +1,2 @@ + 'Количество', + 'Products' => 'Товары', + 'Products loaded from Odoo' => 'Товары загруженные с Odoo', + 'Orders' => 'Заказы', + 'Orders loaded from Odoo' => 'Заказы загруженные с Odoo', + 'Categories' => 'Категории', + 'Categories loaded from Odoo' => 'Категории загруженные с Odoo', + ]; \ No newline at end of file diff --git a/migrations/m170615_080920_odoo_remote_tables.php b/migrations/m170615_080920_odoo_remote_tables.php new file mode 100644 index 0000000..0e5bac1 --- /dev/null +++ b/migrations/m170615_080920_odoo_remote_tables.php @@ -0,0 +1,137 @@ +createTable( + 'odoo_to_product', + [ + 'product_id' => $this->integer() + ->notNull(), + 'remote_id' => $this->integer() + ->notNull(), + ] + ); + + $this->createIndex( + 'odoo_to_product_uindex', + 'odoo_to_product', + [ + 'product_id', + 'remote_id', + ], + true + ); + + $this->addForeignKey( + 'odoo_to_product_product_fkey', + 'odoo_to_product', + 'product_id', + 'product', + 'id', + 'CASCADE', + 'CASCADE' + ); + + $this->createTable( + 'odoo_to_order', + [ + 'order_id' => $this->integer() + ->notNull(), + 'remote_id' => $this->integer() + ->notNull(), + ] + ); + + $this->createIndex( + 'odoo_to_order_uindex', + 'odoo_to_order', + [ + 'order_id', + 'remote_id', + ], + true + ); + + $this->addForeignKey( + 'odoo_to_order_order_fkey', + 'odoo_to_order', + 'order_id', + 'order', + 'id', + 'CASCADE', + 'CASCADE' + ); + + $this->createTable( + 'odoo_to_category', + [ + 'category_id' => $this->integer() + ->notNull(), + 'remote_id' => $this->integer() + ->notNull(), + ] + ); + + $this->createIndex( + 'odoo_to_category_uindex', + 'odoo_to_category', + [ + 'category_id', + 'remote_id', + ], + true + ); + + $this->addForeignKey( + 'odoo_to_category_category_fkey', + 'odoo_to_category', + 'category_id', + 'category', + 'id', + 'CASCADE', + 'CASCADE' + ); + + $this->createTable( + 'odoo_to_customer', + [ + 'customer_id' => $this->integer() + ->notNull(), + 'remote_id' => $this->integer() + ->notNull(), + ] + ); + + $this->createIndex( + 'odoo_to_customer_uindex', + 'odoo_to_customer', + [ + 'customer_id', + 'remote_id', + ], + true + ); + + $this->addForeignKey( + 'odoo_to_customer_customer_fkey', + 'odoo_to_customer', + 'customer_id', + 'customer', + 'id', + 'CASCADE', + 'CASCADE' + ); + } + + public function safeDown() + { + $this->dropTable('odoo_to_order'); + $this->dropTable('odoo_to_product'); + $this->dropTable('odoo_to_category'); + $this->dropTable('odoo_to_customer'); + } + } diff --git a/models/OdooToCategory.php b/models/OdooToCategory.php new file mode 100644 index 0000000..03b28df --- /dev/null +++ b/models/OdooToCategory.php @@ -0,0 +1,97 @@ + [ + 'category_id', + 'remote_id', + ], + 'message' => 'The combination of Category ID and Remote ID has already been taken.', + ], + [ + [ 'category_id' ], + 'exist', + 'skipOnError' => true, + 'targetClass' => Category::className(), + 'targetAttribute' => [ 'category_id' => 'id' ], + ], + ]; + } + + /** + * @inheritdoc + */ + public function attributeLabels() + { + return [ + 'category_id' => Yii::t('odoo', 'Category ID'), + 'remote_id' => Yii::t('odoo', 'Remote ID'), + ]; + } + + /** + * @return \yii\db\ActiveQuery + */ + public function getCategory() + { + return $this->hasOne(Category::className(), [ 'id' => 'category_id' ]); + } + } diff --git a/models/OdooToCustomer.php b/models/OdooToCustomer.php new file mode 100644 index 0000000..3e4034e --- /dev/null +++ b/models/OdooToCustomer.php @@ -0,0 +1,98 @@ + [ + 'customer_id', + 'remote_id', + ], + 'message' => 'The combination of Customer ID and Remote ID has already been taken.', + ], + [ + [ 'customer_id' ], + 'exist', + 'skipOnError' => true, + 'targetClass' => Customer::className(), + 'targetAttribute' => [ 'customer_id' => 'id' ], + ], + ]; + } + + /** + * @inheritdoc + */ + public function attributeLabels() + { + return [ + 'customer_id' => Yii::t('odoo', 'Customer ID'), + 'remote_id' => Yii::t('odoo', 'Remote ID'), + ]; + } + + /** + * @return \yii\db\ActiveQuery + */ + public function getCustomer() + { + return $this->hasOne(Customer::className(), [ 'id' => 'customer_id' ]); + } + } diff --git a/models/OdooToOrder.php b/models/OdooToOrder.php new file mode 100644 index 0000000..9cc8509 --- /dev/null +++ b/models/OdooToOrder.php @@ -0,0 +1,97 @@ + [ + 'order_id', + 'remote_id', + ], + 'message' => 'The combination of Order ID and Remote ID has already been taken.', + ], + [ + [ 'order_id' ], + 'exist', + 'skipOnError' => true, + 'targetClass' => Order::className(), + 'targetAttribute' => [ 'order_id' => 'id' ], + ], + ]; + } + + /** + * @inheritdoc + */ + public function attributeLabels() + { + return [ + 'order_id' => Yii::t('odoo', 'Order ID'), + 'remote_id' => Yii::t('odoo', 'Remote ID'), + ]; + } + + /** + * @return \yii\db\ActiveQuery + */ + public function getOrder() + { + return $this->hasOne(Order::className(), [ 'id' => 'order_id' ]) + ->inverseOf('odooToOrder'); + } + } diff --git a/models/OdooToProduct.php b/models/OdooToProduct.php new file mode 100644 index 0000000..6ac3e28 --- /dev/null +++ b/models/OdooToProduct.php @@ -0,0 +1,97 @@ + [ + 'product_id', + 'remote_id', + ], + 'message' => 'The combination of Product ID and Remote ID has already been taken.', + ], + [ + [ 'product_id' ], + 'exist', + 'skipOnError' => true, + 'targetClass' => Product::className(), + 'targetAttribute' => [ 'product_id' => 'id' ], + ], + ]; + } + + /** + * @inheritdoc + */ + public function attributeLabels() + { + return [ + 'product_id' => Yii::t('odoo', 'Product ID'), + 'remote_id' => Yii::t('odoo', 'Remote ID'), + ]; + } + + /** + * @return \yii\db\ActiveQuery + */ + public function getProduct() + { + return $this->hasOne(Product::className(), [ 'id' => 'product_id' ]); + } + } diff --git a/models/Order.php b/models/Order.php new file mode 100644 index 0000000..da3d502 --- /dev/null +++ b/models/Order.php @@ -0,0 +1,20 @@ +hasOne(OdooToOrder::className(), [ 'order_id' => 'id' ]) + ->inverseOf('order'); + } + } diff --git a/views/odoo/import.php b/views/odoo/import.php new file mode 100644 index 0000000..720d429 --- /dev/null +++ b/views/odoo/import.php @@ -0,0 +1,59 @@ +title = \Yii::t('odoo', 'Odoo import'); + $this->params[ 'breadcrumbs' ][] = $this->title; +?> +