From cfd25c425bf74ff8f5d223ea79dffd71bc07444d Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Sat, 7 Jul 2018 12:43:35 -0700 Subject: [PATCH] Initial commit of `connector_walmart` for Odoo 11.0 (using beta version of `connector_ecommerce`) --- connector_walmart/__init__.py | 2 + connector_walmart/__manifest__.py | 29 ++ connector_walmart/components/__init__.py | 6 + connector_walmart/components/api/__init__.py | 1 + connector_walmart/components/api/walmart.py | 407 ++++++++++++++++++ .../components/backend_adapter.py | 67 +++ connector_walmart/components/binder.py | 22 + connector_walmart/components/exporter.py | 313 ++++++++++++++ connector_walmart/components/importer.py | 324 ++++++++++++++ connector_walmart/components/mapper.py | 16 + .../data/connector_walmart_data.xml | 54 +++ connector_walmart/models/__init__.py | 6 + connector_walmart/models/account/__init__.py | 1 + .../models/account/account_fiscal_position.py | 68 +++ connector_walmart/models/delivery/__init__.py | 1 + connector_walmart/models/delivery/common.py | 52 +++ .../models/sale_order/__init__.py | 2 + connector_walmart/models/sale_order/common.py | 209 +++++++++ .../models/sale_order/importer.py | 347 +++++++++++++++ .../models/stock_picking/__init__.py | 2 + .../models/stock_picking/common.py | 95 ++++ .../models/stock_picking/exporter.py | 78 ++++ .../models/walmart_backend/__init__.py | 1 + .../models/walmart_backend/common.py | 128 ++++++ .../models/walmart_binding/__init__.py | 1 + .../models/walmart_binding/common.py | 66 +++ .../security/ir.model.access.csv | 14 + connector_walmart/views/account_views.xml | 15 + .../views/connector_walmart_menu.xml | 15 + connector_walmart/views/delivery_views.xml | 20 + connector_walmart/views/sale_order_views.xml | 53 +++ .../views/walmart_backend_views.xml | 101 +++++ external/hibou-oca/connector-ecommerce | 2 +- external/hibou-oca/sale-workflow | 2 +- 34 files changed, 2518 insertions(+), 2 deletions(-) create mode 100644 connector_walmart/__init__.py create mode 100644 connector_walmart/__manifest__.py create mode 100644 connector_walmart/components/__init__.py create mode 100644 connector_walmart/components/api/__init__.py create mode 100644 connector_walmart/components/api/walmart.py create mode 100644 connector_walmart/components/backend_adapter.py create mode 100644 connector_walmart/components/binder.py create mode 100644 connector_walmart/components/exporter.py create mode 100644 connector_walmart/components/importer.py create mode 100644 connector_walmart/components/mapper.py create mode 100644 connector_walmart/data/connector_walmart_data.xml create mode 100644 connector_walmart/models/__init__.py create mode 100644 connector_walmart/models/account/__init__.py create mode 100644 connector_walmart/models/account/account_fiscal_position.py create mode 100644 connector_walmart/models/delivery/__init__.py create mode 100644 connector_walmart/models/delivery/common.py create mode 100644 connector_walmart/models/sale_order/__init__.py create mode 100644 connector_walmart/models/sale_order/common.py create mode 100644 connector_walmart/models/sale_order/importer.py create mode 100644 connector_walmart/models/stock_picking/__init__.py create mode 100644 connector_walmart/models/stock_picking/common.py create mode 100644 connector_walmart/models/stock_picking/exporter.py create mode 100644 connector_walmart/models/walmart_backend/__init__.py create mode 100644 connector_walmart/models/walmart_backend/common.py create mode 100644 connector_walmart/models/walmart_binding/__init__.py create mode 100644 connector_walmart/models/walmart_binding/common.py create mode 100644 connector_walmart/security/ir.model.access.csv create mode 100644 connector_walmart/views/account_views.xml create mode 100644 connector_walmart/views/connector_walmart_menu.xml create mode 100644 connector_walmart/views/delivery_views.xml create mode 100644 connector_walmart/views/sale_order_views.xml create mode 100644 connector_walmart/views/walmart_backend_views.xml diff --git a/connector_walmart/__init__.py b/connector_walmart/__init__.py new file mode 100644 index 00000000..f24d3e24 --- /dev/null +++ b/connector_walmart/__init__.py @@ -0,0 +1,2 @@ +from . import components +from . import models diff --git a/connector_walmart/__manifest__.py b/connector_walmart/__manifest__.py new file mode 100644 index 00000000..e20bd78e --- /dev/null +++ b/connector_walmart/__manifest__.py @@ -0,0 +1,29 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + 'name': 'Walmart Connector', + 'version': '11.0.1.0.0', + 'category': 'Connector', + 'depends': [ + 'account', + 'product', + 'delivery', + 'sale_stock', + 'connector_ecommerce', + ], + 'author': "Hibou Corp.", + 'license': 'AGPL-3', + 'website': 'https://hibou.io', + 'data': [ + 'views/walmart_backend_views.xml', + 'views/connector_walmart_menu.xml', + 'views/sale_order_views.xml', + 'views/account_views.xml', + 'views/delivery_views.xml', + 'security/ir.model.access.csv', + 'data/connector_walmart_data.xml', + ], + 'installable': True, + 'application': False, +} diff --git a/connector_walmart/components/__init__.py b/connector_walmart/components/__init__.py new file mode 100644 index 00000000..ad96af21 --- /dev/null +++ b/connector_walmart/components/__init__.py @@ -0,0 +1,6 @@ +from . import api +from . import backend_adapter +from . import binder +from . import importer +from . import exporter +from . import mapper diff --git a/connector_walmart/components/api/__init__.py b/connector_walmart/components/api/__init__.py new file mode 100644 index 00000000..ff014f05 --- /dev/null +++ b/connector_walmart/components/api/__init__.py @@ -0,0 +1 @@ +from . import walmart diff --git a/connector_walmart/components/api/walmart.py b/connector_walmart/components/api/walmart.py new file mode 100644 index 00000000..dfcf41d8 --- /dev/null +++ b/connector_walmart/components/api/walmart.py @@ -0,0 +1,407 @@ +# -*- coding: utf-8 -*- + +# BSD License +# +# Copyright (c) 2016, Fulfil.IO Inc. +# 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 Fulfil 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 HOLDER 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. + +# © 2017 Hibou Corp. - Extended and converted to v3/JSON + + +import requests +import base64 +import time +from uuid import uuid4 +# from lxml import etree +# from lxml.builder import E, ElementMaker +from json import dumps, loads + +from Crypto.Hash import SHA256 +from Crypto.PublicKey import RSA +from Crypto.Signature import PKCS1_v1_5 + +try: + from urllib.parse import urlencode +except ImportError: + from urllib import urlencode + + +class Walmart(object): + + def __init__(self, consumer_id, channel_type, private_key): + self.base_url = 'https://marketplace.walmartapis.com/v3/%s' + self.consumer_id = consumer_id + self.channel_type = channel_type + self.private_key = private_key + self.session = requests.Session() + self.session.headers['Accept'] = 'application/json' + self.session.headers['WM_SVC.NAME'] = 'Walmart Marketplace' + self.session.headers['WM_CONSUMER.ID'] = self.consumer_id + self.session.headers['WM_CONSUMER.CHANNEL.TYPE'] = self.channel_type + + @property + def items(self): + return Items(connection=self) + + @property + def inventory(self): + return Inventory(connection=self) + + @property + def prices(self): + return Prices(connection=self) + + @property + def orders(self): + return Orders(connection=self) + + def get_sign(self, url, method, timestamp): + return self.sign_data( + '\n'.join([self.consumer_id, url, method, timestamp]) + '\n' + ) + + def sign_data(self, data): + rsakey = RSA.importKey(base64.b64decode(self.private_key)) + signer = PKCS1_v1_5.new(rsakey) + digest = SHA256.new() + digest.update(data.encode('utf-8')) + sign = signer.sign(digest) + return base64.b64encode(sign) + + def get_headers(self, url, method): + timestamp = str(int(round(time.time() * 1000))) + headers = { + 'WM_SEC.AUTH_SIGNATURE': self.get_sign(url, method, timestamp), + 'WM_SEC.TIMESTAMP': timestamp, + 'WM_QOS.CORRELATION_ID': str(uuid4()), + } + if method in ('POST', ): + headers['Content-Type'] = 'application/json' + return headers + + def send_request(self, method, url, params=None, body=None): + encoded_url = url + if params: + encoded_url += '?%s' % urlencode(params) + headers = self.get_headers(encoded_url, method) + + if method == 'GET': + return loads(self.session.get(url, params=params, headers=headers).text) + elif method == 'PUT': + return loads(self.session.put(url, params=params, headers=headers).text) + elif method == 'POST': + return loads(self.session.post(url, data=body, headers=headers).text) + + +class Resource(object): + """ + A base class for all Resources to extend + """ + + def __init__(self, connection): + self.connection = connection + + @property + def url(self): + return self.connection.base_url % self.path + + def all(self, **kwargs): + return self.connection.send_request( + method='GET', url=self.url, params=kwargs) + + def get(self, id): + url = self.url + '/%s' % id + return self.connection.send_request(method='GET', url=url) + + def update(self, **kwargs): + return self.connection.send_request( + method='PUT', url=self.url, params=kwargs) + + # def bulk_update(self, items): + # url = self.connection.base_url % 'feeds?feedType=%s' % self.feedType + # return self.connection.send_request( + # method='POST', url=url, data=self.get_payload(items)) + + +class Items(Resource): + """ + Get all items + """ + + path = 'items' + + +class Inventory(Resource): + """ + Retreives inventory of an item + """ + + path = 'inventory' + feedType = 'inventory' + + def get_payload(self, items): + return etree.tostring( + E.InventoryFeed( + E.InventoryHeader(E('version', '1.4')), + *[E( + 'inventory', + E('sku', item['sku']), + E( + 'quantity', + E('unit', 'EACH'), + E('amount', item['quantity']), + ) + ) for item in items], + xmlns='http://walmart.com/' + ) + ) + + +class Prices(Resource): + """ + Retreives price of an item + """ + + path = 'prices' + feedType = 'price' + + def get_payload(self, items): + # root = ElementMaker( + # nsmap={'gmp': 'http://walmart.com/'} + # ) + # return etree.tostring( + # root.PriceFeed( + # E.PriceHeader(E('version', '1.5')), + # *[E.Price( + # E( + # 'itemIdentifier', + # E('sku', item['sku']) + # ), + # E( + # 'pricingList', + # E( + # 'pricing', + # E( + # 'currentPrice', + # E( + # 'value', + # **{ + # 'currency': item['currenctCurrency'], + # 'amount': item['currenctPrice'] + # } + # ) + # ), + # E('currentPriceType', item['priceType']), + # E( + # 'comparisonPrice', + # E( + # 'value', + # **{ + # 'currency': item['comparisonCurrency'], + # 'amount': item['comparisonPrice'] + # } + # ) + # ), + # E( + # 'priceDisplayCode', + # **{ + # 'submapType': item['displayCode'] + # } + # ), + # ) + # ) + # ) for item in items] + # ), xml_declaration=True, encoding='utf-8' + # ) + payload = {} + return + + +class Orders(Resource): + """ + Retrieves Order details + """ + + path = 'orders' + + def all(self, **kwargs): + next_cursor = kwargs.pop('nextCursor', '') + return self.connection.send_request( + method='GET', url=self.url + next_cursor, params=kwargs) + + def released(self, **kwargs): + next_cursor = kwargs.pop('nextCursor', '') + url = self.url + '/released' + return self.connection.send_request( + method='GET', url=url + next_cursor, params=kwargs) + + def acknowledge(self, id): + url = self.url + '/%s/acknowledge' % id + return self.connection.send_request(method='POST', url=url) + + def cancel(self, id, lines): + url = self.url + '/%s/cancel' % id + return self.connection.send_request( + method='POST', url=url, body=self.get_cancel_payload(lines)) + + def get_cancel_payload(self, lines): + """ + { + "orderCancellation": { + "orderLines": { + "orderLine": [ + { + "lineNumber": "1", + "orderLineStatuses": { + "orderLineStatus": [ + { + "status": "Cancelled", + "cancellationReason": "CUSTOMER_REQUESTED_SELLER_TO_CANCEL", + "statusQuantity": { + "unitOfMeasurement": "EA", + "amount": "1" + } + } + ] + } + } + ] + } + } + } + :param lines: + :return: string + """ + payload = { + 'orderCancellation': { + 'orderLines': [{ + 'lineNumber': line['number'], + 'orderLineStatuses': { + 'orderLineStatus': [ + { + 'status': 'Cancelled', + 'cancellationReason': 'CUSTOMER_REQUESTED_SELLER_TO_CANCEL', + 'statusQuantity': { + 'unitOfMeasurement': 'EA', + 'amount': line['amount'], + } + } + ] + } + } for line in lines] + } + } + return dumps(payload) + + + + def ship(self, id, lines): + url = self.url + '/%s/shipping' % id + return self.connection.send_request( + method='POST', + url=url, + body=self.get_ship_payload(lines) + ) + + def get_ship_payload(self, lines): + """ + + :param lines: list[ dict(number, amount, carrier, methodCode, trackingNumber, trackingUrl) ] + :return: + """ + """ + { + "orderShipment": { + "orderLines": { + "orderLine": [ + { + "lineNumber": "1", + "orderLineStatuses": { + "orderLineStatus": [ + { + "status": "Shipped", + "statusQuantity": { + "unitOfMeasurement": "EA", + "amount": "1" + }, + "trackingInfo": { + "shipDateTime": 1488480443000, + "carrierName": { + "otherCarrier": null, + "carrier": "UPS" + }, + "methodCode": "Express", + "trackingNumber": "12345", + "trackingURL": "www.fedex.com" + } + } + ] + } + } + ] + } + } + } + :param lines: + :return: + """ + + payload = { + "orderShipment": { + "orderLines": { + "orderLine": [ + { + "lineNumber": str(line['number']), + "orderLineStatuses": { + "orderLineStatus": [ + { + "status": "Shipped", + "statusQuantity": { + "unitOfMeasurement": "EA", + "amount": str(line['amount']) + }, + "trackingInfo": { + "shipDateTime": line['shipDateTime'], + "carrierName": { + "otherCarrier": None, + "carrier": line['carrier'] + }, + "methodCode": line['methodCode'], + "trackingNumber": line['trackingNumber'], + "trackingURL": line['trackingUrl'] + } + } + ] + } + } + for line in lines] + } + } + } + + return dumps(payload) \ No newline at end of file diff --git a/connector_walmart/components/backend_adapter.py b/connector_walmart/components/backend_adapter.py new file mode 100644 index 00000000..0ab00aee --- /dev/null +++ b/connector_walmart/components/backend_adapter.py @@ -0,0 +1,67 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import AbstractComponent +from odoo.addons.queue_job.exception import RetryableJobError +from odoo.addons.connector.exception import NetworkRetryableError +from .api.walmart import Walmart +from logging import getLogger +from lxml import etree + +_logger = getLogger(__name__) + + +class BaseWalmartConnectorComponent(AbstractComponent): + """ Base Walmart Connector Component + + All components of this connector should inherit from it. + """ + _name = 'base.walmart.connector' + _inherit = 'base.connector' + _collection = 'walmart.backend' + + +class WalmartAdapter(AbstractComponent): + + _name = 'walmart.adapter' + _inherit = ['base.backend.adapter', 'base.walmart.connector'] + + _walmart_model = None + + def search(self, filters=None): + """ Search records according to some criterias + and returns a list of ids """ + raise NotImplementedError + + def read(self, id, attributes=None): + """ Returns the information of a record """ + raise NotImplementedError + + def search_read(self, filters=None): + """ Search records according to some criterias + and returns their information""" + raise NotImplementedError + + def create(self, data): + """ Create a record on the external system """ + raise NotImplementedError + + def write(self, id, data): + """ Update records on the external system """ + raise NotImplementedError + + def delete(self, id): + """ Delete a record on the external system """ + raise NotImplementedError + + @property + def api_instance(self): + try: + walmart_api = getattr(self.work, 'walmart_api') + except AttributeError: + raise AttributeError( + 'You must provide a walmart_api attribute with a ' + 'Walmart instance to be able to use the ' + 'Backend Adapter.' + ) + return walmart_api diff --git a/connector_walmart/components/binder.py b/connector_walmart/components/binder.py new file mode 100644 index 00000000..21d949ff --- /dev/null +++ b/connector_walmart/components/binder.py @@ -0,0 +1,22 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class WalmartModelBinder(Component): + """ Bind records and give odoo/walmart ids correspondence + + Binding models are models called ``walmart.{normal_model}``, + like ``walmart.sale.order`` or ``walmart.product.product``. + They are ``_inherits`` of the normal models and contains + the Walmart ID, the ID of the Walmart Backend and the additional + fields belonging to the Walmart instance. + """ + _name = 'walmart.binder' + _inherit = ['base.binder', 'base.walmart.connector'] + _apply_on = [ + 'walmart.sale.order', + 'walmart.sale.order.line', + 'walmart.stock.picking', + ] diff --git a/connector_walmart/components/exporter.py b/connector_walmart/components/exporter.py new file mode 100644 index 00000000..5b305f69 --- /dev/null +++ b/connector_walmart/components/exporter.py @@ -0,0 +1,313 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from contextlib import contextmanager +from datetime import datetime + +import psycopg2 + +import odoo +from odoo import _ +from odoo.addons.component.core import AbstractComponent +from odoo.addons.connector.exception import (IDMissingInBackend, + RetryableJobError) + +_logger = logging.getLogger(__name__) + + + + +class WalmartBaseExporter(AbstractComponent): + """ Base exporter for Walmart """ + + _name = 'walmart.base.exporter' + _inherit = ['base.exporter', 'base.walmart.connector'] + _usage = 'record.exporter' + + def __init__(self, working_context): + super(WalmartBaseExporter, self).__init__(working_context) + self.binding = None + self.external_id = None + + def run(self, binding, *args, **kwargs): + """ Run the synchronization + + :param binding: binding record to export + """ + self.binding = binding + self.external_id = self.binder.to_external(self.binding) + + result = self._run(*args, **kwargs) + + self.binder.bind(self.external_id, self.binding) + # Commit so we keep the external ID when there are several + # exports (due to dependencies) and one of them fails. + # The commit will also release the lock acquired on the binding + # record + if not odoo.tools.config['test_enable']: + self.env.cr.commit() # noqa + + self._after_export() + return result + + def _run(self): + """ Flow of the synchronization, implemented in inherited classes""" + raise NotImplementedError + + def _after_export(self): + """ Can do several actions after exporting a record to Walmart """ + pass + + +class WalmartExporter(AbstractComponent): + """ A common flow for the exports to Walmart """ + + _name = 'walmart.exporter' + _inherit = 'walmart.base.exporter' + + def __init__(self, working_context): + super(WalmartExporter, self).__init__(working_context) + self.binding = None + + def _lock(self): + """ Lock the binding record. + + Lock the binding record so we are sure that only one export + job is running for this record if concurrent jobs have to export the + same record. + + When concurrent jobs try to export the same record, the first one + will lock and proceed, the others will fail to lock and will be + retried later. + + This behavior works also when the export becomes multilevel + with :meth:`_export_dependencies`. Each level will set its own lock + on the binding record it has to export. + + """ + sql = ("SELECT id FROM %s WHERE ID = %%s FOR UPDATE NOWAIT" % + self.model._table) + try: + self.env.cr.execute(sql, (self.binding.id, ), + log_exceptions=False) + except psycopg2.OperationalError: + _logger.info('A concurrent job is already exporting the same ' + 'record (%s with id %s). Job delayed later.', + self.model._name, self.binding.id) + raise RetryableJobError( + 'A concurrent job is already exporting the same record ' + '(%s with id %s). The job will be retried later.' % + (self.model._name, self.binding.id)) + + def _has_to_skip(self): + """ Return True if the export can be skipped """ + return False + + @contextmanager + def _retry_unique_violation(self): + """ Context manager: catch Unique constraint error and retry the + job later. + + When we execute several jobs workers concurrently, it happens + that 2 jobs are creating the same record at the same time (binding + record created by :meth:`_export_dependency`), resulting in: + + IntegrityError: duplicate key value violates unique + constraint "walmart_product_product_odoo_uniq" + DETAIL: Key (backend_id, odoo_id)=(1, 4851) already exists. + + In that case, we'll retry the import just later. + + .. warning:: The unique constraint must be created on the + binding record to prevent 2 bindings to be created + for the same Walmart record. + + """ + try: + yield + except psycopg2.IntegrityError as err: + if err.pgcode == psycopg2.errorcodes.UNIQUE_VIOLATION: + raise RetryableJobError( + 'A database error caused the failure of the job:\n' + '%s\n\n' + 'Likely due to 2 concurrent jobs wanting to create ' + 'the same record. The job will be retried later.' % err) + else: + raise + + def _export_dependency(self, relation, binding_model, + component_usage='record.exporter', + binding_field='walmart_bind_ids', + binding_extra_vals=None): + """ + Export a dependency. The exporter class is a subclass of + ``WalmartExporter``. If a more precise class need to be defined, + it can be passed to the ``exporter_class`` keyword argument. + + .. warning:: a commit is done at the end of the export of each + dependency. The reason for that is that we pushed a record + on the backend and we absolutely have to keep its ID. + + So you *must* take care not to modify the Odoo + database during an export, excepted when writing + back the external ID or eventually to store + external data that we have to keep on this side. + + You should call this method only at the beginning + of the exporter synchronization, + in :meth:`~._export_dependencies`. + + :param relation: record to export if not already exported + :type relation: :py:class:`odoo.models.BaseModel` + :param binding_model: name of the binding model for the relation + :type binding_model: str | unicode + :param component_usage: 'usage' to look for to find the Component to + for the export, by default 'record.exporter' + :type exporter: str | unicode + :param binding_field: name of the one2many field on a normal + record that points to the binding record + (default: walmart_bind_ids). + It is used only when the relation is not + a binding but is a normal record. + :type binding_field: str | unicode + :binding_extra_vals: In case we want to create a new binding + pass extra values for this binding + :type binding_extra_vals: dict + """ + if not relation: + return + rel_binder = self.binder_for(binding_model) + # wrap is typically True if the relation is for instance a + # 'product.product' record but the binding model is + # 'walmart.product.product' + wrap = relation._name != binding_model + + if wrap and hasattr(relation, binding_field): + domain = [('odoo_id', '=', relation.id), + ('backend_id', '=', self.backend_record.id)] + binding = self.env[binding_model].search(domain) + if binding: + assert len(binding) == 1, ( + 'only 1 binding for a backend is ' + 'supported in _export_dependency') + # we are working with a unwrapped record (e.g. + # product.category) and the binding does not exist yet. + # Example: I created a product.product and its binding + # walmart.product.product and we are exporting it, but we need to + # create the binding for the product.category on which it + # depends. + else: + bind_values = {'backend_id': self.backend_record.id, + 'odoo_id': relation.id} + if binding_extra_vals: + bind_values.update(binding_extra_vals) + # If 2 jobs create it at the same time, retry + # one later. A unique constraint (backend_id, + # odoo_id) should exist on the binding model + with self._retry_unique_violation(): + binding = (self.env[binding_model] + .with_context(connector_no_export=True) + .sudo() + .create(bind_values)) + # Eager commit to avoid having 2 jobs + # exporting at the same time. The constraint + # will pop if an other job already created + # the same binding. It will be caught and + # raise a RetryableJobError. + if not odoo.tools.config['test_enable']: + self.env.cr.commit() # noqa + else: + # If walmart_bind_ids does not exist we are typically in a + # "direct" binding (the binding record is the same record). + # If wrap is True, relation is already a binding record. + binding = relation + + if not rel_binder.to_external(binding): + exporter = self.component(usage=component_usage, + model_name=binding_model) + exporter.run(binding) + + def _export_dependencies(self): + """ Export the dependencies for the record""" + return + + def _map_data(self): + """ Returns an instance of + :py:class:`~odoo.addons.connector.components.mapper.MapRecord` + + """ + return self.mapper.map_record(self.binding) + + def _validate_create_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``Model.create`` if some fields + are missing or invalid + + Raise `InvalidDataError` + """ + return + + def _validate_update_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``Model.update`` if some fields + are missing or invalid + + Raise `InvalidDataError` + """ + return + + def _create_data(self, map_record, fields=None, **kwargs): + """ Get the data to pass to :py:meth:`_create` """ + return map_record.values(for_create=True, fields=fields, **kwargs) + + def _create(self, data): + """ Create the Walmart record """ + # special check on data before export + self._validate_create_data(data) + return self.backend_adapter.create(data) + + def _update_data(self, map_record, fields=None, **kwargs): + """ Get the data to pass to :py:meth:`_update` """ + return map_record.values(fields=fields, **kwargs) + + def _update(self, data): + """ Update an Walmart record """ + assert self.external_id + # special check on data before export + self._validate_update_data(data) + self.backend_adapter.write(self.external_id, data) + + def _run(self, fields=None): + """ Flow of the synchronization, implemented in inherited classes""" + assert self.binding + + if not self.external_id: + fields = None # should be created with all the fields + + if self._has_to_skip(): + return + + # export the missing linked resources + self._export_dependencies() + + # prevent other jobs to export the same record + # will be released on commit (or rollback) + self._lock() + + map_record = self._map_data() + + if self.external_id: + record = self._update_data(map_record, fields=fields) + if not record: + return _('Nothing to export.') + self._update(record) + else: + record = self._create_data(map_record, fields=fields) + if not record: + return _('Nothing to export.') + self.external_id = self._create(record) + return _('Record exported with ID %s on Walmart.') % self.external_id diff --git a/connector_walmart/components/importer.py b/connector_walmart/components/importer.py new file mode 100644 index 00000000..cb677553 --- /dev/null +++ b/connector_walmart/components/importer.py @@ -0,0 +1,324 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +""" + +Importers for Walmart. + +An import can be skipped if the last sync date is more recent than +the last update in Walmart. + +They should call the ``bind`` method if the binder even if the records +are already bound, to update the last sync date. + +""" + +import logging +from odoo import fields, _ +from odoo.addons.component.core import AbstractComponent, Component +from odoo.addons.connector.exception import IDMissingInBackend +from odoo.addons.queue_job.exception import NothingToDoJob + + +_logger = logging.getLogger(__name__) + + +class WalmartImporter(AbstractComponent): + """ Base importer for Walmart """ + + _name = 'walmart.importer' + _inherit = ['base.importer', 'base.walmart.connector'] + _usage = 'record.importer' + + def __init__(self, work_context): + super(WalmartImporter, self).__init__(work_context) + self.external_id = None + self.walmart_record = None + + def _get_walmart_data(self): + """ Return the raw Walmart data for ``self.external_id`` """ + return self.backend_adapter.read(self.external_id) + + def _before_import(self): + """ Hook called before the import, when we have the Walmart + data""" + + def _is_uptodate(self, binding): + """Return True if the import should be skipped because + it is already up-to-date in Odoo""" + assert self.walmart_record + if not self.walmart_record.get('updated_at'): + return # no update date on Walmart, always import it. + if not binding: + return # it does not exist so it should not be skipped + sync = binding.sync_date + if not sync: + return + from_string = fields.Datetime.from_string + sync_date = from_string(sync) + walmart_date = from_string(self.walmart_record['updated_at']) + # if the last synchronization date is greater than the last + # update in walmart, we skip the import. + # Important: at the beginning of the exporters flows, we have to + # check if the walmart_date is more recent than the sync_date + # and if so, schedule a new import. If we don't do that, we'll + # miss changes done in Walmart + return walmart_date < sync_date + + def _import_dependency(self, external_id, binding_model, + importer=None, always=False): + """ Import a dependency. + + The importer class is a class or subclass of + :class:`WalmartImporter`. A specific class can be defined. + + :param external_id: id of the related binding to import + :param binding_model: name of the binding model for the relation + :type binding_model: str | unicode + :param importer_component: component to use for import + By default: 'importer' + :type importer_component: Component + :param always: if True, the record is updated even if it already + exists, note that it is still skipped if it has + not been modified on Walmart since the last + update. When False, it will import it only when + it does not yet exist. + :type always: boolean + """ + if not external_id: + return + binder = self.binder_for(binding_model) + if always or not binder.to_internal(external_id): + if importer is None: + importer = self.component(usage='record.importer', + model_name=binding_model) + try: + importer.run(external_id) + except NothingToDoJob: + _logger.info( + 'Dependency import of %s(%s) has been ignored.', + binding_model._name, external_id + ) + + def _import_dependencies(self): + """ Import the dependencies for the record + + Import of dependencies can be done manually or by calling + :meth:`_import_dependency` for each dependency. + """ + return + + def _map_data(self): + """ Returns an instance of + :py:class:`~odoo.addons.connector.components.mapper.MapRecord` + + """ + return self.mapper.map_record(self.walmart_record) + + def _validate_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``_create`` or + ``_update`` if some fields are missing or invalid. + + Raise `InvalidDataError` + """ + return + + def _must_skip(self): + """ Hook called right after we read the data from the backend. + + If the method returns a message giving a reason for the + skipping, the import will be interrupted and the message + recorded in the job (if the import is called directly by the + job, not by dependencies). + + If it returns None, the import will continue normally. + + :returns: None | str | unicode + """ + return + + def _get_binding(self): + return self.binder.to_internal(self.external_id) + + def _create_data(self, map_record, **kwargs): + return map_record.values(for_create=True, **kwargs) + + def _create(self, data): + """ Create the OpenERP record """ + # special check on data before import + self._validate_data(data) + model = self.model.with_context(connector_no_export=True) + binding = model.create(data) + _logger.debug('%d created from walmart %s', binding, self.external_id) + return binding + + def _update_data(self, map_record, **kwargs): + return map_record.values(**kwargs) + + def _update(self, binding, data): + """ Update an OpenERP record """ + # special check on data before import + self._validate_data(data) + binding.with_context(connector_no_export=True).write(data) + _logger.debug('%d updated from walmart %s', binding, self.external_id) + return + + def _after_import(self, binding): + """ Hook called at the end of the import """ + return + + def run(self, external_id, force=False): + """ Run the synchronization + + :param external_id: identifier of the record on Walmart + """ + self.external_id = external_id + lock_name = 'import({}, {}, {}, {})'.format( + self.backend_record._name, + self.backend_record.id, + self.work.model_name, + external_id, + ) + + try: + self.walmart_record = self._get_walmart_data() + except IDMissingInBackend: + return _('Record does no longer exist in Walmart') + + skip = self._must_skip() + if skip: + return skip + + binding = self._get_binding() + + if not force and self._is_uptodate(binding): + return _('Already up-to-date.') + + # Keep a lock on this import until the transaction is committed + # The lock is kept since we have detected that the informations + # will be updated into Odoo + self.advisory_lock_or_retry(lock_name) + self._before_import() + + # import the missing linked resources + self._import_dependencies() + + map_record = self._map_data() + + if binding: + record = self._update_data(map_record) + self._update(binding, record) + else: + record = self._create_data(map_record) + binding = self._create(record) + + self.binder.bind(self.external_id, binding) + + self._after_import(binding) + + +class BatchImporter(AbstractComponent): + """ The role of a BatchImporter is to search for a list of + items to import, then it can either import them directly or delay + the import of each item separately. + """ + + _name = 'walmart.batch.importer' + _inherit = ['base.importer', 'base.walmart.connector'] + _usage = 'batch.importer' + + def run(self, filters=None): + """ Run the synchronization """ + record_ids = self.backend_adapter.search(filters) + for record_id in record_ids: + self._import_record(record_id) + + def _import_record(self, external_id): + """ Import a record directly or delay the import of the record. + + Method to implement in sub-classes. + """ + raise NotImplementedError + + +class DirectBatchImporter(AbstractComponent): + """ Import the records directly, without delaying the jobs. """ + + _name = 'walmart.direct.batch.importer' + _inherit = 'walmart.batch.importer' + + def _import_record(self, external_id): + """ Import the record directly """ + self.model.import_record(self.backend_record, external_id) + + +class DelayedBatchImporter(AbstractComponent): + """ Delay import of the records """ + + _name = 'walmart.delayed.batch.importer' + _inherit = 'walmart.batch.importer' + + def _import_record(self, external_id, job_options=None, **kwargs): + """ Delay the import of the records""" + delayable = self.model.with_delay(**job_options or {}) + delayable.import_record(self.backend_record, external_id, **kwargs) + + +# class SimpleRecordImporter(Component): +# """ Import one Walmart Website """ +# +# _name = 'walmart.simple.record.importer' +# _inherit = 'walmart.importer' +# _apply_on = [ +# 'walmart.res.partner.category', +# ] + + +# class TranslationImporter(Component): +# """ Import translations for a record. +# +# Usually called from importers, in ``_after_import``. +# For instance from the products and products' categories importers. +# """ +# +# _name = 'walmart.translation.importer' +# _inherit = 'walmart.importer' +# _usage = 'translation.importer' +# +# def _get_walmart_data(self, storeview_id=None): +# """ Return the raw Walmart data for ``self.external_id`` """ +# return self.backend_adapter.read(self.external_id, storeview_id) +# +# def run(self, external_id, binding, mapper=None): +# self.external_id = external_id +# storeviews = self.env['walmart.storeview'].search( +# [('backend_id', '=', self.backend_record.id)] +# ) +# default_lang = self.backend_record.default_lang_id +# lang_storeviews = [sv for sv in storeviews +# if sv.lang_id and sv.lang_id != default_lang] +# if not lang_storeviews: +# return +# +# # find the translatable fields of the model +# fields = self.model.fields_get() +# translatable_fields = [field for field, attrs in fields.items() +# if attrs.get('translate')] +# +# if mapper is None: +# mapper = self.mapper +# else: +# mapper = self.component_by_name(mapper) +# +# for storeview in lang_storeviews: +# lang_record = self._get_walmart_data(storeview.external_id) +# map_record = mapper.map_record(lang_record) +# record = map_record.values() +# +# data = dict((field, value) for field, value in record.items() +# if field in translatable_fields) +# +# binding.with_context(connector_no_export=True, +# lang=storeview.lang_id.code).write(data) diff --git a/connector_walmart/components/mapper.py b/connector_walmart/components/mapper.py new file mode 100644 index 00000000..d41c00ab --- /dev/null +++ b/connector_walmart/components/mapper.py @@ -0,0 +1,16 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import AbstractComponent + + +class WalmartImportMapper(AbstractComponent): + _name = 'walmart.import.mapper' + _inherit = ['base.walmart.connector', 'base.import.mapper'] + _usage = 'import.mapper' + + +class WalmartExportMapper(AbstractComponent): + _name = 'walmart.export.mapper' + _inherit = ['base.walmart.connector', 'base.export.mapper'] + _usage = 'export.mapper' diff --git a/connector_walmart/data/connector_walmart_data.xml b/connector_walmart/data/connector_walmart_data.xml new file mode 100644 index 00000000..39d39715 --- /dev/null +++ b/connector_walmart/data/connector_walmart_data.xml @@ -0,0 +1,54 @@ + + + + + + Walmart - Import Sales Orders + + + 1 + days + -1 + + + + + + + + Total Amount differs from Walmart + The amount computed in Odoo doesn't match with the amount in Walmart. + +Cause: +The taxes are probably different between Odoo and Walmart. A fiscal position could have changed the final price. + +Resolution: +Check your taxes and fiscal positions configuration and correct them if necessary. + 30 + sale.order + sale + if sale.walmart_bind_ids and abs(sale.amount_total - sale.walmart_bind_ids[0].total_amount) >= 0.01: + failed = True + + + + + Total Tax Amount differs from Walmart + The tax amount computed in Odoo doesn't match with the tax amount in Walmart. + +Cause: +The taxes are probably different between Odoo and Walmart. A fiscal position could have changed the final price. + +Resolution: +Check your taxes and fiscal positions configuration and correct them if necessary. + 30 + sale.order + sale + # By default, a cent of difference for the tax amount is allowed, feel free to customise it in your own module +if sale.walmart_bind_ids and abs(sale.amount_tax - sale.walmart_bind_ids[0].total_amount_tax) > 0.01: + failed = True + + + + + diff --git a/connector_walmart/models/__init__.py b/connector_walmart/models/__init__.py new file mode 100644 index 00000000..7c54dcde --- /dev/null +++ b/connector_walmart/models/__init__.py @@ -0,0 +1,6 @@ +from . import walmart_backend +from . import walmart_binding +from . import sale_order +from . import stock_picking +from . import delivery +from . import account diff --git a/connector_walmart/models/account/__init__.py b/connector_walmart/models/account/__init__.py new file mode 100644 index 00000000..2637a061 --- /dev/null +++ b/connector_walmart/models/account/__init__.py @@ -0,0 +1 @@ +from . import account_fiscal_position diff --git a/connector_walmart/models/account/account_fiscal_position.py b/connector_walmart/models/account/account_fiscal_position.py new file mode 100644 index 00000000..b8ce30c3 --- /dev/null +++ b/connector_walmart/models/account/account_fiscal_position.py @@ -0,0 +1,68 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +from logging import getLogger + +_logger = getLogger(__name__) + + +class AccountFiscalPosition(models.Model): + _inherit = 'account.fiscal.position' + + is_connector_walmart = fields.Boolean(string='Use Walmart Order Item Rate') + + @api.multi + def map_tax(self, taxes, product=None, partner=None, order_line=None): + + if not taxes or not self.is_connector_walmart: + return super(AccountFiscalPosition, self).map_tax(taxes, product=product, partner=partner) + + AccountTax = self.env['account.tax'].sudo() + result = AccountTax.browse() + + for tax in taxes: + if not order_line: + raise ValidationError('Walmart Connector fiscal position requires order item details.') + + if not order_line.walmart_bind_ids: + if order_line.price_unit == 0.0: + continue + else: + raise ValidationError('Walmart Connector fiscal position requires Walmart Order Lines') + + tax_rate = order_line.walmart_bind_ids[0].tax_rate + + if tax_rate == 0.0: + continue + + # step 1: Check if we already have this rate. + tax_line = self.tax_ids.filtered(lambda x: tax_rate == x.tax_dest_id.amount and x.tax_src_id.id == tax.id) + if not tax_line: + #step 2: find or create this tax and tax_line + new_tax = AccountTax.search([ + ('name', 'like', 'Walmart %'), + ('amount', '=', tax_rate), + ('amount_type', '=', 'percent'), + ('type_tax_use', '=', 'sale'), + ], limit=1) + if not new_tax: + new_tax = AccountTax.create({ + 'name': 'Walmart Tax %0.2f %%' % (tax_rate,), + 'amount': tax_rate, + 'amount_type': 'percent', + 'type_tax_use': 'sale', + 'account_id': tax.account_id.id, + 'refund_account_id': tax.refund_account_id.id, + }) + tax_line = self.env['account.fiscal.position.tax'].sudo().create({ + 'position_id': self.id, + 'tax_src_id': tax.id, + 'tax_dest_id': new_tax.id, + }) + + # step 3: map the tax + result |= tax_line.tax_dest_id + return result diff --git a/connector_walmart/models/delivery/__init__.py b/connector_walmart/models/delivery/__init__.py new file mode 100644 index 00000000..e4193cf0 --- /dev/null +++ b/connector_walmart/models/delivery/__init__.py @@ -0,0 +1 @@ +from . import common diff --git a/connector_walmart/models/delivery/common.py b/connector_walmart/models/delivery/common.py new file mode 100644 index 00000000..f1200588 --- /dev/null +++ b/connector_walmart/models/delivery/common.py @@ -0,0 +1,52 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models, fields, api + + +class DeliveryCarrier(models.Model): + """ Adds Walmart specific fields to ``delivery.carrier`` + + ``walmart_code`` + + Code of the carrier delivery method in Walmart. + Example: ``Standard`` + + ``walmart_carrier_code`` + + Walmart specific list of carriers. + + """ + _inherit = "delivery.carrier" + + walmart_code = fields.Selection( + selection=[ + ('Value', 'Value'), + ('Standard', 'Standard'), + ('Express', 'Express'), + ('Oneday', 'Oneday'), + ('Freight', 'Freight'), + ], + string='Walmart Method Code', + required=False, + ) + + # From API: + # UPS, USPS, FedEx, Airborne, OnTrac, DHL, NG, LS, UDS, UPSMI, FDX + walmart_carrier_code = fields.Selection( + selection=[ + ('UPS', 'UPS'), + ('USPS', 'USPS'), + ('FedEx', 'FedEx'), + ('Airborne', 'Airborne'), + ('OnTrac', 'OnTrac'), + ('DHL', 'DHL'), + ('NG', 'NG'), + ('LS', 'LS'), + ('UDS', 'UDS'), + ('UPSMI', 'UPSMI'), + ('FDX', 'FDX'), + ], + string='Walmart Base Carrier Code', + required=False, + ) diff --git a/connector_walmart/models/sale_order/__init__.py b/connector_walmart/models/sale_order/__init__.py new file mode 100644 index 00000000..79ab5dc6 --- /dev/null +++ b/connector_walmart/models/sale_order/__init__.py @@ -0,0 +1,2 @@ +from . import common +from . import importer diff --git a/connector_walmart/models/sale_order/common.py b/connector_walmart/models/sale_order/common.py new file mode 100644 index 00000000..b173ad04 --- /dev/null +++ b/connector_walmart/models/sale_order/common.py @@ -0,0 +1,209 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +import odoo.addons.decimal_precision as dp + +from odoo import models, fields, api +from odoo.addons.queue_job.job import job +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import RetryableJobError + +_logger = logging.getLogger(__name__) + + +class WalmartSaleOrder(models.Model): + _name = 'walmart.sale.order' + _inherit = 'walmart.binding' + _description = 'Walmart Sale Order' + _inherits = {'sale.order': 'odoo_id'} + + odoo_id = fields.Many2one(comodel_name='sale.order', + string='Sale Order', + required=True, + ondelete='cascade') + walmart_order_line_ids = fields.One2many( + comodel_name='walmart.sale.order.line', + inverse_name='walmart_order_id', + string='Walmart Order Lines' + ) + customer_order_id = fields.Char(string='Customer Order ID') + total_amount = fields.Float( + string='Total amount', + digits=dp.get_precision('Account') + ) + total_amount_tax = fields.Float( + string='Total amount w. tax', + digits=dp.get_precision('Account') + ) + shipping_method_code = fields.Selection( + selection=[ + ('Value', 'Value'), + ('Standard', 'Standard'), + ('Express', 'Express'), + ('Oneday', 'Oneday'), + ('Freight', 'Freight'), + ], + string='Shipping Method Code', + required=False, + ) + + @job(default_channel='root.walmart') + @api.model + def import_batch(self, backend, filters=None): + """ Prepare the import of Sales Orders from Walmart """ + return super(WalmartSaleOrder, self).import_batch(backend, filters=filters) + + @api.multi + def action_confirm(self): + for order in self: + if order.backend_id.acknowledge_order == 'order_confirm': + self.with_delay().acknowledge_order(order.backend_id, order.external_id) + + @job(default_channel='root.walmart') + @api.model + def acknowledge_order(self, backend, external_id): + with backend.work_on(self._name) as work: + adapter = work.component(usage='backend.adapter') + return adapter.acknowledge_order(external_id) + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + walmart_bind_ids = fields.One2many( + comodel_name='walmart.sale.order', + inverse_name='odoo_id', + string="Walmart Bindings", + ) + + @api.multi + def action_confirm(self): + res = super(SaleOrder, self).action_confirm() + self.walmart_bind_ids.action_confirm() + return res + + +class WalmartSaleOrderLine(models.Model): + _name = 'walmart.sale.order.line' + _inherit = 'walmart.binding' + _description = 'Walmart Sale Order Line' + _inherits = {'sale.order.line': 'odoo_id'} + + walmart_order_id = fields.Many2one(comodel_name='walmart.sale.order', + string='Walmart Sale Order', + required=True, + ondelete='cascade', + index=True) + odoo_id = fields.Many2one(comodel_name='sale.order.line', + string='Sale Order Line', + required=True, + ondelete='cascade') + backend_id = fields.Many2one( + related='walmart_order_id.backend_id', + string='Walmart Backend', + readonly=True, + store=True, + # override 'walmart.binding', can't be INSERTed if True: + required=False, + ) + tax_rate = fields.Float(string='Tax Rate', + digits=dp.get_precision('Account')) + walmart_number = fields.Char(string='Walmart lineNumber') + # notes = fields.Char() + + @api.model + def create(self, vals): + walmart_order_id = vals['walmart_order_id'] + binding = self.env['walmart.sale.order'].browse(walmart_order_id) + vals['order_id'] = binding.odoo_id.id + binding = super(WalmartSaleOrderLine, self).create(vals) + return binding + + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + walmart_bind_ids = fields.One2many( + comodel_name='walmart.sale.order.line', + inverse_name='odoo_id', + string="Walmart Bindings", + ) + + + @api.multi + def _compute_tax_id(self): + """ + This overrides core behavior because we need to get the order_line into the order + to be able to compute Walmart taxes. + :return: + """ + for line in self: + fpos = line.order_id.fiscal_position_id or line.order_id.partner_id.property_account_position_id + # If company_id is set, always filter taxes by the company + taxes = line.product_id.taxes_id.filtered(lambda r: not line.company_id or r.company_id == line.company_id) + line.tax_id = fpos.map_tax(taxes, line.product_id, line.order_id.partner_shipping_id, order_line=line) if fpos else taxes + + + +class SaleOrderAdapter(Component): + _name = 'walmart.sale.order.adapter' + _inherit = 'walmart.adapter' + _apply_on = 'walmart.sale.order' + + def search(self, from_date=None, next_cursor=None): + """ + + :param filters: Dict of filters + :param from_date: + :param next_cursor: + :return: List + """ + if next_cursor: + arguments = {'nextCursor': next_cursor} + else: + arguments = {'createdStartDate': from_date.isoformat()} + + api_instance = self.api_instance + orders_response = api_instance.orders.all(**arguments) + _logger.debug(orders_response) + + if not 'list' in orders_response: + return [] + + next = orders_response['list']['meta']['nextCursor'] + if next: + self.env[self._apply_on].with_delay().import_batch( + self.backend_record, + filters={'next_cursor': next} + ) + + orders = orders_response['list']['elements']['order'] + return map(lambda o: o['purchaseOrderId'], orders) + + def read(self, id, attributes=None): + """ Returns the information of a record + + :rtype: dict + """ + api_instance = self.api_instance + record = api_instance.orders.get(id) + if 'order' in record: + order = record['order'] + order['orderLines'] = order['orderLines']['orderLine'] + return order + raise RetryableJobError('Order "' + str(id) + '" did not return an order response.') + + def acknowledge_order(self, id): + """ Returns the order after ack + :rtype: dict + """ + _logger.warn('BEFORE ACK ' + str(id)) + api_instance = self.api_instance + record = api_instance.orders.acknowledge(id) + _logger.warn('AFTER ACK RECORD: ' + str(record)) + if 'order' in record: + return record['order'] + raise RetryableJobError('Acknowledge Order "' + str(id) + '" did not return an order response.') + diff --git a/connector_walmart/models/sale_order/importer.py b/connector_walmart/models/sale_order/importer.py new file mode 100644 index 00000000..adf4c099 --- /dev/null +++ b/connector_walmart/models/sale_order/importer.py @@ -0,0 +1,347 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from datetime import datetime, timedelta +from copy import deepcopy, copy + +from odoo import _ +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping +from odoo.addons.queue_job.exception import NothingToDoJob, FailedJobError + +_logger = logging.getLogger(__name__) + + +def walk_charges(charges): + item_amount = 0.0 + tax_amount = 0.0 + for charge in charges['charge']: + #charge_details = charge['charge'] + charge_details = charge + charge_amount_details = charge_details['chargeAmount'] + assert charge_amount_details['currency'] == 'USD', ("Invalid currency: " + charge_amount_details['currency']) + tax_details = charge_details['tax'] + tax_amount_details = tax_details['taxAmount'] if tax_details else {'amount': 0.0} + item_amount += float(charge_amount_details['amount']) + tax_amount += float(tax_amount_details['amount']) + return item_amount, tax_amount + + +class SaleOrderBatchImporter(Component): + _name = 'walmart.sale.order.batch.importer' + _inherit = 'walmart.delayed.batch.importer' + _apply_on = 'walmart.sale.order' + + def _import_record(self, external_id, job_options=None, **kwargs): + if not job_options: + job_options = { + 'max_retries': 0, + 'priority': 5, + } + return super(SaleOrderBatchImporter, self)._import_record( + external_id, job_options=job_options) + + def run(self, filters=None): + """ Run the synchronization """ + if filters is None: + filters = {} + from_date = filters.get('from_date') + next_cursor = filters.get('next_cursor') + external_ids = self.backend_adapter.search( + from_date=from_date, + next_cursor=next_cursor, + ) + for external_id in external_ids: + self._import_record(external_id) + + + +class SaleOrderImportMapper(Component): + + _name = 'walmart.sale.order.mapper' + _inherit = 'walmart.import.mapper' + _apply_on = 'walmart.sale.order' + + direct = [('purchaseOrderId', 'external_id'), + ('customerOrderId', 'customer_order_id'), + ] + + children = [('orderLines', 'walmart_order_line_ids', 'walmart.sale.order.line'), + ] + + # def _map_child(self, map_record, from_attr, to_attr, model_name): + # return super(SaleOrderImportMapper, self)._map_child(map_record, from_attr, to_attr, model_name) + + def _add_shipping_line(self, map_record, values): + record = map_record.source + + line_builder = self.component(usage='order.line.builder.shipping') + line_builder.price_unit = 0.0 + + if values.get('carrier_id'): + carrier = self.env['delivery.carrier'].browse(values['carrier_id']) + line_builder.product = carrier.product_id + + line = (0, 0, line_builder.get_line()) + values['order_line'].append(line) + return values + + def finalize(self, map_record, values): + values.setdefault('order_line', []) + self._add_shipping_line(map_record, values) + values.update({ + 'partner_id': self.options.partner_id, + 'partner_invoice_id': self.options.partner_invoice_id, + 'partner_shipping_id': self.options.partner_shipping_id, + }) + onchange = self.component( + usage='ecommerce.onchange.manager.sale.order' + ) + return onchange.play(values, values['walmart_order_line_ids']) + + @mapping + def name(self, record): + name = record['purchaseOrderId'] + prefix = self.backend_record.sale_prefix + if prefix: + name = prefix + name + return {'name': name} + + @mapping + def date_order(self, record): + return {'date_order': datetime.fromtimestamp(record['orderDate'] / 1e3)} + + @mapping + def fiscal_position_id(self, record): + if self.backend_record.fiscal_position_id: + return {'fiscal_position_id': self.backend_record.fiscal_position_id.id} + + @mapping + def team_id(self, record): + if self.backend_record.team_id: + return {'team_id': self.backend_record.team_id.id} + + @mapping + def payment_mode_id(self, record): + assert self.backend_record.payment_mode_id, ("Payment mode must be specified.") + return {'payment_mode_id': self.backend_record.payment_mode_id.id} + + @mapping + def project_id(self, record): + if self.backend_record.analytic_account_id: + return {'project_id': self.backend_record.analytic_account_id.id} + + @mapping + def warehouse_id(self, record): + if self.backend_record.warehouse_id: + return {'warehouse_id': self.backend_record.warehouse_id.id} + + @mapping + def shipping_method(self, record): + method = record['shippingInfo']['methodCode'] + carrier = self.env['delivery.carrier'].search([('walmart_code', '=', method)], limit=1) + if not carrier: + raise ValueError('Delivery Carrier for methodCode "%s", cannot be found.' % (method, )) + return {'carrier_id': carrier.id, 'shipping_method_code': method} + + @mapping + def backend_id(self, record): + return {'backend_id': self.backend_record.id} + + @mapping + def total_amount(self, record): + lines = record['orderLines'] + total_amount = 0.0 + total_amount_tax = 0.0 + for l in lines: + item_amount, tax_amount = walk_charges(l['charges']) + total_amount += item_amount + tax_amount + total_amount_tax += tax_amount + return {'total_amount': total_amount, 'total_amount_tax': total_amount_tax} + + +class SaleOrderImporter(Component): + _name = 'walmart.sale.order.importer' + _inherit = 'walmart.importer' + _apply_on = 'walmart.sale.order' + + def _must_skip(self): + if self.binder.to_internal(self.external_id): + return _('Already imported') + + def _before_import(self): + # @TODO check if the order is released + pass + + def _create_partner(self, values): + return self.env['res.partner'].create(values) + + def _partner_matches(self, partner, values): + for key, value in values.items(): + if key == 'state_id': + if value != partner.state_id.id: + return False + elif key == 'country_id': + if value != partner.country_id.id: + return False + elif bool(value) and value != getattr(partner, key): + return False + return True + + def _get_partner_values(self): + record = self.walmart_record + + # find or make partner with these details. + if 'customerEmailId' not in record: + raise ValueError('Order does not have customerEmailId in : ' + str(record)) + customer_email = record['customerEmailId'] + shipping_info = record['shippingInfo'] + phone = shipping_info.get('phone', '') + postal_address = shipping_info.get('postalAddress', []) + name = postal_address.get('name', 'Undefined') + street = postal_address.get('address1', '') + street2 = postal_address.get('address2', '') + city = postal_address.get('city', '') + state_code = postal_address.get('state', '') + zip_ = postal_address.get('postalCode', '') + country_code = postal_address['country'] + country = self.env['res.country'].search([('code', '=', country_code)], limit=1) + state = self.env['res.country.state'].search([ + ('country_id', '=', country.id), + ('code', '=', state_code) + ], limit=1) + + return { + 'email': customer_email, + 'name': name, + 'phone': phone, + 'street': street, + 'street2': street2, + 'zip': zip_, + 'city': city, + 'state_id': state.id, + 'country_id': country.id, + } + + + def _import_addresses(self): + record = self.walmart_record + + partner_values = self._get_partner_values() + partner = self.env['res.partner'].search([ + ('email', '=', partner_values['email']), + ], limit=1) + + if not partner: + # create partner. + partner = self._create_partner(copy(partner_values)) + + if not self._partner_matches(partner, partner_values): + partner_values['parent_id'] = partner.id + partner_values['active'] = False + shipping_partner = self._create_partner(copy(partner_values)) + else: + shipping_partner = partner + + self.partner = partner + self.shipping_partner = shipping_partner + + def _check_special_fields(self): + assert self.partner, ( + "self.partner should have been defined " + "in SaleOrderImporter._import_addresses") + assert self.shipping_partner, ( + "self.shipping_partner should have been defined " + "in SaleOrderImporter._import_addresses") + + def _create_data(self, map_record, **kwargs): + # non dependencies + self._check_special_fields() + return super(SaleOrderImporter, self)._create_data( + map_record, + partner_id=self.partner.id, + partner_invoice_id=self.shipping_partner.id, + partner_shipping_id=self.shipping_partner.id, + **kwargs + ) + + def _create(self, data): + binding = super(SaleOrderImporter, self)._create(data) + # Without this, it won't map taxes with the fiscal position. + if binding.fiscal_position_id: + binding.odoo_id._compute_tax_id() + + if binding.backend_id.acknowledge_order == 'order_create': + binding.with_delay().acknowledge_order(binding.backend_id, binding.external_id) + + return binding + + + def _import_dependencies(self): + record = self.walmart_record + + self._import_addresses() + + # @TODO Import lines? + # Actually, maybe not, since I'm just going to reference by sku + + + +class SaleOrderLineImportMapper(Component): + + _name = 'walmart.sale.order.line.mapper' + _inherit = 'walmart.import.mapper' + _apply_on = 'walmart.sale.order.line' + + def _finalize_product_values(self, record, values): + # This would be a good place to create a vendor or add a route... + return values + + def _product_values(self, record): + item = record['item'] + sku = item['sku'] + item_amount, _ = walk_charges(record['charges']) + values = { + 'default_code': sku, + 'name': item.get('productName', sku), + 'type': 'product', + 'list_price': item_amount, + 'categ_id': self.backend_record.product_categ_id.id, + } + return self._finalize_product_values(record, values) + + @mapping + def product_id(self, record): + item = record['item'] + sku = item['sku'] + product = self.env['product.template'].search([ + ('default_code', '=', sku) + ], limit=1) + + if not product: + # we could use a record like (0, 0, values) + product = self.env['product.template'].create(self._product_values(record)) + + return {'product_id': product.product_variant_id.id} + + @mapping + def price_unit(self, record): + order_line_qty = record['orderLineQuantity'] + product_uom_qty = int(order_line_qty['amount']) + item_amount, tax_amount = walk_charges(record['charges']) + tax_rate = (tax_amount / item_amount) * 100.0 if item_amount else 0.0 + + price_unit = item_amount / product_uom_qty + + return {'product_uom_qty': product_uom_qty, 'price_unit': price_unit, 'tax_rate': tax_rate} + + @mapping + def walmart_number(self, record): + return {'walmart_number': record['lineNumber']} + + @mapping + def backend_id(self, record): + return {'backend_id': self.backend_record.id} + diff --git a/connector_walmart/models/stock_picking/__init__.py b/connector_walmart/models/stock_picking/__init__.py new file mode 100644 index 00000000..2db3f18c --- /dev/null +++ b/connector_walmart/models/stock_picking/__init__.py @@ -0,0 +1,2 @@ +from . import common +from . import exporter diff --git a/connector_walmart/models/stock_picking/common.py b/connector_walmart/models/stock_picking/common.py new file mode 100644 index 00000000..d1b96b4d --- /dev/null +++ b/connector_walmart/models/stock_picking/common.py @@ -0,0 +1,95 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +from odoo import api, models, fields +from odoo.addons.queue_job.job import job, related_action +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import RetryableJobError + +_logger = logging.getLogger(__name__) + + +class WalmartStockPicking(models.Model): + _name = 'walmart.stock.picking' + _inherit = 'walmart.binding' + _inherits = {'stock.picking': 'odoo_id'} + _description = 'Walmart Delivery Order' + + odoo_id = fields.Many2one(comodel_name='stock.picking', + string='Stock Picking', + required=True, + ondelete='cascade') + walmart_order_id = fields.Many2one(comodel_name='walmart.sale.order', + string='Walmart Sale Order', + ondelete='set null') + + @job(default_channel='root.walmart') + @related_action(action='related_action_unwrap_binding') + @api.multi + def export_picking_done(self): + """ Export a complete or partial delivery order. """ + self.ensure_one() + with self.backend_id.work_on(self._name) as work: + exporter = work.component(usage='record.exporter') + return exporter.run(self) + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + walmart_bind_ids = fields.One2many( + comodel_name='walmart.stock.picking', + inverse_name='odoo_id', + string="Walmart Bindings", + ) + +class StockPickingAdapter(Component): + _name = 'walmart.stock.picking.adapter' + _inherit = 'walmart.adapter' + _apply_on = 'walmart.stock.picking' + + def create(self, id, lines): + api_instance = self.api_instance + _logger.warn('BEFORE SHIPPING %s list: %s' % (str(id), str(lines))) + record = api_instance.orders.ship(id, lines) + _logger.warn('AFTER SHIPPING RECORD: ' + str(record)) + if 'order' in record: + return record['order'] + raise RetryableJobError('Shipping Order %s did not return an order response. (lines: %s)' % (str(id), str(lines))) + + +class WalmartBindingStockPickingListener(Component): + _name = 'walmart.binding.stock.picking.listener' + _inherit = 'base.event.listener' + _apply_on = ['walmart.stock.picking'] + + def on_record_create(self, record, fields=None): + record.with_delay().export_picking_done() + + +class WalmartStockPickingListener(Component): + _name = 'walmart.stock.picking.listener' + _inherit = 'base.event.listener' + _apply_on = ['stock.picking'] + + def on_picking_dropship_done(self, record, picking_method): + return self.on_picking_out_done(record, picking_method) + + def on_picking_out_done(self, record, picking_method): + """ + Create a ``walmart.stock.picking`` record. This record will then + be exported to Walmart. + + :param picking_method: picking_method, can be 'complete' or 'partial' + :type picking_method: str + """ + sale = record.sale_id + if not sale: + return + for walmart_sale in sale.walmart_bind_ids: + self.env['walmart.stock.picking'].create({ + 'backend_id': walmart_sale.backend_id.id, + 'odoo_id': record.id, + 'walmart_order_id': walmart_sale.id, + }) diff --git a/connector_walmart/models/stock_picking/exporter.py b/connector_walmart/models/stock_picking/exporter.py new file mode 100644 index 00000000..73fef876 --- /dev/null +++ b/connector_walmart/models/stock_picking/exporter.py @@ -0,0 +1,78 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import NothingToDoJob +from logging import getLogger + +_logger = getLogger(__name__) + + +class WalmartPickingExporter(Component): + _name = 'walmart.stock.picking.exporter' + _inherit = 'walmart.exporter' + _apply_on = ['walmart.stock.picking'] + + def _get_args(self, binding, lines): + sale_binder = self.binder_for('walmart.sale.order') + walmart_sale_id = sale_binder.to_external(binding.walmart_order_id) + return walmart_sale_id, lines + + def _get_lines(self, binding): + """ + Normalizes picking line data into the format to export to Walmart. + :param binding: walmart.stock.picking + :return: list[ dict(number, amount, carrier, methodCode, trackingNumber, trackingUrl=None) ] + """ + ship_date = binding.date_done + # in ms + ship_date_time = int(fields.Datetime.from_string(ship_date).strftime('%s')) * 1000 + lines = [] + for line in binding.move_lines: + sale_line = line.procurement_id.sale_line_id + if not sale_line.walmart_bind_ids: + continue + # this is a particularly interesting way to get this, + walmart_sale_line = next( + (line for line in sale_line.walmart_bind_ids + if line.backend_id.id == binding.backend_id.id), + None + ) + if not walmart_sale_line: + continue + + number = walmart_sale_line.walmart_number + amount = 1 if line.product_qty > 0 else 0 + carrier = binding.carrier_id.walmart_carrier_code + methodCode = binding.walmart_order_id.shipping_method_code + trackingNumber = binding.carrier_tracking_ref + trackingUrl = None + lines.append(dict( + shipDateTime=ship_date_time, + number=number, + amount=amount, + carrier=carrier, + methodCode=methodCode, + trackingNumber=trackingNumber, + trackingUrl=trackingUrl, + )) + + return lines + + def run(self, binding): + """ + Export the picking to Walmart + :param binding: walmart.stock.picking + :return: + """ + + if binding.external_id: + return 'Already exported' + lines = self._get_lines(binding) + if not lines: + raise NothingToDoJob('Cancelled: the delivery order does not contain ' + 'lines from the original sale order.') + args = self._get_args(binding, lines) + external_id = self.backend_adapter.create(*args) + self.binder.bind(external_id, binding) diff --git a/connector_walmart/models/walmart_backend/__init__.py b/connector_walmart/models/walmart_backend/__init__.py new file mode 100644 index 00000000..e4193cf0 --- /dev/null +++ b/connector_walmart/models/walmart_backend/__init__.py @@ -0,0 +1 @@ +from . import common diff --git a/connector_walmart/models/walmart_backend/common.py b/connector_walmart/models/walmart_backend/common.py new file mode 100644 index 00000000..8b18fc25 --- /dev/null +++ b/connector_walmart/models/walmart_backend/common.py @@ -0,0 +1,128 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from datetime import datetime, timedelta +from logging import getLogger +from contextlib import contextmanager + +from odoo import api, fields, models, _ +from ...components.api.walmart import Walmart + +_logger = getLogger(__name__) + +IMPORT_DELTA_BUFFER = 60 # seconds + + +class WalmartBackend(models.Model): + _name = 'walmart.backend' + _description = 'Walmart Backend' + _inherit = 'connector.backend' + + name = fields.Char(string='Name') + consumer_id = fields.Char( + string='Consumer ID', + required=True, + help='Walmart Consumer ID', + ) + channel_type = fields.Char( + string='Channel Type', + required=True, + help='Walmart Channel Type', + ) + private_key = fields.Char( + string='Private Key', + required=True, + help='Walmart Private Key' + ) + warehouse_id = fields.Many2one( + comodel_name='stock.warehouse', + string='Warehouse', + required=True, + help='Warehouse to use for stock.', + ) + company_id = fields.Many2one( + comodel_name='res.company', + related='warehouse_id.company_id', + string='Company', + readonly=True, + ) + fiscal_position_id = fields.Many2one( + comodel_name='account.fiscal.position', + string='Fiscal Position', + help='Fiscal position to use on orders.', + ) + analytic_account_id = fields.Many2one( + comodel_name='account.analytic.account', + string='Analytic account', + help='If specified, this analytic account will be used to fill the ' + 'field on the sale order created by the connector.' + ) + team_id = fields.Many2one(comodel_name='crm.team', string='Sales Team') + sale_prefix = fields.Char( + string='Sale Prefix', + help="A prefix put before the name of imported sales orders.\n" + "For instance, if the prefix is 'WMT-', the sales " + "order 5571768504079 in Walmart, will be named 'WMT-5571768504079' " + "in Odoo.", + ) + payment_mode_id = fields.Many2one(comodel_name='account.payment.mode', string="Payment Mode") + + # New Product fields. + product_categ_id = fields.Many2one(comodel_name='product.category', string='Product Category', + help='Default product category for newly created products.') + + acknowledge_order = fields.Selection([ + ('never', 'Never'), + ('order_create', 'On Order Import'), + ('order_confirm', 'On Order Confirmation'), + ], string='Acknowledge Order') + + + import_orders_from_date = fields.Datetime( + string='Import sale orders from date', + ) + + @contextmanager + @api.multi + def work_on(self, model_name, **kwargs): + self.ensure_one() + walmart_api = Walmart(self.consumer_id, self.channel_type, self.private_key) + _super = super(WalmartBackend, self) + with _super.work_on(model_name, walmart_api=walmart_api, **kwargs) as work: + yield work + + @api.model + def _scheduler_import_sale_orders(self): + # potential hook for customization (e.g. pad from date or provide its own) + backends = self.search([ + ('consumer_id', '!=', False), + ('channel_type', '!=', False), + ('private_key', '!=', False), + ('import_orders_from_date', '!=', False), + ]) + return backends.import_sale_orders() + + @api.multi + def import_sale_orders(self): + self._import_from_date('walmart.sale.order', 'import_orders_from_date') + return True + + @api.multi + def _import_from_date(self, model_name, from_date_field): + import_start_time = datetime.now() + for backend in self: + from_date = backend[from_date_field] + if from_date: + from_date = fields.Datetime.from_string(from_date) + else: + from_date = None + + self.env[model_name].with_delay().import_batch( + backend, + filters={'from_date': from_date, 'to_date': import_start_time} + ) + # We add a buffer, but won't import them twice. + next_time = import_start_time - timedelta(seconds=IMPORT_DELTA_BUFFER) + next_time = fields.Datetime.to_string(next_time) + self.write({from_date_field: next_time}) diff --git a/connector_walmart/models/walmart_binding/__init__.py b/connector_walmart/models/walmart_binding/__init__.py new file mode 100644 index 00000000..e4193cf0 --- /dev/null +++ b/connector_walmart/models/walmart_binding/__init__.py @@ -0,0 +1 @@ +from . import common diff --git a/connector_walmart/models/walmart_binding/common.py b/connector_walmart/models/walmart_binding/common.py new file mode 100644 index 00000000..01c39d92 --- /dev/null +++ b/connector_walmart/models/walmart_binding/common.py @@ -0,0 +1,66 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models, fields +from odoo.addons.queue_job.job import job, related_action + + +class WalmartBinding(models.AbstractModel): + """ Abstract Model for the Bindings. + + All of the models used as bindings between Walmart and Odoo + (``walmart.sale.order``) should ``_inherit`` from it. + """ + _name = 'walmart.binding' + _inherit = 'external.binding' + _description = 'Walmart Binding (abstract)' + + backend_id = fields.Many2one( + comodel_name='walmart.backend', + string='Walmart Backend', + required=True, + ondelete='restrict', + ) + external_id = fields.Char(string='ID in Walmart') + + _sql_constraints = [ + ('walmart_uniq', 'unique(backend_id, external_id)', 'A binding already exists for this Walmart ID.'), + ] + + @job(default_channel='root.walmart') + @related_action(action='related_action_walmart_link') + @api.model + def import_batch(self, backend, filters=None): + """ Prepare the import of records modified on Walmart """ + if filters is None: + filters = {} + with backend.work_on(self._name) as work: + importer = work.component(usage='batch.importer') + return importer.run(filters=filters) + + @job(default_channel='root.walmart') + @related_action(action='related_action_walmart_link') + @api.model + def import_record(self, backend, external_id, force=False): + """ Import a Walmart record """ + with backend.work_on(self._name) as work: + importer = work.component(usage='record.importer') + return importer.run(external_id, force=force) + + # @job(default_channel='root.walmart') + # @related_action(action='related_action_unwrap_binding') + # @api.multi + # def export_record(self, fields=None): + # """ Export a record on Walmart """ + # self.ensure_one() + # with self.backend_id.work_on(self._name) as work: + # exporter = work.component(usage='record.exporter') + # return exporter.run(self, fields) + # + # @job(default_channel='root.walmart') + # @related_action(action='related_action_walmart_link') + # def export_delete_record(self, backend, external_id): + # """ Delete a record on Walmart """ + # with backend.work_on(self._name) as work: + # deleter = work.component(usage='record.exporter.deleter') + # return deleter.run(external_id) diff --git a/connector_walmart/security/ir.model.access.csv b/connector_walmart/security/ir.model.access.csv new file mode 100644 index 00000000..2953189e --- /dev/null +++ b/connector_walmart/security/ir.model.access.csv @@ -0,0 +1,14 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_walmart_backend","walmart_backend connector manager","model_walmart_backend","connector.group_connector_manager",1,1,1,1 +"access_walmart_binding","walmart_binding connector manager","model_walmart_binding","connector.group_connector_manager",1,1,1,1 +"access_walmart_sale_order","walmart_sale_order connector manager","model_walmart_sale_order","connector.group_connector_manager",1,1,1,1 +"access_walmart_sale_order_line","walmart_sale_order_line connector manager","model_walmart_sale_order_line","connector.group_connector_manager",1,1,1,1 +"access_walmart_stock_picking","walmart_stock_picking connector manager","model_walmart_stock_picking","connector.group_connector_manager",1,1,1,1 +"access_walmart_sale_order_sale_salesman","walmart_sale_order","model_walmart_sale_order","sales_team.group_sale_salesman",1,0,0,0 +"access_walmart_sale_order_sale_manager","walmart_sale_order","model_walmart_sale_order","sales_team.group_sale_manager",1,1,1,1 +"access_walmart_sale_order_line_sale_salesman","walmart_sale_order_line","model_walmart_sale_order_line","sales_team.group_sale_salesman",1,0,0,0 +"access_walmart_sale_order_line_sale_manager","walmart_sale_order_line","model_walmart_sale_order_line","sales_team.group_sale_manager",1,1,1,1 +"access_walmart_sale_order_stock_user","walmart_sale_order warehouse user","model_walmart_sale_order","stock.group_stock_user",1,0,0,0 +"access_walmart_sale_order_line_stock_user","walmart_sale_order_line warehouse user","model_walmart_sale_order_line","stock.group_stock_user",1,0,0,0 +"access_walmart_backend_user","walmart_backend user","model_walmart_backend","sales_team.group_sale_salesman",1,0,0,0 +"access_walmart_stock_picking_user","walmart_stock_picking user","model_walmart_stock_picking","sales_team.group_sale_salesman",1,1,1,0 \ No newline at end of file diff --git a/connector_walmart/views/account_views.xml b/connector_walmart/views/account_views.xml new file mode 100644 index 00000000..81726b9d --- /dev/null +++ b/connector_walmart/views/account_views.xml @@ -0,0 +1,15 @@ + + + + + account.fiscal.position.form.inherit + account.fiscal.position + + + + + + + + + diff --git a/connector_walmart/views/connector_walmart_menu.xml b/connector_walmart/views/connector_walmart_menu.xml new file mode 100644 index 00000000..fd946c42 --- /dev/null +++ b/connector_walmart/views/connector_walmart_menu.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/connector_walmart/views/delivery_views.xml b/connector_walmart/views/delivery_views.xml new file mode 100644 index 00000000..d710ce5b --- /dev/null +++ b/connector_walmart/views/delivery_views.xml @@ -0,0 +1,20 @@ + + + + + walmart.delivery.carrier.form + delivery.carrier + + + + + + + + + + + + + + diff --git a/connector_walmart/views/sale_order_views.xml b/connector_walmart/views/sale_order_views.xml new file mode 100644 index 00000000..0184711f --- /dev/null +++ b/connector_walmart/views/sale_order_views.xml @@ -0,0 +1,53 @@ + + + + + sale.order.walmart.form + sale.order + + + + 0 + + + + + + + + + + + walmart.sale.order.form + walmart.sale.order + +
+ + + + + + + + +
+
+
+ + + walmart.sale.order.tree + walmart.sale.order + + + + + + + + + + + +
diff --git a/connector_walmart/views/walmart_backend_views.xml b/connector_walmart/views/walmart_backend_views.xml new file mode 100644 index 00000000..4aa02aa8 --- /dev/null +++ b/connector_walmart/views/walmart_backend_views.xml @@ -0,0 +1,101 @@ + + + + + walmart.backend.form + walmart.backend + +
+
+
+ +