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..ecfa6217 --- /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': '14.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..39a0d13c --- /dev/null +++ b/connector_walmart/components/api/__init__.py @@ -0,0 +1,7 @@ +"""python-walmart - Walmart Marketplace API""" + +__version__ = '0.0.6' +__author__ = 'Fulfil.IO Inc. ' +__all__ = [] + +from .walmart import Walmart # noqa diff --git a/connector_walmart/components/api/exceptions.py b/connector_walmart/components/api/exceptions.py new file mode 100644 index 00000000..2c42717e --- /dev/null +++ b/connector_walmart/components/api/exceptions.py @@ -0,0 +1,21 @@ +class BaseException(Exception): + """ + Base Exception which implements message attr on exceptions + Required for: Python 3 + """ + def __init__(self, message=None, *args, **kwargs): + self.message = message + super(BaseException, self).__init__( + self.message, *args, **kwargs + ) + + def __str__(self): + return self.message or self.__class__.__name__ + + +class WalmartException(BaseException): + pass + + +class WalmartAuthenticationError(WalmartException): + pass diff --git a/connector_walmart/components/api/walmart.py b/connector_walmart/components/api/walmart.py new file mode 100644 index 00000000..352d89ff --- /dev/null +++ b/connector_walmart/components/api/walmart.py @@ -0,0 +1,535 @@ +# -*- 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. + +import requests +import uuid +import csv +import io +import zipfile + +from datetime import datetime +from requests.auth import HTTPBasicAuth +from lxml import etree +from lxml.builder import E, ElementMaker + +from .exceptions import WalmartAuthenticationError + + +def epoch_milliseconds(dt): + "Walmart accepts timestamps as epoch time in milliseconds" + epoch = datetime.utcfromtimestamp(0) + return int((dt - epoch).total_seconds() * 1000.0) + + +class Walmart(object): + + def __init__(self, client_id, client_secret): + """To get client_id and client_secret for your Walmart Marketplace + visit: https://developer.walmart.com/#/generateKey + """ + self.client_id = client_id + self.client_secret = client_secret + self.token = None + self.token_expires_in = None + self.base_url = "https://marketplace.walmartapis.com/v3" + + session = requests.Session() + session.headers.update({ + "WM_SVC.NAME": "Walmart Marketplace", + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }) + session.auth = HTTPBasicAuth(self.client_id, self.client_secret) + self.session = session + + # Get the token required for API requests + self.authenticate() + + def authenticate(self): + data = self.send_request( + "POST", "{}/token".format(self.base_url), + body={ + "grant_type": "client_credentials", + }, + ) + self.token = data["access_token"] + self.token_expires_in = data["expires_in"] + + self.session.headers["WM_SEC.ACCESS_TOKEN"] = self.token + + @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) + + @property + def report(self): + return Report(connection=self) + + @property + def feed(self): + return Feed(connection=self) + + def send_request( + self, method, url, params=None, body=None, json=None, + request_headers=None + ): + # A unique ID which identifies each API call and used to track + # and debug issues; use a random generated GUID for this ID + headers = { + "WM_QOS.CORRELATION_ID": uuid.uuid4().hex, + } + if request_headers: + headers.update(request_headers) + + response = None + if method == "GET": + response = self.session.get(url, params=params, headers=headers) + elif method == "PUT": + response = self.session.put( + url, params=params, headers=headers, data=body + ) + elif method == "POST": + request_params = { + "params": params, + "headers": headers, + } + if json is not None: + request_params["json"] = json + else: + request_params["data"] = body + response = self.session.post(url, **request_params) + + if response is not None: + try: + response.raise_for_status() + except requests.exceptions.HTTPError: + if response.status_code == 401: + raise WalmartAuthenticationError(( + "Invalid client_id or client_secret. Please verify " + "your credentials from https://developer.walmart." + "com/#/generateKey" + )) + elif response.status_code == 400: + data = response.json() + if data.get("error", [{}])[0].get("code", '') == \ + "INVALID_TOKEN.GMP_GATEWAY_API": + # Refresh the token as the current token has expired + self.authenticate() + return self.send_request( + method, url, params, body, request_headers + ) + raise + try: + return response.json() + except ValueError: + # In case of reports, there is no JSON response, so return the + # content instead which contains the actual report + return response.content + + +class Resource(object): + """ + A base class for all Resources to extend + """ + + def __init__(self, connection): + self.connection = connection + + @property + def url(self): + return "{}/{}".format(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 = "{}/{}".format(self.url, 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 + ) + + +class Items(Resource): + """ + Get all items + """ + + path = 'items' + + def get_items(self): + "Get all the items from the Item Report" + response = self.connection.report.all(type="item") + zf = zipfile.ZipFile(io.BytesIO(response), "r") + product_report = zf.read(zf.infolist()[0]).decode("utf-8") + + return list(csv.DictReader(io.StringIO(product_report))) + + +class Inventory(Resource): + """ + Retreives inventory of an item + """ + + path = 'inventory' + feedType = 'inventory' + + def bulk_update(self, items): + """Updates the inventory for multiple items at once by creating the + feed on Walmart. + + :param items: Items for which the inventory needs to be updated in + the format of: + [{ + "sku": "XXXXXXXXX", + "availability_code": "AC", + "quantity": "10", + "uom": "EACH", + "fulfillment_lag_time": "1", + }] + """ + inventory_data = [] + for item in items: + data = { + "sku": item["sku"], + "quantity": { + "amount": item["quantity"], + "unit": item.get("uom", "EACH"), + }, + "fulfillmentLagTime": item.get("fulfillment_lag_time"), + } + if item.get("availability_code"): + data["availabilityCode"] = item["availability_code"] + inventory_data.append(data) + + body = { + "InventoryHeader": { + "version": "1.4", + }, + "Inventory": inventory_data, + } + return self.connection.feed.create(resource="inventory", content=body) + + def update_inventory(self, sku, quantity): + headers = { + 'Content-Type': "application/xml" + } + return self.connection.send_request( + method='PUT', + url=self.url, + params={'sku': sku}, + body=self.get_inventory_payload(sku, quantity), + request_headers=headers + ) + + def get_inventory_payload(self, sku, quantity): + element = ElementMaker( + namespace='http://walmart.com/', + nsmap={ + 'wm': 'http://walmart.com/', + } + ) + return etree.tostring( + element( + 'inventory', + element('sku', sku), + element( + 'quantity', + element('unit', 'EACH'), + element('amount', str(quantity)), + ), + element('fulfillmentLagTime', '4'), + ), xml_declaration=True, encoding='utf-8' + ) + + 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' + ) + + +class Orders(Resource): + """ + Retrieves Order details + """ + + path = 'orders' + + def all(self, **kwargs): + try: + return super(Orders, self).all(**kwargs) + except requests.exceptions.HTTPError as exc: + if exc.response.status_code == 404: + # If no orders are there on walmart matching the query + # filters, it throws 404. In this case return an empty + # list to make the API consistent + return { + "list": { + "elements": { + "order": [], + } + } + } + raise + + 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): + element = ElementMaker( + namespace='http://walmart.com/mp/orders', + nsmap={ + 'ns2': 'http://walmart.com/mp/orders', + 'ns3': 'http://walmart.com/' + } + ) + return etree.tostring( + element( + 'orderCancellation', + element( + 'orderLines', + *[element( + 'orderLine', + element('lineNumber', line), + element( + 'orderLineStatuses', + element( + 'orderLineStatus', + element('status', 'Cancelled'), + element( + 'cancellationReason', 'CANCEL_BY_SELLER'), + element( + 'statusQuantity', + element('unitOfMeasurement', 'EACH'), + element('amount', '1') + ) + ) + ) + ) for line in lines] + ) + ), xml_declaration=True, encoding='utf-8' + ) + + def create_shipment(self, order_id, lines): + """Send shipping updates to Walmart + + :param order_id: Purchase order ID of an order + :param lines: Order lines to be fulfilled in the format: + [{ + "line_number": "123", + "uom": "EACH", + "quantity": 3, + "ship_time": datetime(2019, 04, 04, 12, 00, 00), + "other_carrier": None, + "carrier": "USPS", + "carrier_service": "Standard", + "tracking_number": "34567890567890678", + "tracking_url": "www.fedex.com", + }] + """ + url = self.url + "/{}/shipping".format(order_id) + + order_lines = [] + for line in lines: + ship_time = line.get("ship_time", "") + if ship_time: + ship_time = epoch_milliseconds(ship_time) + order_lines.append({ + "lineNumber": line["line_number"], + "orderLineStatuses": { + "orderLineStatus": [{ + "status": "Shipped", + "statusQuantity": { + "unitOfMeasurement": line.get("uom", "EACH"), + "amount": str(int(line["quantity"])), + }, + "trackingInfo": { + "shipDateTime": ship_time, + "carrierName": { + "otherCarrier": line.get("other_carrier"), + "carrier": line["carrier"], + }, + "methodCode": line.get("carrier_service", ""), + "trackingNumber": line["tracking_number"], + "trackingURL": line.get("tracking_url", "") + } + }], + } + }) + + body = { + "orderShipment": { + "orderLines": { + "orderLine": order_lines, + } + } + } + return self.connection.send_request( + method="POST", + url=url, + json=body, + ) + + +class Report(Resource): + """ + Get report + """ + + path = 'getReport' + + +class Feed(Resource): + path = "feeds" + + def create(self, resource, content): + """Creates the feed on Walmart for respective resource + + Once you upload the Feed, you can use the Feed ID returned in the + response to track the status of the feed and the status of the + item within that Feed. + + :param resource: The resource for which the feed needs to be created. + :param content: The content needed to create the Feed. + """ + return self.connection.send_request( + method="POST", + url=self.url, + params={ + "feedType": resource, + }, + json=content, + ) + + def get_status(self, feed_id, offset=0, limit=1000): + "Returns the feed and item status for a specified Feed ID" + return self.connection.send_request( + method="GET", + url="{}/{}".format(self.url, feed_id), + params={ + "includeDetails": "true", + "limit": limit, + "offset": offset, + }, + ) 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..2be65d70 --- /dev/null +++ b/connector_walmart/data/connector_walmart_data.xml @@ -0,0 +1,50 @@ + + + + + + Walmart - Import Sales Orders + + code + + 1 + days + -1 + + + model._scheduler_import_sale_orders() + + + + 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 + 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 + # 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..064af512 --- /dev/null +++ b/connector_walmart/models/account/account_fiscal_position.py @@ -0,0 +1,67 @@ +# © 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') + + 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..50e725fb --- /dev/null +++ b/connector_walmart/models/sale_order/common.py @@ -0,0 +1,205 @@ +# © 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 urllib.parse import parse_qs + +from odoo import models, fields, api +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, + ) + + @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) + + 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) + + @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", + ) + + 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", + ) + + 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: + # next_cursor looks like '?somefield=xxx&blah=yyy' + arguments = parse_qs(next_cursor.strip('?')) + 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 [] + + # 'meta' may not be there (though it is in the example on the API docs even when nextCursor is none) + next = orders_response.get('list', {}).get('meta', {}).get('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.info('BEFORE ACK ' + str(id)) + api_instance = self.api_instance + record = api_instance.orders.acknowledge(id) + _logger.info('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..3c589e46 --- /dev/null +++ b/connector_walmart/models/sale_order/importer.py @@ -0,0 +1,352 @@ +# © 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 user_id(self, record): + if self.backend_record.user_id: + return {'user_id': self.backend_record.user_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 analytic_account_id(self, record): + if self.backend_record.analytic_account_id: + return {'analytic_account_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..6d2d10d1 --- /dev/null +++ b/connector_walmart/models/stock_picking/common.py @@ -0,0 +1,91 @@ +# © 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.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') + + 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.info('BEFORE SHIPPING %s list: %s' % (str(id), str(lines))) + record = api_instance.orders.create_shipment(id, lines) + _logger.info('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..bd84dcfd --- /dev/null +++ b/connector_walmart/models/stock_picking/exporter.py @@ -0,0 +1,76 @@ +# © 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(line_number, uom="EACH", quantity, ship_time, carrier, carrier_service, tracking_number, tracking_url=None) ] + """ + ship_date = binding.date_done + # in ms + ship_date_time = fields.Datetime.from_string(ship_date) + lines = [] + for line in binding.move_lines: + sale_line = line.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 # potentially because of EACH? + carrier = binding.carrier_id.walmart_carrier_code + carrier_service = binding.walmart_order_id.shipping_method_code + tracking_number = binding.carrier_tracking_ref + lines.append(dict( + line_number=number, + quantity=amount, + ship_time=ship_date_time, + carrier=carrier, + carrier_service=carrier_service, + tracking_number=tracking_number, + )) + + 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..677fb956 --- /dev/null +++ b/connector_walmart/models/walmart_backend/common.py @@ -0,0 +1,113 @@ +# © 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 = 600 # seconds + + +class WalmartBackend(models.Model): + _name = 'walmart.backend' + _description = 'Walmart Backend' + _inherit = 'connector.backend' + + name = fields.Char(string='Name') + client_id = fields.Char(string='Client ID') + client_secret = fields.Char(string='Client Secret') + 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') + user_id = fields.Many2one(comodel_name='res.users', string='Salesperson', + help="Default Salesperson for newly imported orders.") + 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 + def work_on(self, model_name, **kwargs): + self.ensure_one() + walmart_api = Walmart(self.client_id, self.client_secret) + _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([ + ('client_id', '!=', False), + ('client_secret', '!=', False), + ('import_orders_from_date', '!=', False), + ]) + return backends.import_sale_orders() + + def import_sale_orders(self): + self._import_from_date('walmart.sale.order', 'import_orders_from_date') + return True + + 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..f5fb0391 --- /dev/null +++ b/connector_walmart/models/walmart_binding/common.py @@ -0,0 +1,43 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models, fields + + +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.'), + ] + + @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) + + @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) 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..4258ae5a --- /dev/null +++ b/connector_walmart/views/walmart_backend_views.xml @@ -0,0 +1,100 @@ + + + + + walmart.backend.form + walmart.backend + +
+
+
+ +