Merge branch '11.0' into 11.0-WIP-sale_planner

This commit is contained in:
Jared Kipe
2018-09-14 12:28:16 -07:00
267 changed files with 19175 additions and 23 deletions

33
.gitmodules vendored
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1 @@
./external/hibou-oca/bank-payment/account_payment_mode

1
account_payment_partner Symbolic link
View File

@@ -0,0 +1 @@
./external/hibou-oca/bank-payment/account_payment_partner

1
account_payment_sale Symbolic link
View File

@@ -0,0 +1 @@
./external/hibou-oca/bank-payment/account_payment_sale

View File

@@ -0,0 +1 @@
from . import models

View 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,
}

View File

@@ -0,0 +1,2 @@
from . import account_fiscal_position
from . import wa_tax_request

View 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='')

View 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

View File

@@ -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
auditlog Symbolic link
View File

@@ -0,0 +1 @@
external/hibou-oca/server-tools/auditlog

1
base_exception Symbolic link
View File

@@ -0,0 +1 @@
./external/hibou-oca/server-tools/base_exception

1
base_technical_user Symbolic link
View File

@@ -0,0 +1 @@
./external/hibou-oca/server-tools/base_technical_user

1
component Symbolic link
View File

@@ -0,0 +1 @@
./external/hibou-oca/connector/component

1
component_event Symbolic link
View File

@@ -0,0 +1 @@
./external/hibou-oca/connector/component_event

1
connector Symbolic link
View File

@@ -0,0 +1 @@
./external/hibou-oca/connector/connector

1
connector_base_product Symbolic link
View File

@@ -0,0 +1 @@
./external/hibou-oca/connector/connector_base_product

1
connector_ecommerce Symbolic link
View File

@@ -0,0 +1 @@
./external/hibou-oca/connector-ecommerce/connector_ecommerce

1
connector_magento Symbolic link
View File

@@ -0,0 +1 @@
./external/hibou-oca/connector-magento/connector_magento

View File

@@ -0,0 +1 @@
external/hibou-oca/connector-magento/connector_magento_product_by_sku

View File

@@ -0,0 +1,2 @@
from . import components
from . import models

View 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,
}

View File

@@ -0,0 +1,6 @@
from . import api
from . import backend_adapter
from . import binder
from . import importer
from . import exporter
from . import mapper

View File

@@ -0,0 +1 @@
from . import walmart

View 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)

View 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

View 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',
]

View 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

View 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)

View 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'

View 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>

View 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

View File

@@ -0,0 +1 @@
from . import account_fiscal_position

View 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

View File

@@ -0,0 +1 @@
from . import common

View 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,
)

View File

@@ -0,0 +1,2 @@
from . import common
from . import importer

View 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.')

View 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}

View File

@@ -0,0 +1,2 @@
from . import common
from . import exporter

View 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,
})

View 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)

View File

@@ -0,0 +1 @@
from . import common

View 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})

View File

@@ -0,0 +1 @@
from . import common

View 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)

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_walmart_backend walmart_backend connector manager model_walmart_backend connector.group_connector_manager 1 1 1 1
3 access_walmart_binding walmart_binding connector manager model_walmart_binding connector.group_connector_manager 1 1 1 1
4 access_walmart_sale_order walmart_sale_order connector manager model_walmart_sale_order connector.group_connector_manager 1 1 1 1
5 access_walmart_sale_order_line walmart_sale_order_line connector manager model_walmart_sale_order_line connector.group_connector_manager 1 1 1 1
6 access_walmart_stock_picking walmart_stock_picking connector manager model_walmart_stock_picking connector.group_connector_manager 1 1 1 1
7 access_walmart_sale_order_sale_salesman walmart_sale_order model_walmart_sale_order sales_team.group_sale_salesman 1 0 0 0
8 access_walmart_sale_order_sale_manager walmart_sale_order model_walmart_sale_order sales_team.group_sale_manager 1 1 1 1
9 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
10 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
11 access_walmart_sale_order_stock_user walmart_sale_order warehouse user model_walmart_sale_order stock.group_stock_user 1 0 0 0
12 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
13 access_walmart_backend_user walmart_backend user model_walmart_backend sales_team.group_sale_salesman 1 0 0 0
14 access_walmart_stock_picking_user walmart_stock_picking user model_walmart_stock_picking sales_team.group_sale_salesman 1 1 1 0

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1 @@
from . import models

View 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,
}

View File

@@ -0,0 +1 @@
from . import delivery_stamps

View 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.

View 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"

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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)

View 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>

1
external/hibou-oca/connector vendored Submodule

1
external/hibou-oca/queue vendored Submodule

1
external/hibou-shipbox vendored Submodule

Submodule external/hibou-shipbox added at 61ec9b25ab

View 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

View File

@@ -0,0 +1 @@
from . import models

View 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,
}

View File

@@ -0,0 +1 @@
from . import hr_holidays

View 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)

View File

@@ -0,0 +1 @@
from . import test_leaves

View 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)

View 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>

View 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

View File

@@ -0,0 +1 @@
from . import models

View 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,
}

View File

@@ -0,0 +1 @@
from . import hr_payslip

View 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

View File

@@ -0,0 +1 @@
from . import test_accrual

View 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)

View 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>

View File

@@ -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',
],

View File

@@ -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()

View File

@@ -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
View File

@@ -0,0 +1 @@
from . import contract

Some files were not shown because too many files have changed in this diff Show More