mirror of
https://gitlab.com/hibou-io/hibou-odoo/suite.git
synced 2025-01-20 12:37:31 +02:00
Merge branch '11.0' into 11.0-WIP-sale_planner
This commit is contained in:
33
.gitmodules
vendored
33
.gitmodules
vendored
@@ -5,3 +5,36 @@
|
||||
[submodule "external/hibou-oca/server-tools"]
|
||||
path = external/hibou-oca/server-tools
|
||||
url = https://github.com/hibou-io/oca-server-tools.git
|
||||
[submodule "external/hibou-oca/connector-magento"]
|
||||
path = external/hibou-oca/connector-magento
|
||||
url = https://github.com/hibou-io/oca-connector-magento.git
|
||||
[submodule "external/hibou-oca/product-attribute"]
|
||||
path = external/hibou-oca/product-attribute
|
||||
url = https://github.com/hibou-io/oca-product-attribute.git
|
||||
[submodule "external/hibou-oca/connector-ecommerce"]
|
||||
path = external/hibou-oca/connector-ecommerce
|
||||
url = https://github.com/hibou-io/oca-connector-ecommerce.git
|
||||
[submodule "external/hibou-oca/connector"]
|
||||
path = external/hibou-oca/connector
|
||||
url = https://github.com/hibou-io/oca-connector.git
|
||||
[submodule "external/hibou-oca/sale-workflow"]
|
||||
path = external/hibou-oca/sale-workflow
|
||||
url = https://github.com/hibou-io/oca-sale-workflow.git
|
||||
[submodule "external/hibou-oca/queue"]
|
||||
path = external/hibou-oca/queue
|
||||
url = https://github.com/hibou-io/oca-queue.git
|
||||
[submodule "external/hibou-oca/bank-payment"]
|
||||
path = external/hibou-oca/bank-payment
|
||||
url = https://github.com/hibou-io/oca-bank-payment.git
|
||||
[submodule "external/hibou-shipbox"]
|
||||
path = external/hibou-shipbox
|
||||
url = https://github.com/hibou-io/shipbox.git
|
||||
[submodule "external/hibou-oca/purchase-workflow"]
|
||||
path = external/hibou-oca/purchase-workflow
|
||||
url = https://github.com/hibou-io/oca-purchase-workflow.git
|
||||
[submodule "external/hibou-oca/stock-logistics-workflow"]
|
||||
path = external/hibou-oca/stock-logistics-workflow
|
||||
url = https://github.com/hibou-io/oca-stock-logistics-workflow.git
|
||||
[submodule "external/hibou-oca/stock-logistics-warehouse"]
|
||||
path = external/hibou-oca/stock-logistics-warehouse
|
||||
url = https://github.com/hibou-io/stock-logistics-warehouse.git
|
||||
|
||||
@@ -27,11 +27,6 @@ Main Features
|
||||
:width: 988
|
||||
:align: left
|
||||
|
||||
=============
|
||||
Known Issues
|
||||
=============
|
||||
|
||||
* It is technically possible, but *not* recommended, to change which invoices are in the wizard. You've been warned.
|
||||
|
||||
=======
|
||||
License
|
||||
|
||||
@@ -60,11 +60,10 @@ class AccountRegisterPaymentsInvoiceLine(models.TransientModel):
|
||||
amount = fields.Float(string='Amount')
|
||||
writeoff_acc_id = fields.Many2one('account.account', string='Write-off Account')
|
||||
|
||||
@api.depends('invoice_id.residual', 'wizard_id.due_date_cutoff', 'invoice_id.partner_id')
|
||||
@api.depends('invoice_id', 'wizard_id.due_date_cutoff', 'invoice_id.partner_id')
|
||||
def _compute_balances(self):
|
||||
sudo_self = self.sudo()
|
||||
for line in sudo_self:
|
||||
line.residual = line.invoice_id.residual
|
||||
for line in self:
|
||||
residual = line.invoice_id.residual
|
||||
|
||||
cutoff_date = line.wizard_id.due_date_cutoff
|
||||
total_amount = 0.0
|
||||
@@ -76,14 +75,17 @@ class AccountRegisterPaymentsInvoiceLine(models.TransientModel):
|
||||
)):
|
||||
amount = abs(move_line.debit - move_line.credit)
|
||||
total_amount += amount
|
||||
for partial_line in (move_line.matched_debit_ids + move_line.matched_credit_ids):
|
||||
for partial_line in move_line.matched_debit_ids:
|
||||
total_reconciled += partial_line.amount
|
||||
for partial_line in move_line.matched_credit_ids:
|
||||
total_reconciled += partial_line.amount
|
||||
|
||||
line.residual = residual
|
||||
line.residual_due = total_amount - total_reconciled
|
||||
line.difference = line.residual - line.amount
|
||||
line.difference = residual - (line.amount or 0.0)
|
||||
line.partner_id = line.invoice_id.partner_id
|
||||
|
||||
@api.onchange('amount')
|
||||
def _onchange_amount(self):
|
||||
sudo_self = self.sudo()
|
||||
for line in sudo_self:
|
||||
for line in self:
|
||||
line.difference = line.residual - line.amount
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<tree editable="bottom" create="false">
|
||||
<field name="wizard_id" invisible="1"/>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
<field name="invoice_id"/>
|
||||
<field name="invoice_id" readonly="1" force_save="1"/>
|
||||
<field name="residual" readonly="1" sum="Total Residual"/>
|
||||
<field name="residual_due" readonly="1" sum="Total Due"/>
|
||||
<field name="amount" sum="Total Amount"/>
|
||||
|
||||
1
account_payment_mode
Symbolic link
1
account_payment_mode
Symbolic link
@@ -0,0 +1 @@
|
||||
./external/hibou-oca/bank-payment/account_payment_mode
|
||||
1
account_payment_partner
Symbolic link
1
account_payment_partner
Symbolic link
@@ -0,0 +1 @@
|
||||
./external/hibou-oca/bank-payment/account_payment_partner
|
||||
1
account_payment_sale
Symbolic link
1
account_payment_sale
Symbolic link
@@ -0,0 +1 @@
|
||||
./external/hibou-oca/bank-payment/account_payment_sale
|
||||
1
account_us_wa_salestax/__init__.py
Normal file
1
account_us_wa_salestax/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
13
account_us_wa_salestax/__manifest__.py
Normal file
13
account_us_wa_salestax/__manifest__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
{'name': 'US WA State SalesTax API',
|
||||
'version': '10.0.1.0.0',
|
||||
'category': 'Tools',
|
||||
'depends': ['account',
|
||||
],
|
||||
'author': 'Hibou Corp.',
|
||||
'license': 'AGPL-3',
|
||||
'website': 'https://hibou.io/',
|
||||
'data': ['views/account_fiscal_position_view.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
}
|
||||
2
account_us_wa_salestax/models/__init__.py
Normal file
2
account_us_wa_salestax/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import account_fiscal_position
|
||||
from . import wa_tax_request
|
||||
78
account_us_wa_salestax/models/account_fiscal_position.py
Normal file
78
account_us_wa_salestax/models/account_fiscal_position.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from .wa_tax_request import WATaxRequest
|
||||
|
||||
|
||||
class AccountFiscalPosition(models.Model):
|
||||
_inherit = 'account.fiscal.position'
|
||||
|
||||
is_us_wa = fields.Boolean(string='Use WA State API')
|
||||
wa_base_tax_id = fields.Many2one('account.tax', string='WA Base/Error Tax')
|
||||
|
||||
@api.multi
|
||||
def map_tax(self, taxes, product=None, partner=None):
|
||||
|
||||
if not taxes or not self.is_us_wa or partner is None:
|
||||
return super(AccountFiscalPosition, self).map_tax(taxes)
|
||||
|
||||
AccountTax = self.env['account.tax'].sudo()
|
||||
result = AccountTax.browse()
|
||||
|
||||
for tax in taxes:
|
||||
# step 1: If we were to save the location code on the partner we might not have to do this
|
||||
request = WATaxRequest()
|
||||
res = request.get_rate(partner)
|
||||
wa_tax = None
|
||||
if not request.is_success(res):
|
||||
# Cache.
|
||||
wa_tax = AccountTax.search([
|
||||
('wa_location_zips', 'like', '%' + partner.zip + '%'),
|
||||
('amount_type', '=', 'percent'),
|
||||
('type_tax_use', '=', 'sale')], limit=1)
|
||||
if not wa_tax:
|
||||
result |= self.wa_base_tax_id
|
||||
continue
|
||||
|
||||
# step 2: Find or create tax
|
||||
if not wa_tax:
|
||||
wa_tax = AccountTax.search([
|
||||
('wa_location_code', '=', res['location_code']),
|
||||
('amount', '=', res['rate']),
|
||||
('amount_type', '=', 'percent'),
|
||||
('type_tax_use', '=', 'sale')], limit=1)
|
||||
if not wa_tax:
|
||||
wa_tax = AccountTax.create({
|
||||
'name': '%s - WA Tax %s %%' % (res['location_code'], res['rate']),
|
||||
'wa_location_code': res['location_code'],
|
||||
'amount': res['rate'],
|
||||
'amount_type': 'percent',
|
||||
'type_tax_use': 'sale',
|
||||
'account_id': self.wa_base_tax_id.account_id.id,
|
||||
'refund_account_id': self.wa_base_tax_id.refund_account_id.id
|
||||
})
|
||||
|
||||
if not wa_tax.wa_location_zips:
|
||||
wa_tax.wa_location_zips = partner.zip
|
||||
elif not wa_tax.wa_location_zips.find(partner.zip) >= 0:
|
||||
zips = wa_tax.wa_location_zips.split(',')
|
||||
zips.append(partner.zip)
|
||||
wa_tax.wa_location_zips = zips.append(',')
|
||||
|
||||
# step 3: Find or create mapping
|
||||
tax_line = self.tax_ids.filtered(lambda x: x.tax_src_id.id == tax.id and x.tax_dest_id.id == wa_tax.id)
|
||||
if not tax_line:
|
||||
tax_line = self.env['account.fiscal.position.tax'].sudo().create({
|
||||
'position_id': self.id,
|
||||
'tax_src_id': tax.id,
|
||||
'tax_dest_id': wa_tax.id,
|
||||
})
|
||||
|
||||
result |= tax_line.tax_dest_id
|
||||
return result
|
||||
|
||||
class AccountTax(models.Model):
|
||||
_inherit = 'account.tax'
|
||||
|
||||
wa_location_code = fields.Integer('WA Location Code')
|
||||
wa_location_zips = fields.Char('WA Location ZIPs', default='')
|
||||
74
account_us_wa_salestax/models/wa_tax_request.py
Normal file
74
account_us_wa_salestax/models/wa_tax_request.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from urllib.request import urlopen, quote
|
||||
from urllib.error import HTTPError
|
||||
from ssl import _create_unverified_context
|
||||
from logging import getLogger
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = getLogger(__name__)
|
||||
|
||||
|
||||
class WATaxRequest(object):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def get_rate(self, partner):
|
||||
# https://webgis.dor.wa.gov/webapi/addressrates.aspx/?output=text\&addr=test\&city=Marysville\&zip=98270
|
||||
if not all((partner.street, partner.city, partner.zip)):
|
||||
raise ValidationError('WATaxRequest impossible without Street, City and ZIP.')
|
||||
url = 'https://webgis.dor.wa.gov/webapi/addressrates.aspx?output=text&addr=' + quote(partner.street) + \
|
||||
'&city=' + quote(partner.city) + '&zip=' + quote(partner.zip)
|
||||
_logger.info(url)
|
||||
try:
|
||||
response = urlopen(url, context=_create_unverified_context())
|
||||
response_body = response.read()
|
||||
_logger.info(response_body)
|
||||
except HTTPError as e:
|
||||
_logger.warn('Error on request: ' + str(e))
|
||||
response_body = ''
|
||||
|
||||
return self._parse_rate(response_body)
|
||||
|
||||
def is_success(self, result):
|
||||
'''
|
||||
ADDRESS = 0,
|
||||
LATLON = 0,
|
||||
PLUS4 = 1,
|
||||
ADDRESS_STANARDIZED = 2,
|
||||
PLUS4_STANARDIZED = 3,
|
||||
ADDRESS_CHANGED = 4,
|
||||
ZIPCODE = 5,
|
||||
ADDRESS_NOT_FOUND = 6,
|
||||
LATLON_NOT_FOUND = 7,
|
||||
POI = 8,
|
||||
ERROR = 9
|
||||
internal parse_error = 100
|
||||
'''
|
||||
if 'result_code' not in result or result['result_code'] >= 9 or result['result_code'] < 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _parse_rate(self, response_body):
|
||||
# 'LocationCode=1704 Rate=0.100 ResultCode=0'
|
||||
# {
|
||||
# 'result_code': 0,
|
||||
# 'location_code': '1704',
|
||||
# 'rate': '10.00',
|
||||
# }
|
||||
|
||||
res = {'result_code': 100}
|
||||
if len(response_body) > 200:
|
||||
# this likely means that they returned an HTML page
|
||||
return res
|
||||
|
||||
body_parts = response_body.decode().split(' ')
|
||||
for part in body_parts:
|
||||
if part.find('ResultCode=') >= 0:
|
||||
res['result_code'] = int(part[len('ResultCode='):])
|
||||
elif part.find('Rate=') >= 0:
|
||||
res['rate'] = '%.2f' % (float(part[len('Rate='):]) * 100.0)
|
||||
elif part.find('LocationCode=') >= 0:
|
||||
res['location_code'] = part[len('LocationCode='):]
|
||||
elif part.find('debughint=') >= 0:
|
||||
res['debug_hint'] = part[len('debughint='):]
|
||||
return res
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="account_position_us_wa_inherit_from_view" model="ir.ui.view">
|
||||
<field name="name">account.fiscal.position.form.inherit</field>
|
||||
<field name="model">account.fiscal.position</field>
|
||||
<field name="inherit_id" ref="account.view_account_position_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='active']" position="after">
|
||||
<field name="is_us_wa"/>
|
||||
<field name="wa_base_tax_id" attrs="{'invisible': [('is_us_wa', '=', False)]}" />
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_tax_form" model="ir.ui.view">
|
||||
<field name="name">account.tax.form.inherit</field>
|
||||
<field name="model">account.tax</field>
|
||||
<field name="inherit_id" ref="account.view_tax_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='description']" position="after">
|
||||
<field name="wa_location_code" />
|
||||
<field name="wa_location_zips" />
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
1
base_exception
Symbolic link
1
base_exception
Symbolic link
@@ -0,0 +1 @@
|
||||
./external/hibou-oca/server-tools/base_exception
|
||||
1
base_technical_user
Symbolic link
1
base_technical_user
Symbolic link
@@ -0,0 +1 @@
|
||||
./external/hibou-oca/server-tools/base_technical_user
|
||||
1
component_event
Symbolic link
1
component_event
Symbolic link
@@ -0,0 +1 @@
|
||||
./external/hibou-oca/connector/component_event
|
||||
1
connector_base_product
Symbolic link
1
connector_base_product
Symbolic link
@@ -0,0 +1 @@
|
||||
./external/hibou-oca/connector/connector_base_product
|
||||
1
connector_ecommerce
Symbolic link
1
connector_ecommerce
Symbolic link
@@ -0,0 +1 @@
|
||||
./external/hibou-oca/connector-ecommerce/connector_ecommerce
|
||||
1
connector_magento
Symbolic link
1
connector_magento
Symbolic link
@@ -0,0 +1 @@
|
||||
./external/hibou-oca/connector-magento/connector_magento
|
||||
1
connector_magento_product_by_sku
Symbolic link
1
connector_magento_product_by_sku
Symbolic link
@@ -0,0 +1 @@
|
||||
external/hibou-oca/connector-magento/connector_magento_product_by_sku
|
||||
2
connector_walmart/__init__.py
Normal file
2
connector_walmart/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import components
|
||||
from . import models
|
||||
29
connector_walmart/__manifest__.py
Normal file
29
connector_walmart/__manifest__.py
Normal file
@@ -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,
|
||||
}
|
||||
6
connector_walmart/components/__init__.py
Normal file
6
connector_walmart/components/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from . import api
|
||||
from . import backend_adapter
|
||||
from . import binder
|
||||
from . import importer
|
||||
from . import exporter
|
||||
from . import mapper
|
||||
1
connector_walmart/components/api/__init__.py
Normal file
1
connector_walmart/components/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import walmart
|
||||
407
connector_walmart/components/api/walmart.py
Normal file
407
connector_walmart/components/api/walmart.py
Normal file
@@ -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)
|
||||
67
connector_walmart/components/backend_adapter.py
Normal file
67
connector_walmart/components/backend_adapter.py
Normal file
@@ -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
|
||||
22
connector_walmart/components/binder.py
Normal file
22
connector_walmart/components/binder.py
Normal file
@@ -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',
|
||||
]
|
||||
313
connector_walmart/components/exporter.py
Normal file
313
connector_walmart/components/exporter.py
Normal file
@@ -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
|
||||
324
connector_walmart/components/importer.py
Normal file
324
connector_walmart/components/importer.py
Normal file
@@ -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)
|
||||
16
connector_walmart/components/mapper.py
Normal file
16
connector_walmart/components/mapper.py
Normal file
@@ -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'
|
||||
54
connector_walmart/data/connector_walmart_data.xml
Normal file
54
connector_walmart/data/connector_walmart_data.xml
Normal file
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<record model="ir.cron" id="ir_cron_import_sale_orders" forcecreate="True">
|
||||
<field name="name">Walmart - Import Sales Orders</field>
|
||||
<field eval="False" name="active"/>
|
||||
<field name="user_id" ref="base.user_root"/>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field eval="False" name="doall"/>
|
||||
<field ref="connector_walmart.model_walmart_backend" name="model_id"/>
|
||||
<field eval="'_scheduler_import_sale_orders'" name="function"/>
|
||||
<field eval="'()'" name="args"/>
|
||||
</record>
|
||||
|
||||
<record id="excep_wrong_total_amount" model="exception.rule">
|
||||
<field name="name">Total Amount differs from Walmart</field>
|
||||
<field name="description">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.</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="rule_group">sale</field>
|
||||
<field name="code">if sale.walmart_bind_ids and abs(sale.amount_total - sale.walmart_bind_ids[0].total_amount) >= 0.01:
|
||||
failed = True</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="excep_wrong_total_amount_tax" model="exception.rule">
|
||||
<field name="name">Total Tax Amount differs from Walmart</field>
|
||||
<field name="description">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.</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="rule_group">sale</field>
|
||||
<field name="code"># 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</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
6
connector_walmart/models/__init__.py
Normal file
6
connector_walmart/models/__init__.py
Normal file
@@ -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
|
||||
1
connector_walmart/models/account/__init__.py
Normal file
1
connector_walmart/models/account/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import account_fiscal_position
|
||||
68
connector_walmart/models/account/account_fiscal_position.py
Normal file
68
connector_walmart/models/account/account_fiscal_position.py
Normal file
@@ -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
|
||||
1
connector_walmart/models/delivery/__init__.py
Normal file
1
connector_walmart/models/delivery/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import common
|
||||
52
connector_walmart/models/delivery/common.py
Normal file
52
connector_walmart/models/delivery/common.py
Normal file
@@ -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,
|
||||
)
|
||||
2
connector_walmart/models/sale_order/__init__.py
Normal file
2
connector_walmart/models/sale_order/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import common
|
||||
from . import importer
|
||||
209
connector_walmart/models/sale_order/common.py
Normal file
209
connector_walmart/models/sale_order/common.py
Normal file
@@ -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.')
|
||||
|
||||
347
connector_walmart/models/sale_order/importer.py
Normal file
347
connector_walmart/models/sale_order/importer.py
Normal file
@@ -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}
|
||||
|
||||
2
connector_walmart/models/stock_picking/__init__.py
Normal file
2
connector_walmart/models/stock_picking/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import common
|
||||
from . import exporter
|
||||
95
connector_walmart/models/stock_picking/common.py
Normal file
95
connector_walmart/models/stock_picking/common.py
Normal file
@@ -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,
|
||||
})
|
||||
78
connector_walmart/models/stock_picking/exporter.py
Normal file
78
connector_walmart/models/stock_picking/exporter.py
Normal file
@@ -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)
|
||||
1
connector_walmart/models/walmart_backend/__init__.py
Normal file
1
connector_walmart/models/walmart_backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import common
|
||||
128
connector_walmart/models/walmart_backend/common.py
Normal file
128
connector_walmart/models/walmart_backend/common.py
Normal file
@@ -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})
|
||||
1
connector_walmart/models/walmart_binding/__init__.py
Normal file
1
connector_walmart/models/walmart_binding/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import common
|
||||
66
connector_walmart/models/walmart_binding/common.py
Normal file
66
connector_walmart/models/walmart_binding/common.py
Normal file
@@ -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)
|
||||
14
connector_walmart/security/ir.model.access.csv
Normal file
14
connector_walmart/security/ir.model.access.csv
Normal file
@@ -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
|
||||
|
15
connector_walmart/views/account_views.xml
Normal file
15
connector_walmart/views/account_views.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="account_position_walmart_inherit_from_view" model="ir.ui.view">
|
||||
<field name="name">account.fiscal.position.form.inherit</field>
|
||||
<field name="model">account.fiscal.position</field>
|
||||
<field name="inherit_id" ref="account.view_account_position_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='active']" position="after">
|
||||
<field name="is_connector_walmart"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
15
connector_walmart/views/connector_walmart_menu.xml
Normal file
15
connector_walmart/views/connector_walmart_menu.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<menuitem id="menu_walmart_root"
|
||||
parent="connector.menu_connector_root"
|
||||
name="Walmart"
|
||||
sequence="10"
|
||||
groups="connector.group_connector_manager"/>
|
||||
|
||||
<menuitem id="menu_walmart_backend"
|
||||
name="Backends"
|
||||
parent="menu_walmart_root"
|
||||
action="action_walmart_backend"/>
|
||||
|
||||
</odoo>
|
||||
20
connector_walmart/views/delivery_views.xml
Normal file
20
connector_walmart/views/delivery_views.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_walmart_delivery_carrier_form" model="ir.ui.view">
|
||||
<field name="name">walmart.delivery.carrier.form</field>
|
||||
<field name="model">delivery.carrier</field>
|
||||
<field name="inherit_id" ref="delivery.view_delivery_carrier_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Walmart" name="walmart">
|
||||
<group name="walmart_info">
|
||||
<field name="walmart_code"/>
|
||||
<field name="walmart_carrier_code"/>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
53
connector_walmart/views/sale_order_views.xml
Normal file
53
connector_walmart/views/sale_order_views.xml
Normal file
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_sale_order_walmart_form" model="ir.ui.view">
|
||||
<field name="name">sale.order.walmart.form</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="connector_ecommerce.view_order_connector_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<page name="connector" position="attributes">
|
||||
<attribute name="invisible">0</attribute>
|
||||
</page>
|
||||
<page name="connector" position="inside">
|
||||
<group string="Walmart Bindings">
|
||||
<field name="walmart_bind_ids" nolabel="1"/>
|
||||
</group>
|
||||
</page>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_walmart_sale_order_form" model="ir.ui.view">
|
||||
<field name="name">walmart.sale.order.form</field>
|
||||
<field name="model">walmart.sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Walmart Sales Orders"
|
||||
create="false" delete="false">
|
||||
<group>
|
||||
<field name="backend_id"/>
|
||||
<field name="external_id"/>
|
||||
<field name="customer_order_id"/>
|
||||
<field name="total_amount"/>
|
||||
<field name="total_amount_tax"/>
|
||||
<field name="shipping_method_code"/>
|
||||
</group>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_walmart_sale_order_tree" model="ir.ui.view">
|
||||
<field name="name">walmart.sale.order.tree</field>
|
||||
<field name="model">walmart.sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Walmart Sales Orders"
|
||||
create="false" delete="false">
|
||||
<field name="backend_id"/>
|
||||
<field name="external_id"/>
|
||||
<field name="customer_order_id"/>
|
||||
<field name="total_amount"/>
|
||||
<field name="total_amount_tax"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
101
connector_walmart/views/walmart_backend_views.xml
Normal file
101
connector_walmart/views/walmart_backend_views.xml
Normal file
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_walmart_backend_form" model="ir.ui.view">
|
||||
<field name="name">walmart.backend.form</field>
|
||||
<field name="model">walmart.backend</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Walmart Backend">
|
||||
<header>
|
||||
</header>
|
||||
<sheet>
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1>
|
||||
<field name="name" class="oe_inline" />
|
||||
</h1>
|
||||
<group name="walmart" string="Walmart Configuration">
|
||||
<notebook>
|
||||
<page string="API" name="api">
|
||||
<group colspan="4" col="4">
|
||||
<field name="consumer_id"/>
|
||||
<field name="channel_type"/>
|
||||
<field name="private_key" password="1"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</group>
|
||||
<group name="main_configuration" string="Main Configuration">
|
||||
<group name="order_configuration" string="Order Defaults">
|
||||
<field name="warehouse_id"/>
|
||||
<field name="analytic_account_id"/>
|
||||
<field name="fiscal_position_id"/>
|
||||
<field name="team_id"/>
|
||||
<field name="sale_prefix"/>
|
||||
<field name="payment_mode_id"/>
|
||||
<field name="acknowledge_order"/>
|
||||
</group>
|
||||
<group name="product_configuration" string="Product Defaults">
|
||||
<field name="product_categ_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page name="import" string="Imports">
|
||||
<p class="oe_grey oe_inline">
|
||||
By clicking on the buttons,
|
||||
you will initiate the synchronizations
|
||||
with Walmart.
|
||||
Note that the import or exports
|
||||
won't be done directly,
|
||||
they will create 'Jobs'
|
||||
executed as soon as possible.
|
||||
</p>
|
||||
<p class="oe_grey oe_inline">
|
||||
Once imported,
|
||||
some types of records,
|
||||
like the products or categories,
|
||||
need a manual review.
|
||||
You will find the list
|
||||
of the new records to review
|
||||
in the menu 'Connectors > Checkpoint'.
|
||||
</p>
|
||||
<group>
|
||||
<div>
|
||||
<label string="Import sale orders since" class="oe_inline"/>
|
||||
<field name="import_orders_from_date"
|
||||
class="oe_inline"
|
||||
nolabel="1"/>
|
||||
</div>
|
||||
<button name="import_sale_orders"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
string="Import in background"/>
|
||||
</group>
|
||||
|
||||
</page>
|
||||
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_walmart_backend_tree" model="ir.ui.view">
|
||||
<field name="name">walmart.backend.tree</field>
|
||||
<field name="model">walmart.backend</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Walmart Backend">
|
||||
<field name="name"/>
|
||||
<field name="import_orders_from_date"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_walmart_backend" model="ir.actions.act_window">
|
||||
<field name="name">Walmart Backends</field>
|
||||
<field name="res_model">walmart.backend</field>
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_id" ref="view_walmart_backend_tree"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
1
delivery_stamps/__init__.py
Normal file
1
delivery_stamps/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
26
delivery_stamps/__manifest__.py
Normal file
26
delivery_stamps/__manifest__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
'name': 'Stamps.com (USPS) Shipping',
|
||||
'summary': 'Send your shippings through Stamps.com and track them online.',
|
||||
'version': '11.0.1.0.0',
|
||||
'author': "Hibou Corp.",
|
||||
'category': 'Warehouse',
|
||||
'license': 'AGPL-3',
|
||||
'images': [],
|
||||
'website': "https://hibou.io",
|
||||
'description': """
|
||||
Stamps.com (USPS) Shipping
|
||||
==========================
|
||||
|
||||
Send your shippings through Stamps.com and track them online.
|
||||
|
||||
""",
|
||||
'depends': [
|
||||
'delivery',
|
||||
],
|
||||
'demo': [],
|
||||
'data': [
|
||||
'views/delivery_stamps_view.xml',
|
||||
],
|
||||
'auto_install': False,
|
||||
'installable': True,
|
||||
}
|
||||
1
delivery_stamps/models/__init__.py
Normal file
1
delivery_stamps/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import delivery_stamps
|
||||
31
delivery_stamps/models/api/LICENSE
Executable file
31
delivery_stamps/models/api/LICENSE
Executable file
@@ -0,0 +1,31 @@
|
||||
Copyright (c) 2014 by Jonathan Zempel.
|
||||
|
||||
Some 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.
|
||||
|
||||
* The names of the contributors may not be used to endorse or
|
||||
promote products derived from this software without specific
|
||||
prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
14
delivery_stamps/models/api/__init__.py
Executable file
14
delivery_stamps/models/api/__init__.py
Executable file
@@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
stamps
|
||||
~~~~~~
|
||||
|
||||
Stamps.com API.
|
||||
|
||||
:copyright: 2014 by Jonathan Zempel.
|
||||
:license: BSD, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
__author__ = "Jonathan Zempel"
|
||||
__license__ = "BSD"
|
||||
__version__ = "0.9.1"
|
||||
102
delivery_stamps/models/api/config.py
Executable file
102
delivery_stamps/models/api/config.py
Executable file
@@ -0,0 +1,102 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
stamps.config
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
Stamps.com configuration.
|
||||
|
||||
:copyright: 2014 by Jonathan Zempel.
|
||||
:license: BSD, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
from configparser import NoOptionError, NoSectionError, SafeConfigParser
|
||||
from urllib.request import pathname2url
|
||||
from urllib.parse import urljoin
|
||||
import os
|
||||
|
||||
|
||||
VERSION = 49
|
||||
|
||||
|
||||
class StampsConfiguration(object):
|
||||
"""Stamps service configuration. The service configuration may be provided
|
||||
directly via parameter values, or it can be read from a configuration file.
|
||||
If no parameters are given, the configuration will attempt to read from a
|
||||
``'.stamps.cfg'`` file in the user's HOME directory. Alternately, a
|
||||
configuration filename can be passed to the constructor.
|
||||
|
||||
Here is a sample configuration (by default the constructor reads from a
|
||||
``'default'`` section)::
|
||||
|
||||
[default]
|
||||
integration_id = XXXXXXXX-1111-2222-3333-YYYYYYYYYYYY
|
||||
username = stampy
|
||||
password = secret
|
||||
|
||||
:param integration_id: Default `None`. Unique ID, provided by Stamps.com,
|
||||
that represents your application.
|
||||
:param username: Default `None`. Stamps.com account username.
|
||||
:param password: Default `None`. Stamps.com password.
|
||||
:param wsdl: Default `None`. WSDL URI. Use ``'testing'`` to use the test
|
||||
server WSDL.
|
||||
:param port: Default `None`. The name of the WSDL port to use.
|
||||
:param file_name: Default `None`. Optional configuration file name.
|
||||
:param section: Default ``'default'``. The configuration section to use.
|
||||
"""
|
||||
|
||||
def __init__(self, integration_id=None, username=None, password=None,
|
||||
wsdl=None, port=None, file_name=None, section="default"):
|
||||
parser = SafeConfigParser()
|
||||
|
||||
if file_name:
|
||||
parser.read([file_name])
|
||||
else:
|
||||
parser.read([os.path.expanduser("~/.stamps.cfg")])
|
||||
|
||||
self.integration_id = self.__get(parser, section, "integration_id",
|
||||
integration_id)
|
||||
self.username = self.__get(parser, section, "username", username)
|
||||
self.password = self.__get(parser, section, "password", password)
|
||||
self.wsdl = self.__get(parser, section, "wsdl", wsdl)
|
||||
self.port = self.__get(parser, section, "port", port)
|
||||
|
||||
if self.wsdl is None or wsdl == "testing":
|
||||
file_path = os.path.abspath(__file__)
|
||||
directory_path = os.path.dirname(file_path)
|
||||
|
||||
if wsdl == "testing":
|
||||
file_name = "stamps_v{0}.test.wsdl".format(VERSION)
|
||||
else:
|
||||
file_name = "stamps_v{0}.wsdl".format(VERSION)
|
||||
|
||||
wsdl = os.path.join(directory_path, "wsdls", file_name)
|
||||
self.wsdl = urljoin("file:", pathname2url(wsdl))
|
||||
|
||||
if self.port is None:
|
||||
self.port = "SwsimV{0}Soap12".format(VERSION)
|
||||
|
||||
assert self.integration_id
|
||||
assert self.username
|
||||
assert self.password
|
||||
assert self.wsdl
|
||||
assert self.port
|
||||
|
||||
@staticmethod
|
||||
def __get(parser, section, name, default):
|
||||
"""Get a configuration value for the named section.
|
||||
|
||||
:param parser: The configuration parser.
|
||||
:param section: The section for the given name.
|
||||
:param name: The name of the value to retrieve.
|
||||
"""
|
||||
if default:
|
||||
vars = {name: default}
|
||||
else:
|
||||
vars = None
|
||||
|
||||
try:
|
||||
ret_val = parser.get(section, name, vars=vars)
|
||||
except (NoSectionError, NoOptionError):
|
||||
ret_val = default
|
||||
|
||||
return ret_val
|
||||
298
delivery_stamps/models/api/services.py
Executable file
298
delivery_stamps/models/api/services.py
Executable file
@@ -0,0 +1,298 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
stamps.services
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
Stamps.com services.
|
||||
|
||||
:copyright: 2014 by Jonathan Zempel.
|
||||
:license: BSD, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from logging import getLogger
|
||||
from re import compile
|
||||
from suds import WebFault
|
||||
from suds.bindings.document import Document
|
||||
from suds.client import Client
|
||||
from suds.plugin import MessagePlugin
|
||||
from suds.sax.element import Element
|
||||
from suds.sudsobject import asdict
|
||||
from suds.xsd.sxbase import XBuiltin
|
||||
from suds.xsd.sxbuiltin import Factory
|
||||
|
||||
|
||||
PATTERN_HEX = r"[0-9a-fA-F]"
|
||||
PATTERN_ID = r"{hex}{{8}}-{hex}{{4}}-{hex}{{4}}-{hex}{{4}}-{hex}{{12}}".format(
|
||||
hex=PATTERN_HEX)
|
||||
RE_TRANSACTION_ID = compile(PATTERN_ID)
|
||||
|
||||
|
||||
class AuthenticatorPlugin(MessagePlugin):
|
||||
"""Handle message authentication.
|
||||
|
||||
:param credentials: Stamps API credentials.
|
||||
:param wsdl: Configured service client.
|
||||
"""
|
||||
|
||||
def __init__(self, credentials, client):
|
||||
self.credentials = credentials
|
||||
self.client = client
|
||||
self.authenticator = None
|
||||
|
||||
def marshalled(self, context):
|
||||
"""Add an authenticator token to the document before it is sent.
|
||||
|
||||
:param context: The current message context.
|
||||
"""
|
||||
body = context.envelope.getChild("Body")
|
||||
operation = body[0]
|
||||
|
||||
if operation.name in ("AuthenticateUser", "RegisterAccount"):
|
||||
pass
|
||||
elif self.authenticator:
|
||||
namespace = operation.namespace()
|
||||
element = Element("Authenticator", ns=namespace)
|
||||
element.setText(self.authenticator)
|
||||
operation.insert(element)
|
||||
else:
|
||||
document = Document(self.client.wsdl)
|
||||
method = self.client.service.AuthenticateUser.method
|
||||
parameter = document.param_defs(method)[0]
|
||||
element = document.mkparam(method, parameter, self.credentials)
|
||||
operation.insert(element)
|
||||
|
||||
def unmarshalled(self, context):
|
||||
"""Store the authenticator token for the next call.
|
||||
|
||||
:param context: The current message context.
|
||||
"""
|
||||
if hasattr(context.reply, "Authenticator"):
|
||||
self.authenticator = context.reply.Authenticator
|
||||
del context.reply.Authenticator
|
||||
else:
|
||||
self.authenticator = None
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class BaseService(object):
|
||||
"""Base service.
|
||||
|
||||
:param configuration: API configuration.
|
||||
"""
|
||||
|
||||
def __init__(self, configuration):
|
||||
Factory.maptag("decimal", XDecimal)
|
||||
self.client = Client(configuration.wsdl)
|
||||
credentials = self.create("Credentials")
|
||||
credentials.IntegrationID = configuration.integration_id
|
||||
credentials.Username = configuration.username
|
||||
credentials.Password = configuration.password
|
||||
self.plugin = AuthenticatorPlugin(credentials, self.client)
|
||||
self.client.set_options(plugins=[self.plugin], port=configuration.port)
|
||||
self.logger = getLogger("stamps")
|
||||
|
||||
def call(self, method, **kwargs):
|
||||
"""Call the given web service method.
|
||||
|
||||
:param method: The name of the web service operation to call.
|
||||
:param kwargs: Method keyword-argument parameters.
|
||||
"""
|
||||
self.logger.debug("%s(%s)", method, kwargs)
|
||||
instance = getattr(self.client.service, method)
|
||||
|
||||
try:
|
||||
ret_val = instance(**kwargs)
|
||||
except WebFault as error:
|
||||
self.logger.warning("Retry %s", method, exc_info=True)
|
||||
self.plugin.authenticator = None
|
||||
|
||||
try: # retry with a re-authenticated user.
|
||||
ret_val = instance(**kwargs)
|
||||
except WebFault as error:
|
||||
self.logger.exception("%s retry failed", method)
|
||||
self.plugin.authenticator = None
|
||||
raise error
|
||||
|
||||
return ret_val
|
||||
|
||||
def create(self, wsdl_type):
|
||||
"""Create an object of the given WSDL type.
|
||||
|
||||
:param wsdl_type: The WSDL type to create an object for.
|
||||
"""
|
||||
return self.client.factory.create(wsdl_type)
|
||||
|
||||
|
||||
class StampsService(BaseService):
|
||||
"""Stamps.com service.
|
||||
"""
|
||||
|
||||
def add_postage(self, amount, transaction_id=None):
|
||||
"""Add postage to the account.
|
||||
|
||||
:param amount: The amount of postage to purchase.
|
||||
:param transaction_id: Default `None`. ID that may be used to retry the
|
||||
purchase of this postage.
|
||||
"""
|
||||
account = self.get_account()
|
||||
control = account.AccountInfo.PostageBalance.ControlTotal
|
||||
|
||||
return self.call("PurchasePostage", PurchaseAmount=amount,
|
||||
ControlTotal=control, IntegratorTxID=transaction_id)
|
||||
|
||||
def create_add_on(self):
|
||||
"""Create a new add-on object.
|
||||
"""
|
||||
return self.create("AddOnV7")
|
||||
|
||||
def create_customs(self):
|
||||
"""Create a new customs object.
|
||||
"""
|
||||
return self.create("CustomsV3")
|
||||
|
||||
def create_array_of_customs_lines(self):
|
||||
"""Create a new array of customs objects.
|
||||
"""
|
||||
return self.create("ArrayOfCustomsLine")
|
||||
|
||||
def create_customs_lines(self):
|
||||
"""Create new customs lines.
|
||||
"""
|
||||
return self.create("CustomsLine")
|
||||
|
||||
def create_address(self):
|
||||
"""Create a new address object.
|
||||
"""
|
||||
return self.create("Address")
|
||||
|
||||
def create_purchase_status(self):
|
||||
"""Create a new purchase status object.
|
||||
"""
|
||||
return self.create("PurchaseStatus")
|
||||
|
||||
def create_registration(self):
|
||||
"""Create a new registration object.
|
||||
"""
|
||||
ret_val = self.create("RegisterAccount")
|
||||
ret_val.IntegrationID = self.plugin.credentials.IntegrationID
|
||||
ret_val.UserName = self.plugin.credentials.Username
|
||||
ret_val.Password = self.plugin.credentials.Password
|
||||
|
||||
return ret_val
|
||||
|
||||
def create_shipping(self):
|
||||
"""Create a new shipping object.
|
||||
"""
|
||||
return self.create("RateV18")
|
||||
|
||||
def get_address(self, address):
|
||||
"""Get a shipping address.
|
||||
|
||||
:param address: Address instance to get a clean shipping address for.
|
||||
"""
|
||||
return self.call("CleanseAddress", Address=address)
|
||||
|
||||
def get_account(self):
|
||||
"""Get account information.
|
||||
"""
|
||||
return self.call("GetAccountInfo")
|
||||
|
||||
def get_label(self, from_address, to_address, rate, transaction_id, image_type=None,
|
||||
customs=None, sample=False):
|
||||
"""Get a shipping label.
|
||||
|
||||
:param from_address: The shipping 'from' address.
|
||||
:param to_address: The shipping 'to' address.
|
||||
:param rate: A rate instance for the shipment.
|
||||
:param transaction_id: ID that may be used to retry/rollback the
|
||||
purchase of this label.
|
||||
:param customs: A customs instance for international shipments.
|
||||
:param sample: Default ``False``. Get a sample label without postage.
|
||||
"""
|
||||
return self.call("CreateIndicium", IntegratorTxID=transaction_id,
|
||||
Rate=rate, From=from_address, To=to_address, ImageType=image_type, Customs=customs,
|
||||
SampleOnly=sample)
|
||||
|
||||
def get_postage_status(self, transaction_id):
|
||||
"""Get postage purchase status.
|
||||
|
||||
:param transaction_id: The transaction ID returned by
|
||||
:meth:`add_postage`.
|
||||
"""
|
||||
return self.call("GetPurchaseStatus", TransactionID=transaction_id)
|
||||
|
||||
def get_rates(self, shipping):
|
||||
"""Get shipping rates.
|
||||
|
||||
:param shipping: Shipping instance to get rates for.
|
||||
"""
|
||||
rates = self.call("GetRates", Rate=shipping)
|
||||
|
||||
if rates.Rates:
|
||||
ret_val = [rate for rate in rates.Rates.Rate]
|
||||
else:
|
||||
ret_val = []
|
||||
|
||||
return ret_val
|
||||
|
||||
def get_tracking(self, transaction_id):
|
||||
"""Get tracking events for a shipment.
|
||||
|
||||
:param transaction_id: The transaction ID (or tracking number) returned
|
||||
by :meth:`get_label`.
|
||||
"""
|
||||
if RE_TRANSACTION_ID.match(transaction_id):
|
||||
arguments = dict(StampsTxID=transaction_id)
|
||||
else:
|
||||
arguments = dict(TrackingNumber=transaction_id)
|
||||
|
||||
return self.call("TrackShipment", **arguments)
|
||||
|
||||
def register_account(self, registration):
|
||||
"""Register a new account.
|
||||
|
||||
:param registration: Registration instance.
|
||||
"""
|
||||
arguments = asdict(registration)
|
||||
|
||||
return self.call("RegisterAccount", **arguments)
|
||||
|
||||
def remove_label(self, transaction_id):
|
||||
"""Cancel a shipping label.
|
||||
|
||||
:param transaction_id: The transaction ID (or tracking number) returned
|
||||
by :meth:`get_label`.
|
||||
"""
|
||||
if RE_TRANSACTION_ID.match(transaction_id):
|
||||
arguments = dict(StampsTxID=transaction_id)
|
||||
else:
|
||||
arguments = dict(TrackingNumber=transaction_id)
|
||||
|
||||
return self.call("CancelIndicium", **arguments)
|
||||
|
||||
|
||||
class XDecimal(XBuiltin):
|
||||
"""Represents an XSD decimal type.
|
||||
"""
|
||||
|
||||
def translate(self, value, topython=True):
|
||||
"""Translate between string and decimal values.
|
||||
|
||||
:param value: The value to translate.
|
||||
:param topython: Default `True`. Determine whether to translate the
|
||||
value for python.
|
||||
"""
|
||||
if topython:
|
||||
if isinstance(value, str) and len(value):
|
||||
ret_val = Decimal(value)
|
||||
else:
|
||||
ret_val = None
|
||||
else:
|
||||
if isinstance(value, (int, float, Decimal)):
|
||||
ret_val = str(value)
|
||||
else:
|
||||
ret_val = value
|
||||
|
||||
return ret_val
|
||||
149
delivery_stamps/models/api/tests.py
Executable file
149
delivery_stamps/models/api/tests.py
Executable file
@@ -0,0 +1,149 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
stamps.tests
|
||||
~~~~~~~~~~~~
|
||||
|
||||
Stamps.com API tests.
|
||||
|
||||
:copyright: 2014 by Jonathan Zempel.
|
||||
:license: BSD, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
from .config import StampsConfiguration
|
||||
from .services import StampsService
|
||||
from datetime import date, datetime
|
||||
from time import sleep
|
||||
from unittest import TestCase
|
||||
import logging
|
||||
import os
|
||||
|
||||
|
||||
logging.basicConfig()
|
||||
logging.getLogger("suds.client").setLevel(logging.DEBUG)
|
||||
file_path = os.path.abspath(__file__)
|
||||
directory_path = os.path.dirname(file_path)
|
||||
file_name = os.path.join(directory_path, "tests.cfg")
|
||||
CONFIGURATION = StampsConfiguration(wsdl="testing", file_name=file_name)
|
||||
|
||||
|
||||
def get_rate(service):
|
||||
"""Get a test rate.
|
||||
|
||||
:param service: Instance of the stamps service.
|
||||
"""
|
||||
ret_val = service.create_shipping()
|
||||
ret_val.ShipDate = date.today().isoformat()
|
||||
ret_val.FromZIPCode = "94107"
|
||||
ret_val.ToZIPCode = "20500"
|
||||
ret_val.PackageType = "Package"
|
||||
rate = service.get_rates(ret_val)[0]
|
||||
ret_val.Amount = rate.Amount
|
||||
ret_val.ServiceType = rate.ServiceType
|
||||
ret_val.DeliverDays = rate.DeliverDays
|
||||
ret_val.DimWeighting = rate.DimWeighting
|
||||
ret_val.Zone = rate.Zone
|
||||
ret_val.RateCategory = rate.RateCategory
|
||||
ret_val.ToState = rate.ToState
|
||||
add_on = service.create_add_on()
|
||||
add_on.AddOnType = "US-A-DC"
|
||||
ret_val.AddOns.AddOnV7.append(add_on)
|
||||
|
||||
return ret_val
|
||||
|
||||
|
||||
def get_from_address(service):
|
||||
"""Get a test 'from' address.
|
||||
|
||||
:param service: Instance of the stamps service.
|
||||
"""
|
||||
address = service.create_address()
|
||||
address.FullName = "Pickwick & Weller"
|
||||
address.Address1 = "300 Brannan St."
|
||||
address.Address2 = "Suite 405"
|
||||
address.City = "San Francisco"
|
||||
address.State = "CA"
|
||||
|
||||
return service.get_address(address).Address
|
||||
|
||||
|
||||
def get_to_address(service):
|
||||
"""Get a test 'to' address.
|
||||
|
||||
:param service: Instance of the stamps service.
|
||||
"""
|
||||
address = service.create_address()
|
||||
address.FullName = "POTUS"
|
||||
address.Address1 = "1600 Pennsylvania Avenue NW"
|
||||
address.City = "Washington"
|
||||
address.State = "DC"
|
||||
|
||||
return service.get_address(address).Address
|
||||
|
||||
|
||||
class StampsTestCase(TestCase):
|
||||
|
||||
initialized = False
|
||||
|
||||
def setUp(self):
|
||||
if not StampsTestCase.initialized:
|
||||
self.service = StampsService(CONFIGURATION)
|
||||
StampsTestCase.initalized = True
|
||||
|
||||
def _test_0(self):
|
||||
"""Test account registration.
|
||||
"""
|
||||
registration = self.service.create_registration()
|
||||
type = self.service.create("CodewordType")
|
||||
registration.Codeword1Type = type.Last4SocialSecurityNumber
|
||||
registration.Codeword1 = 1234
|
||||
registration.Codeword2Type = type.Last4DriversLicense
|
||||
registration.Codeword2 = 1234
|
||||
registration.PhysicalAddress = get_from_address(self.service)
|
||||
registration.MachineInfo.IPAddress = "127.0.0.1"
|
||||
registration.Email = "sws-support@stamps.com"
|
||||
type = self.service.create("AccountType")
|
||||
registration.AccountType = type.OfficeBasedBusiness
|
||||
result = self.service.register_account(registration)
|
||||
print result
|
||||
|
||||
def _test_1(self):
|
||||
"""Test postage purchase.
|
||||
"""
|
||||
transaction_id = datetime.now().isoformat()
|
||||
result = self.service.add_postage(10, transaction_id=transaction_id)
|
||||
transaction_id = result.TransactionID
|
||||
status = self.service.create_purchase_status()
|
||||
seconds = 4
|
||||
|
||||
while result.PurchaseStatus in (status.Pending, status.Processing):
|
||||
seconds = 32 if seconds * 2 >= 32 else seconds * 2
|
||||
print "Waiting {0:d} seconds to get status...".format(seconds)
|
||||
sleep(seconds)
|
||||
result = self.service.get_postage_status(transaction_id)
|
||||
|
||||
print result
|
||||
|
||||
def test_2(self):
|
||||
"""Test label generation.
|
||||
"""
|
||||
self.service = StampsService(CONFIGURATION)
|
||||
rate = get_rate(self.service)
|
||||
from_address = get_from_address(self.service)
|
||||
to_address = get_to_address(self.service)
|
||||
transaction_id = datetime.now().isoformat()
|
||||
label = self.service.get_label(from_address, to_address, rate,
|
||||
transaction_id=transaction_id)
|
||||
self.service.get_tracking(label.StampsTxID)
|
||||
self.service.get_tracking(label.TrackingNumber)
|
||||
self.service.remove_label(label.StampsTxID)
|
||||
print label
|
||||
|
||||
def test_3(self):
|
||||
"""Test authentication retry.
|
||||
"""
|
||||
self.service.get_account()
|
||||
authenticator = self.service.plugin.authenticator
|
||||
self.service.get_account()
|
||||
self.service.plugin.authenticator = authenticator
|
||||
result = self.service.get_account()
|
||||
print result
|
||||
3381
delivery_stamps/models/api/wsdls/stamps_v49.test.wsdl
Executable file
3381
delivery_stamps/models/api/wsdls/stamps_v49.test.wsdl
Executable file
File diff suppressed because it is too large
Load Diff
3381
delivery_stamps/models/api/wsdls/stamps_v49.wsdl
Executable file
3381
delivery_stamps/models/api/wsdls/stamps_v49.wsdl
Executable file
File diff suppressed because it is too large
Load Diff
292
delivery_stamps/models/delivery_stamps.py
Normal file
292
delivery_stamps/models/delivery_stamps.py
Normal file
@@ -0,0 +1,292 @@
|
||||
from datetime import date
|
||||
from logging import getLogger
|
||||
from urllib.request import urlopen
|
||||
from suds import WebFault
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from .api.config import StampsConfiguration
|
||||
from .api.services import StampsService
|
||||
|
||||
_logger = getLogger(__name__)
|
||||
|
||||
STAMPS_PACKAGE_TYPES = [
|
||||
'Unknown',
|
||||
'Postcard',
|
||||
'Letter',
|
||||
'Large Envelope or Flat',
|
||||
'Thick Envelope',
|
||||
'Package',
|
||||
'Flat Rate Box',
|
||||
'Small Flat Rate Box',
|
||||
'Large Flat Rate Box',
|
||||
'Flat Rate Envelope',
|
||||
'Flat Rate Padded Envelope',
|
||||
'Large Package',
|
||||
'Oversized Package',
|
||||
'Regional Rate Box A',
|
||||
'Regional Rate Box B',
|
||||
'Legal Flat Rate Envelope',
|
||||
'Regional Rate Box C',
|
||||
]
|
||||
|
||||
|
||||
class ProductPackaging(models.Model):
|
||||
_inherit = 'product.packaging'
|
||||
|
||||
package_carrier_type = fields.Selection(selection_add=[('stamps', 'Stamps.com')])
|
||||
|
||||
|
||||
class ProviderStamps(models.Model):
|
||||
_inherit = 'delivery.carrier'
|
||||
|
||||
delivery_type = fields.Selection(selection_add=[('stamps', 'Stamps.com (USPS)')])
|
||||
|
||||
stamps_integration_id = fields.Char(string='Stamps.com Integration ID', groups='base.group_system')
|
||||
stamps_username = fields.Char(string='Stamps.com Username', groups='base.group_system')
|
||||
stamps_password = fields.Char(string='Stamps.com Password', groups='base.group_system')
|
||||
|
||||
stamps_service_type = fields.Selection([('US-FC', 'First-Class'),
|
||||
('US-PM', 'Priority'),
|
||||
],
|
||||
required=True, string="Service Type", default="US-PM")
|
||||
stamps_default_packaging_id = fields.Many2one('product.packaging', string='Default Package Type')
|
||||
|
||||
stamps_image_type = fields.Selection([('Auto', 'Auto'),
|
||||
('Png', 'PNG'),
|
||||
('Gif', 'GIF'),
|
||||
('Pdf', 'PDF'),
|
||||
('Epl', 'EPL'),
|
||||
('Jpg', 'JPG'),
|
||||
('PrintOncePdf', 'Print Once PDF'),
|
||||
('EncryptedPngUrl', 'Encrypted PNG URL'),
|
||||
('Zpl', 'ZPL'),
|
||||
('AZpl', 'AZPL'),
|
||||
('BZpl', 'BZPL'),
|
||||
],
|
||||
required=True, string="Image Type", default="Pdf")
|
||||
|
||||
def _stamps_package_type(self, package=None):
|
||||
if not package:
|
||||
return self.stamps_default_packaging_id.shipper_package_code
|
||||
return package.packaging_id.shipper_package_code if package.packaging_id.shipper_package_code in STAMPS_PACKAGE_TYPES else 'Package'
|
||||
|
||||
def _get_stamps_service(self):
|
||||
sudoself = self.sudo()
|
||||
config = StampsConfiguration(integration_id=sudoself.stamps_integration_id,
|
||||
username=sudoself.stamps_username,
|
||||
password=sudoself.stamps_password)
|
||||
return StampsService(configuration=config)
|
||||
|
||||
def _stamps_convert_weight(self, weight):
|
||||
""" weight always expressed in KG """
|
||||
if self.stamps_default_packaging_id.max_weight and self.stamps_default_packaging_id.max_weight < weight:
|
||||
raise ValidationError('Stamps cannot ship for weight: ' + str(weight) + 'kgs.')
|
||||
|
||||
weight_in_pounds = weight * 2.20462
|
||||
return weight_in_pounds
|
||||
|
||||
def _get_stamps_shipping_for_order(self, service, order, date_planned):
|
||||
weight = sum([(line.product_id.weight * line.product_qty) for line in order.order_line]) or 0.0
|
||||
weight = self._stamps_convert_weight(weight)
|
||||
|
||||
if not all((order.warehouse_id.partner_id.zip, order.partner_shipping_id.zip)):
|
||||
raise ValidationError('Stamps needs ZIP. From: ' + str(order.warehouse_id.partner_id.zip) + ' To: ' + str(order.partner_shipping_id.zip))
|
||||
|
||||
ret_val = service.create_shipping()
|
||||
ret_val.ShipDate = date_planned.split()[0] if date_planned else date.today().isoformat()
|
||||
ret_val.FromZIPCode = order.warehouse_id.partner_id.zip
|
||||
ret_val.ToZIPCode = order.partner_shipping_id.zip
|
||||
ret_val.PackageType = self._stamps_package_type()
|
||||
ret_val.ServiceType = self.stamps_service_type
|
||||
ret_val.WeightLb = weight
|
||||
return ret_val
|
||||
|
||||
def _get_order_for_picking(self, picking):
|
||||
if picking.sale_id:
|
||||
return picking.sale_id
|
||||
return None
|
||||
|
||||
def _get_company_for_order(self, order):
|
||||
company = order.company_id
|
||||
if order.team_id and order.team_id.subcompany_id:
|
||||
company = order.team_id.subcompany_id.company_id
|
||||
elif order.analytic_account_id and order.analytic_account_id.subcompany_id:
|
||||
company = order.analytic_account_id.subcompany_id.company_id
|
||||
return company
|
||||
|
||||
def _get_company_for_picking(self, picking):
|
||||
order = self._get_order_for_picking(picking)
|
||||
if order:
|
||||
return self._get_company_for_order(order)
|
||||
return picking.company_id
|
||||
|
||||
def _stamps_get_addresses_for_picking(self, picking):
|
||||
company = self._get_company_for_picking(picking)
|
||||
from_ = picking.picking_type_id.warehouse_id.partner_id
|
||||
to = picking.partner_id
|
||||
return company, from_, to
|
||||
|
||||
def _stamps_get_shippings_for_picking(self, service, picking):
|
||||
ret = []
|
||||
company, from_partner, to_partner = self._stamps_get_addresses_for_picking(picking)
|
||||
if not all((from_partner.zip, to_partner.zip)):
|
||||
raise ValidationError('Stamps needs ZIP. From: ' + str(from_partner.zip) + ' To: ' + str(to_partner.zip))
|
||||
|
||||
for package in picking.package_ids:
|
||||
weight = self._stamps_convert_weight(package.shipping_weight)
|
||||
|
||||
ret_val = service.create_shipping()
|
||||
ret_val.ShipDate = date.today().isoformat()
|
||||
ret_val.FromZIPCode = from_partner.zip
|
||||
ret_val.ToZIPCode = to_partner.zip
|
||||
ret_val.PackageType = self._stamps_package_type(package=package)
|
||||
ret_val.ServiceType = self.stamps_service_type
|
||||
ret_val.WeightLb = weight
|
||||
ret.append((package.name + ret_val.ShipDate + str(ret_val.WeightLb), ret_val))
|
||||
if not ret:
|
||||
weight = self._stamps_convert_weight(picking.shipping_weight)
|
||||
|
||||
ret_val = service.create_shipping()
|
||||
ret_val.ShipDate = date.today().isoformat()
|
||||
ret_val.FromZIPCode = from_partner.zip
|
||||
ret_val.ToZIPCode = to_partner.zip
|
||||
ret_val.PackageType = self._stamps_package_type()
|
||||
ret_val.ServiceType = self.stamps_service_type
|
||||
ret_val.WeightLb = weight
|
||||
ret.append((picking.name + ret_val.ShipDate + str(ret_val.WeightLb), ret_val))
|
||||
|
||||
return ret
|
||||
|
||||
def stamps_get_shipping_price_from_so(self, orders):
|
||||
res = self.stamps_get_shipping_price_for_plan(orders, date.today().isoformat())
|
||||
return map(lambda r: r[0] if r else 0.0, res)
|
||||
|
||||
def stamps_get_shipping_price_for_plan(self, orders, date_planned):
|
||||
res = []
|
||||
service = self._get_stamps_service()
|
||||
|
||||
for order in orders:
|
||||
shipping = self._get_stamps_shipping_for_order(service, order, date_planned)
|
||||
rates = service.get_rates(shipping)
|
||||
if rates and len(rates) >= 1:
|
||||
rate = rates[0]
|
||||
price = float(rate.Amount)
|
||||
|
||||
if order.currency_id.name != 'USD':
|
||||
quote_currency = self.env['res.currency'].search([('name', '=', 'USD')], limit=1)
|
||||
price = quote_currency.compute(rate.Amount, order.currency_id)
|
||||
|
||||
delivery_days = rate.DeliverDays
|
||||
if delivery_days.find('-') >= 0:
|
||||
delivery_days = delivery_days.split('-')
|
||||
transit_days = int(delivery_days[-1])
|
||||
else:
|
||||
transit_days = int(delivery_days)
|
||||
date_delivered = None
|
||||
if date_planned and transit_days > 0:
|
||||
date_delivered = self.calculate_date_delivered(date_planned, transit_days)
|
||||
|
||||
res = res + [(price, transit_days, date_delivered)]
|
||||
continue
|
||||
res = res + [(0.0, 0, None)]
|
||||
return res
|
||||
|
||||
def stamps_send_shipping(self, pickings):
|
||||
res = []
|
||||
service = self._get_stamps_service()
|
||||
|
||||
for picking in pickings:
|
||||
package_labels = []
|
||||
|
||||
shippings = self._stamps_get_shippings_for_picking(service, picking)
|
||||
company, from_partner, to_partner = self._stamps_get_addresses_for_picking(picking)
|
||||
|
||||
from_address = service.create_address()
|
||||
from_address.FullName = company.partner_id.name
|
||||
from_address.Address1 = from_partner.street
|
||||
if from_partner.street2:
|
||||
from_address.Address2 = from_partner.street2
|
||||
from_address.City = from_partner.city
|
||||
from_address.State = from_partner.state_id.code
|
||||
from_address = service.get_address(from_address).Address
|
||||
|
||||
to_address = service.create_address()
|
||||
to_address.FullName = to_partner.name
|
||||
to_address.Address1 = to_partner.street
|
||||
if to_partner.street2:
|
||||
to_address.Address2 = to_partner.street2
|
||||
to_address.City = to_partner.city
|
||||
to_address.State = to_partner.state_id.code
|
||||
to_address = service.get_address(to_address).Address
|
||||
|
||||
try:
|
||||
for txn_id, shipping in shippings:
|
||||
rates = service.get_rates(shipping)
|
||||
if rates and len(rates) >= 1:
|
||||
rate = rates[0]
|
||||
shipping.Amount = rate.Amount
|
||||
shipping.ServiceType = rate.ServiceType
|
||||
shipping.DeliverDays = rate.DeliverDays
|
||||
shipping.DimWeighting = rate.DimWeighting
|
||||
shipping.Zone = rate.Zone
|
||||
shipping.RateCategory = rate.RateCategory
|
||||
shipping.ToState = rate.ToState
|
||||
add_on = service.create_add_on()
|
||||
add_on.AddOnType = 'US-A-DC'
|
||||
add_on2 = service.create_add_on()
|
||||
add_on2.AddOnType = 'SC-A-HP'
|
||||
shipping.AddOns.AddOnV7 = [add_on, add_on2]
|
||||
label = service.get_label(from_address, to_address, shipping,
|
||||
transaction_id=txn_id, image_type=self.stamps_image_type)
|
||||
package_labels.append((txn_id, label))
|
||||
# self.service.get_tracking(label.StampsTxID)
|
||||
# self.service.get_tracking(label.TrackingNumber)
|
||||
# self.service.remove_label(label.StampsTxID)
|
||||
# print label
|
||||
except WebFault as e:
|
||||
_logger.warn(e)
|
||||
if package_labels:
|
||||
for name, label in package_labels:
|
||||
body = 'Cancelling due to error: ' + str(label.TrackingNumber)
|
||||
try:
|
||||
service.remove_label(label.TrackingNumber)
|
||||
except WebFault as e:
|
||||
raise ValidationError(e)
|
||||
else:
|
||||
picking.message_post(body=body)
|
||||
raise ValidationError('Error on full shipment. Attempted to cancel any previously shipped.')
|
||||
raise ValidationError('Error on shipment. ' + str(e))
|
||||
else:
|
||||
carrier_price = 0.0
|
||||
tracking_numbers = []
|
||||
for name, label in package_labels:
|
||||
body = 'Shipment created into Stamps.com <br/> <b>Tracking Number : <br/>' + label.TrackingNumber + '</b>'
|
||||
tracking_numbers.append(label.TrackingNumber)
|
||||
carrier_price += float(label.Rate.Amount)
|
||||
url = label.URL
|
||||
|
||||
response = urlopen(url)
|
||||
attachment = response.read()
|
||||
picking.message_post(body=body, attachments=[('LabelStamps-%s.%s' % (label.TrackingNumber, self.stamps_image_type), attachment)])
|
||||
shipping_data = {'exact_price': carrier_price, 'tracking_number': ','.join(tracking_numbers)}
|
||||
res = res + [shipping_data]
|
||||
return res
|
||||
|
||||
def stamps_get_tracking_link(self, pickings):
|
||||
res = []
|
||||
for picking in pickings:
|
||||
ref = picking.carrier_tracking_ref
|
||||
res = res + ['https://tools.usps.com/go/TrackConfirmAction_input?qtc_tLabels1=%s' % ref]
|
||||
return res
|
||||
|
||||
def stamps_cancel_shipment(self, picking):
|
||||
service = self._get_stamps_service()
|
||||
try:
|
||||
service.remove_label(picking.carrier_tracking_ref)
|
||||
picking.message_post(body=_(u'Shipment N° %s has been cancelled' % picking.carrier_tracking_ref))
|
||||
picking.write({'carrier_tracking_ref': '',
|
||||
'carrier_price': 0.0})
|
||||
except WebFault as e:
|
||||
raise ValidationError(e)
|
||||
28
delivery_stamps/views/delivery_stamps_view.xml
Normal file
28
delivery_stamps/views/delivery_stamps_view.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_delivery_carrier_form_with_provider_stamps" model="ir.ui.view">
|
||||
<field name="name">delivery.carrier.form.provider.stamps</field>
|
||||
<field name="model">delivery.carrier</field>
|
||||
<field name="inherit_id" ref="delivery.view_delivery_carrier_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page[@name='destination']" position='before'>
|
||||
<page string="Stamps.com Configuration" attrs="{'invisible': [('delivery_type', '!=', 'stamps')]}">
|
||||
<group>
|
||||
<group>
|
||||
<field name="stamps_integration_id" attrs="{'required': [('delivery_type', '=', 'stamps')]}" />
|
||||
<field name="stamps_username" attrs="{'required': [('delivery_type', '=', 'stamps')]}" />
|
||||
<field name="stamps_password" attrs="{'required': [('delivery_type', '=', 'stamps')]}" password="True"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="stamps_service_type" attrs="{'required': [('delivery_type', '==', 'stamps')]}"/>
|
||||
<field name="stamps_default_packaging_id" attrs="{'required': [('delivery_type', '==', 'stamps')]}"/>
|
||||
<field name="stamps_image_type" attrs="{'required': [('delivery_type', '==', 'stamps')]}"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
2
external/hibou-oca/account-analytic
vendored
2
external/hibou-oca/account-analytic
vendored
Submodule external/hibou-oca/account-analytic updated: 7e06779e23...496db65e9d
1
external/hibou-oca/bank-payment
vendored
Submodule
1
external/hibou-oca/bank-payment
vendored
Submodule
Submodule external/hibou-oca/bank-payment added at 634492de89
1
external/hibou-oca/connector
vendored
Submodule
1
external/hibou-oca/connector
vendored
Submodule
Submodule external/hibou-oca/connector added at 601f0dbda3
1
external/hibou-oca/connector-ecommerce
vendored
Submodule
1
external/hibou-oca/connector-ecommerce
vendored
Submodule
Submodule external/hibou-oca/connector-ecommerce added at de8c1911b1
1
external/hibou-oca/connector-magento
vendored
Submodule
1
external/hibou-oca/connector-magento
vendored
Submodule
Submodule external/hibou-oca/connector-magento added at 8cce7538e6
1
external/hibou-oca/product-attribute
vendored
Submodule
1
external/hibou-oca/product-attribute
vendored
Submodule
Submodule external/hibou-oca/product-attribute added at b89f3a7199
1
external/hibou-oca/purchase-workflow
vendored
Submodule
1
external/hibou-oca/purchase-workflow
vendored
Submodule
Submodule external/hibou-oca/purchase-workflow added at 5973bb878b
1
external/hibou-oca/queue
vendored
Submodule
1
external/hibou-oca/queue
vendored
Submodule
Submodule external/hibou-oca/queue added at 56e8abbd24
1
external/hibou-oca/sale-workflow
vendored
Submodule
1
external/hibou-oca/sale-workflow
vendored
Submodule
Submodule external/hibou-oca/sale-workflow added at 4e3a5a0f90
2
external/hibou-oca/server-tools
vendored
2
external/hibou-oca/server-tools
vendored
Submodule external/hibou-oca/server-tools updated: 4bd10c5946...dab39ca53c
1
external/hibou-oca/stock-logistics-warehouse
vendored
Submodule
1
external/hibou-oca/stock-logistics-warehouse
vendored
Submodule
Submodule external/hibou-oca/stock-logistics-warehouse added at eb04e0ba5f
1
external/hibou-oca/stock-logistics-workflow
vendored
Submodule
1
external/hibou-oca/stock-logistics-workflow
vendored
Submodule
Submodule external/hibou-oca/stock-logistics-workflow added at cc0b36e76c
1
external/hibou-shipbox
vendored
Submodule
1
external/hibou-shipbox
vendored
Submodule
Submodule external/hibou-shipbox added at 61ec9b25ab
30
hr_holidays_accrual/README.rst
Normal file
30
hr_holidays_accrual/README.rst
Normal file
@@ -0,0 +1,30 @@
|
||||
***************************
|
||||
Hibou - HR Holidays Accrual
|
||||
***************************
|
||||
|
||||
Use Employee Tags to grant Leave Allocations.
|
||||
|
||||
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
|
||||
|
||||
=============
|
||||
Main Features
|
||||
=============
|
||||
|
||||
* Adds new boolean field `grant_by_tag` to Leave Allocations.
|
||||
* Allows user to create a Leave Allocation by employee tag, then use that tag to grant the newly created leave to as many employees as desired.
|
||||
|
||||
.. image:: https://user-images.githubusercontent.com/15882954/42062226-cf3ce23e-7ae1-11e8-96dc-43268c7b904c.png
|
||||
:alt: 'Equipment Detail'
|
||||
:width: 988
|
||||
:align: left
|
||||
|
||||
|
||||
|
||||
|
||||
=======
|
||||
License
|
||||
=======
|
||||
|
||||
Please see `LICENSE <https://github.com/hibou-io/hibou-odoo-suite/blob/11.0/LICENSE>`_.
|
||||
|
||||
Copyright Hibou Corp. 2018
|
||||
1
hr_holidays_accrual/__init__.py
Executable file
1
hr_holidays_accrual/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
18
hr_holidays_accrual/__manifest__.py
Executable file
18
hr_holidays_accrual/__manifest__.py
Executable file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
'name': 'HR Holidays Accrual',
|
||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||
'version': '11.0.0.0.0',
|
||||
'category': 'Human Resources',
|
||||
'sequence': 95,
|
||||
'summary': 'Grant leave allocations with tags',
|
||||
'description': """
|
||||
Create leave allocations by tag, then use tags to grant leaves to employees.
|
||||
""",
|
||||
'website': 'https://hibou.io/',
|
||||
'depends': ['hr_holidays'],
|
||||
'data': [
|
||||
'views/hr_holidays_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
}
|
||||
1
hr_holidays_accrual/models/__init__.py
Normal file
1
hr_holidays_accrual/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import hr_holidays
|
||||
54
hr_holidays_accrual/models/hr_holidays.py
Normal file
54
hr_holidays_accrual/models/hr_holidays.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class HRHolidays(models.Model):
|
||||
_inherit = 'hr.holidays'
|
||||
|
||||
grant_by_tag = fields.Boolean(string="Grant by Tag")
|
||||
|
||||
def _accrue_for_employee_values(self, employee):
|
||||
return {
|
||||
'holiday_status_id': self.holiday_status_id.id,
|
||||
'number_of_days_temp': self.number_of_days_temp,
|
||||
'holiday_type': 'employee',
|
||||
'employee_id': employee.id,
|
||||
'type': 'add',
|
||||
'state': 'confirm',
|
||||
'double_validation': self.double_validation,
|
||||
'grant_by_tag': self.grant_by_tag,
|
||||
}
|
||||
|
||||
def accrue_for_employee(self, employee):
|
||||
holidays = self.env['hr.holidays'].sudo()
|
||||
for leave_to_create in self:
|
||||
values = leave_to_create._accrue_for_employee_values(employee)
|
||||
if values:
|
||||
leave = holidays.create(values)
|
||||
leave.action_approve()
|
||||
|
||||
|
||||
class HREmployee(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
@api.multi
|
||||
def write(self, values):
|
||||
holidays = self.env['hr.holidays'].sudo()
|
||||
for emp in self:
|
||||
if values.get('category_ids'):
|
||||
categ_ids_command_list = values.get('category_ids')
|
||||
for categ_ids_command in categ_ids_command_list:
|
||||
ids = None
|
||||
if categ_ids_command[0] == 6:
|
||||
ids = set(categ_ids_command[2])
|
||||
ids -= set(emp.category_ids.ids)
|
||||
if categ_ids_command[0] == 4:
|
||||
id = categ_ids_command[1]
|
||||
if id not in emp.category_ids.ids:
|
||||
ids = [id]
|
||||
if ids:
|
||||
# new category ids
|
||||
leaves = holidays.search([('category_id', 'in', list(ids)),
|
||||
('grant_by_tag', '=', True)])
|
||||
leaves.accrue_for_employee(emp)
|
||||
|
||||
return super(HREmployee, self).write(values)
|
||||
1
hr_holidays_accrual/tests/__init__.py
Executable file
1
hr_holidays_accrual/tests/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
from . import test_leaves
|
||||
43
hr_holidays_accrual/tests/test_leaves.py
Normal file
43
hr_holidays_accrual/tests/test_leaves.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase
|
||||
|
||||
|
||||
class TestLeaves(TestHrHolidaysBase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestLeaves, self).setUp()
|
||||
|
||||
self.categ = self.env['hr.employee.category'].create({'name': 'Test Category'})
|
||||
department = self.env['hr.department'].create({'name': 'Test Department'})
|
||||
self.employee = self.env['hr.employee'].create({'name': 'Mark', 'department_id': department.id})
|
||||
self.leave_type = self.env['hr.holidays.status'].create({
|
||||
'name': 'Test Status',
|
||||
'color_name': 'red',
|
||||
})
|
||||
self.test_leave = self.env['hr.holidays'].create({
|
||||
'holiday_status_id': self.leave_type.id,
|
||||
'number_of_days_temp': 5,
|
||||
'holiday_type': 'category',
|
||||
'category_id': self.categ.id,
|
||||
'type': 'add',
|
||||
'state': 'draft',
|
||||
'grant_by_tag': True,
|
||||
})
|
||||
|
||||
def test_tag_assignment(self):
|
||||
self.test_leave.action_confirm()
|
||||
self.test_leave.action_approve()
|
||||
self.assertEqual(self.employee.leaves_count, 0.0)
|
||||
self.employee.write({'category_ids': [(6, False, [self.categ.id])]})
|
||||
self.assertEqual(self.employee.leaves_count, 5.0)
|
||||
leave = self.env['hr.holidays'].search([('employee_id', '=', self.employee.id)])
|
||||
self.assertEqual(leave.holiday_status_id.id, self.leave_type.id)
|
||||
|
||||
def test_double_validation(self):
|
||||
self.test_leave.write({'double_validation': True})
|
||||
self.test_leave.action_confirm()
|
||||
self.test_leave.action_approve()
|
||||
self.test_leave.action_validate()
|
||||
self.employee.write({'category_ids': [(6, False, [self.categ.id])]})
|
||||
leave = self.env['hr.holidays'].search([('employee_id', '=', self.employee.id)])
|
||||
self.assertEqual(leave.state, 'validate1')
|
||||
self.assertEqual(leave.first_approver_id.id, self.env.uid)
|
||||
15
hr_holidays_accrual/views/hr_holidays_views.xml
Normal file
15
hr_holidays_accrual/views/hr_holidays_views.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="hr_holidays_edit_holiday_new_inherit" model="ir.ui.view">
|
||||
<field name="name">hr.holidays.edit.holiday.new.inherit</field>
|
||||
<field name="model">hr.holidays</field>
|
||||
<field name="inherit_id" ref="hr_holidays.edit_holiday_new"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group/group[2]" position="after">
|
||||
<group name="accrue" attrs="{'invisible': [('type', '!=', 'add')]}">
|
||||
<field name="grant_by_tag" attrs="{'invisible': [('holiday_type', '=', 'employee')]}"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
30
hr_holidays_accrual_payroll/README.rst
Normal file
30
hr_holidays_accrual_payroll/README.rst
Normal file
@@ -0,0 +1,30 @@
|
||||
*************************************
|
||||
Hibou - HR Holidays Accrual - Payroll
|
||||
*************************************
|
||||
|
||||
Accrue employee leave allocations every pay period.
|
||||
|
||||
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
|
||||
|
||||
=============
|
||||
Main Features
|
||||
=============
|
||||
|
||||
* New Fields `accrue_by_pay_period` and `allocation_per_pay_period` on Leave allocations.
|
||||
* Can set up an accrual by individual employee, or make an Allocation by Employee Tag for multiple employees.
|
||||
|
||||
|
||||
.. image:: https://user-images.githubusercontent.com/15882954/42062853-f4175416-7ae3-11e8-8432-f54e26fe6094.png
|
||||
:alt: 'Equipment Detail'
|
||||
:width: 988
|
||||
:align: left
|
||||
|
||||
|
||||
|
||||
=======
|
||||
License
|
||||
=======
|
||||
|
||||
Please see `LICENSE <https://github.com/hibou-io/hibou-odoo-suite/blob/11.0/LICENSE>`_.
|
||||
|
||||
Copyright Hibou Corp. 2018
|
||||
1
hr_holidays_accrual_payroll/__init__.py
Executable file
1
hr_holidays_accrual_payroll/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
21
hr_holidays_accrual_payroll/__manifest__.py
Executable file
21
hr_holidays_accrual_payroll/__manifest__.py
Executable file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
'name': 'HR Holidays Accrual - Payroll',
|
||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||
'version': '11.0.1.0.0',
|
||||
'category': 'Human Resources',
|
||||
'sequence': 95,
|
||||
'summary': 'Grant leave allocations per payperiod',
|
||||
'description': """
|
||||
Automates leave allocations.
|
||||
""",
|
||||
'website': 'https://hibou.io/',
|
||||
'depends': [
|
||||
'hr_holidays_accrual',
|
||||
'hr_payroll'
|
||||
],
|
||||
'data': [
|
||||
'views/hr_holidays_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
}
|
||||
1
hr_holidays_accrual_payroll/models/__init__.py
Normal file
1
hr_holidays_accrual_payroll/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import hr_payslip
|
||||
47
hr_holidays_accrual_payroll/models/hr_payslip.py
Normal file
47
hr_holidays_accrual_payroll/models/hr_payslip.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class HRPayslip(models.Model):
|
||||
_inherit = 'hr.payslip'
|
||||
|
||||
@api.multi
|
||||
def action_payslip_done(self):
|
||||
res = super(HRPayslip, self).action_payslip_done()
|
||||
if res and isinstance(res, (int, bool)):
|
||||
holidays = self.env['hr.holidays'].sudo()
|
||||
leaves_to_update = holidays.search([('employee_id', '=', self.employee_id.id),
|
||||
('accrue_by_pay_period', '=', True)])
|
||||
for leave_to_update in leaves_to_update:
|
||||
new_allocation = leave_to_update.number_of_days_temp + leave_to_update.allocation_per_period
|
||||
q = """SELECT SUM(number_of_days)
|
||||
FROM hr_holidays
|
||||
WHERE employee_id = %d AND holiday_status_id = %d;""" % (leave_to_update.employee_id.id, leave_to_update.holiday_status_id.id)
|
||||
self.env.cr.execute(q)
|
||||
total_days = self.env.cr.fetchall()
|
||||
total_days = total_days[0][0]
|
||||
new_total_days = total_days + leave_to_update.allocation_per_period
|
||||
if leave_to_update.accrue_max and total_days > leave_to_update.accrue_max:
|
||||
new_allocation = leave_to_update.number_of_days_temp
|
||||
elif leave_to_update.accrue_max and new_total_days > leave_to_update.accrue_max:
|
||||
difference = leave_to_update.accrue_max - total_days
|
||||
new_allocation = leave_to_update.number_of_days_temp + difference
|
||||
if leave_to_update.number_of_days_temp != new_allocation:
|
||||
leave_to_update.write({'number_of_days_temp': new_allocation})
|
||||
|
||||
return res
|
||||
|
||||
|
||||
class HRHolidays(models.Model):
|
||||
_inherit = 'hr.holidays'
|
||||
|
||||
accrue_by_pay_period = fields.Boolean(string="Accrue by Pay Period")
|
||||
allocation_per_period = fields.Float(string="Allocation Per Pay Period", digits=(12, 4))
|
||||
accrue_max = fields.Float(string="Maximum Accrual")
|
||||
|
||||
def _accrue_for_employee_values(self, employee):
|
||||
values = super(HRHolidays, self)._accrue_for_employee_values(employee)
|
||||
if values:
|
||||
values['accrue_by_pay_period'] = self.accrue_by_pay_period
|
||||
values['allocation_per_period'] = self.allocation_per_period
|
||||
values['accrue_max'] = self.accrue_max
|
||||
return values
|
||||
1
hr_holidays_accrual_payroll/tests/__init__.py
Executable file
1
hr_holidays_accrual_payroll/tests/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
from . import test_accrual
|
||||
43
hr_holidays_accrual_payroll/tests/test_accrual.py
Normal file
43
hr_holidays_accrual_payroll/tests/test_accrual.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase
|
||||
|
||||
|
||||
class TestLeaves(TestHrHolidaysBase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestLeaves, self).setUp()
|
||||
|
||||
self.categ = self.env['hr.employee.category'].create({'name': 'Test Category'})
|
||||
department = self.env['hr.department'].create({'name': 'Test Department'})
|
||||
self.employee = self.env['hr.employee'].create({'name': 'Mark', 'department_id': department.id})
|
||||
self.leave_type = self.env['hr.holidays.status'].create({
|
||||
'name': 'Test Status',
|
||||
'color_name': 'red',
|
||||
})
|
||||
self.test_leave = self.env['hr.holidays'].create({
|
||||
'holiday_status_id': self.leave_type.id,
|
||||
'number_of_days_temp': 0,
|
||||
'holiday_type': 'category',
|
||||
'category_id': self.categ.id,
|
||||
'type': 'add',
|
||||
'state': 'draft',
|
||||
'grant_by_tag': True,
|
||||
})
|
||||
|
||||
def test_payslip_accrual(self):
|
||||
self.test_leave.write({
|
||||
'accrue_by_pay_period': True,
|
||||
'allocation_per_period': 1
|
||||
})
|
||||
self.test_leave.action_confirm()
|
||||
self.test_leave.action_approve()
|
||||
|
||||
self.employee.write({'category_ids': [(6, False, [self.categ.id])]})
|
||||
self.assertEqual(self.employee.leaves_count, 0.0)
|
||||
|
||||
payslip = self.env['hr.payslip'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'date_from': '2018-01-01',
|
||||
'date_to': '2018-01-31'
|
||||
})
|
||||
payslip.action_payslip_done()
|
||||
self.assertEqual(self.employee.leaves_count, 1.0)
|
||||
15
hr_holidays_accrual_payroll/views/hr_holidays_views.xml
Normal file
15
hr_holidays_accrual_payroll/views/hr_holidays_views.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="hr_holidays_edit_holiday_new_inherit_pay" model="ir.ui.view">
|
||||
<field name="name">hr.holidays.edit.holiday.new.inherit.pay</field>
|
||||
<field name="model">hr.holidays</field>
|
||||
<field name="inherit_id" ref="hr_holidays.edit_holiday_new"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group[@name='accrue']" position="inside">
|
||||
<field name="accrue_by_pay_period"/>
|
||||
<field name="allocation_per_period"/>
|
||||
<field name="accrue_max"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -11,7 +11,7 @@
|
||||
Adds the ability to register a payment on a payslip.
|
||||
""",
|
||||
'website': 'https://hibou.io/',
|
||||
'depends': ['hr_payroll_account'],
|
||||
'depends': ['hr_payroll_account', 'payment'],
|
||||
'data': [
|
||||
'hr_payroll_register_payment.xml',
|
||||
],
|
||||
|
||||
@@ -56,6 +56,12 @@ class HrPayrollRegisterPaymentWizard(models.TransientModel):
|
||||
journal_id = fields.Many2one('account.journal', string='Payment Method', required=True, domain=[('type', 'in', ('bank', 'cash'))])
|
||||
company_id = fields.Many2one('res.company', related='journal_id.company_id', string='Company', readonly=True, required=True)
|
||||
payment_method_id = fields.Many2one('account.payment.method', string='Payment Type', required=True)
|
||||
payment_method_code = fields.Char(related='payment_method_id.code',
|
||||
help="Technical field used to adapt the interface to the payment type selected.", readonly=True)
|
||||
payment_transaction_id = fields.Many2one('payment.transaction', string="Payment Transaction")
|
||||
payment_token_id = fields.Many2one('payment.token', string="Saved payment token",
|
||||
domain=[('acquirer_id.capture_manually', '=', False)],
|
||||
help="Note that tokens from acquirers set to only authorize transactions (instead of capturing the amount) are not available.")
|
||||
amount = fields.Monetary(string='Payment Amount', required=True, default=_default_amount)
|
||||
currency_id = fields.Many2one('res.currency', string='Currency', required=True, default=lambda self: self.env.user.company_id.currency_id)
|
||||
payment_date = fields.Date(string='Payment Date', default=fields.Date.context_today, required=True)
|
||||
@@ -63,6 +69,24 @@ class HrPayrollRegisterPaymentWizard(models.TransientModel):
|
||||
hide_payment_method = fields.Boolean(compute='_compute_hide_payment_method',
|
||||
help="Technical field used to hide the payment method if the selected journal has only one available which is 'manual'")
|
||||
|
||||
@api.onchange('partner_id')
|
||||
def _onchange_partner_id(self):
|
||||
res = {}
|
||||
if self.partner_id:
|
||||
partners = self.partner_id | self.partner_id.commercial_partner_id | self.partner_id.commercial_partner_id.child_ids
|
||||
res['domain'] = {
|
||||
'payment_token_id': [('partner_id', 'in', partners.ids), ('acquirer_id.capture_manually', '=', False)]}
|
||||
|
||||
return res
|
||||
|
||||
@api.onchange('payment_method_id', 'journal_id')
|
||||
def _onchange_payment_method(self):
|
||||
if self.payment_method_code == 'electronic':
|
||||
self.payment_token_id = self.env['payment.token'].search(
|
||||
[('partner_id', '=', self.partner_id.id), ('acquirer_id.capture_manually', '=', False)], limit=1)
|
||||
else:
|
||||
self.payment_token_id = False
|
||||
|
||||
@api.one
|
||||
@api.constrains('amount')
|
||||
def _check_amount(self):
|
||||
@@ -106,7 +130,9 @@ class HrPayrollRegisterPaymentWizard(models.TransientModel):
|
||||
'amount': self.amount,
|
||||
'currency_id': self.currency_id.id,
|
||||
'payment_date': self.payment_date,
|
||||
'communication': self.communication
|
||||
'communication': self.communication,
|
||||
'payment_transaction_id': self.payment_transaction_id.id if self.payment_transaction_id else False,
|
||||
'payment_token_id': self.payment_token_id.id if self.payment_token_id else False,
|
||||
})
|
||||
payment.post()
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
<field name="journal_id" widget="selection"/>
|
||||
<field name="hide_payment_method" invisible="1"/>
|
||||
<field name="payment_method_id" widget="radio" attrs="{'invisible': [('hide_payment_method', '=', True)]}"/>
|
||||
<field name="payment_method_code" invisible="1"/>
|
||||
<field name="payment_token_id" options="{'no_create': True}"
|
||||
attrs="{'invisible': [('payment_method_code', '!=', 'electronic')],
|
||||
'required': [('payment_method_code', '=', 'electronic')]}"/>
|
||||
<label for="amount"/>
|
||||
<div name="amount_div" class="o_row">
|
||||
<field name="amount"/>
|
||||
@@ -42,6 +46,7 @@
|
||||
<group>
|
||||
<field name="payment_date"/>
|
||||
<field name="communication"/>
|
||||
<field name="payment_transaction_id"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
|
||||
1
hr_workers_comp/__init__.py
Executable file
1
hr_workers_comp/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
from . import contract
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user