diff --git a/.gitmodules b/.gitmodules
index e3c0dec6..ed7e540e 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -29,3 +29,9 @@
[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
diff --git a/account_us_wa_salestax/__init__.py b/account_us_wa_salestax/__init__.py
new file mode 100644
index 00000000..0650744f
--- /dev/null
+++ b/account_us_wa_salestax/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/account_us_wa_salestax/__manifest__.py b/account_us_wa_salestax/__manifest__.py
new file mode 100644
index 00000000..3bf44876
--- /dev/null
+++ b/account_us_wa_salestax/__manifest__.py
@@ -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,
+ }
diff --git a/account_us_wa_salestax/models/__init__.py b/account_us_wa_salestax/models/__init__.py
new file mode 100644
index 00000000..d0e78197
--- /dev/null
+++ b/account_us_wa_salestax/models/__init__.py
@@ -0,0 +1,2 @@
+from . import account_fiscal_position
+from . import wa_tax_request
diff --git a/account_us_wa_salestax/models/account_fiscal_position.py b/account_us_wa_salestax/models/account_fiscal_position.py
new file mode 100644
index 00000000..cdaa0738
--- /dev/null
+++ b/account_us_wa_salestax/models/account_fiscal_position.py
@@ -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='')
diff --git a/account_us_wa_salestax/models/wa_tax_request.py b/account_us_wa_salestax/models/wa_tax_request.py
new file mode 100644
index 00000000..e9f0242f
--- /dev/null
+++ b/account_us_wa_salestax/models/wa_tax_request.py
@@ -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
diff --git a/account_us_wa_salestax/views/account_fiscal_position_view.xml b/account_us_wa_salestax/views/account_fiscal_position_view.xml
new file mode 100644
index 00000000..59ae7989
--- /dev/null
+++ b/account_us_wa_salestax/views/account_fiscal_position_view.xml
@@ -0,0 +1,28 @@
+
+
+
+
+ account.fiscal.position.form.inherit
+ account.fiscal.position
+
+
+
+
+
+
+
+
+
+
+ account.tax.form.inherit
+ account.tax
+
+
+
+
+
+
+
+
+
+
diff --git a/auditlog b/auditlog
new file mode 120000
index 00000000..7d9f602f
--- /dev/null
+++ b/auditlog
@@ -0,0 +1 @@
+external/hibou-oca/server-tools/auditlog
\ No newline at end of file
diff --git a/connector_magento_product_by_sku b/connector_magento_product_by_sku
new file mode 120000
index 00000000..1b7f87bd
--- /dev/null
+++ b/connector_magento_product_by_sku
@@ -0,0 +1 @@
+external/hibou-oca/connector-magento/connector_magento_product_by_sku
\ No newline at end of file
diff --git a/delivery_stamps/__init__.py b/delivery_stamps/__init__.py
new file mode 100644
index 00000000..0650744f
--- /dev/null
+++ b/delivery_stamps/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/delivery_stamps/__manifest__.py b/delivery_stamps/__manifest__.py
new file mode 100644
index 00000000..6a16401d
--- /dev/null
+++ b/delivery_stamps/__manifest__.py
@@ -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,
+}
diff --git a/delivery_stamps/models/__init__.py b/delivery_stamps/models/__init__.py
new file mode 100644
index 00000000..d675855d
--- /dev/null
+++ b/delivery_stamps/models/__init__.py
@@ -0,0 +1 @@
+from . import delivery_stamps
diff --git a/delivery_stamps/models/api/LICENSE b/delivery_stamps/models/api/LICENSE
new file mode 100755
index 00000000..7276b0b2
--- /dev/null
+++ b/delivery_stamps/models/api/LICENSE
@@ -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.
diff --git a/delivery_stamps/models/api/__init__.py b/delivery_stamps/models/api/__init__.py
new file mode 100755
index 00000000..0654679c
--- /dev/null
+++ b/delivery_stamps/models/api/__init__.py
@@ -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"
diff --git a/delivery_stamps/models/api/config.py b/delivery_stamps/models/api/config.py
new file mode 100755
index 00000000..4665780f
--- /dev/null
+++ b/delivery_stamps/models/api/config.py
@@ -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
diff --git a/delivery_stamps/models/api/services.py b/delivery_stamps/models/api/services.py
new file mode 100755
index 00000000..1f7b350a
--- /dev/null
+++ b/delivery_stamps/models/api/services.py
@@ -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
diff --git a/delivery_stamps/models/api/tests.py b/delivery_stamps/models/api/tests.py
new file mode 100755
index 00000000..6a306ccd
--- /dev/null
+++ b/delivery_stamps/models/api/tests.py
@@ -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
diff --git a/delivery_stamps/models/api/wsdls/stamps_v49.test.wsdl b/delivery_stamps/models/api/wsdls/stamps_v49.test.wsdl
new file mode 100755
index 00000000..7fde531e
--- /dev/null
+++ b/delivery_stamps/models/api/wsdls/stamps_v49.test.wsdl
@@ -0,0 +1,3381 @@
+
+
+ Stamps.com Web Services for Individual Meters (SWS/IM) Version 49
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Get list of shipments.
+
+
+
+
+ Set CodeWord information
+
+
+
+
+ Register a new Stamps.com account.
+
+
+
+
+ Generate an envelope indicium.
+
+
+
+
+ Generate an indicium.
+
+
+
+
+ Generate an unfunded indicium.
+
+
+
+
+ Generate a mailing label sheet.
+
+
+
+
+ Generate NetStamps indicia.
+
+
+
+
+ Calculate a rate or a list of rates.
+
+
+
+
+ Request carrier pickup from USPS.
+
+
+
+
+ Change Plan.
+
+
+
+
+ Set auto-buy settings
+
+
+
+
+ Generate a SCAN form.
+
+
+
+
+ Return the list of available CodeWord types.
+
+
+
+
+ Cleanse an address.
+
+
+
+
+ Get list of shipments.
+
+
+
+
+ Get URL for a Stamps.com web page.
+
+
+
+
+ Recover Username.
+
+
+
+
+ Get list of supported countries.
+
+
+
+
+ Change Password.
+
+
+
+
+ Price Store Orders.
+
+
+
+
+ Place Store Orders.
+
+
+
+
+ Get NetStamps Images.
+
+
+
+
+ Get status of plan change.
+
+
+
+
+ Purchase additional postage.
+
+
+
+
+ Resubmit Purchase.
+
+
+
+
+ Get list of NetStamps layouts.
+
+
+
+
+ Get list of cost codes.
+
+
+
+
+ Authenticate with transfer authenticator.
+
+
+
+
+ Cancel a previously issued indicium.
+
+
+
+
+ Start a password reset by sending a temporary password to the e-mail address on file.
+
+
+
+
+ Finish a password reset, setting the permanent password to a new password.
+
+
+
+
+ Retrieve codeword questions for user for starting password reset.
+
+
+
+
+ Void an unfunded indicium.
+
+
+
+
+ Fund an unfunded indicium.
+
+
+
+
+ Initial authentication.
+
+
+
+
+ Get account information, including postage balance.
+
+
+
+
+ Get status of postage purchase.
+
+
+
+
+ Get tracking events for shipment.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Stamps.com Web Services for Individual Meters (SWS/IM) Version 49
+
+
+
+
+
+
+
+
diff --git a/delivery_stamps/models/api/wsdls/stamps_v49.wsdl b/delivery_stamps/models/api/wsdls/stamps_v49.wsdl
new file mode 100755
index 00000000..d8948f7e
--- /dev/null
+++ b/delivery_stamps/models/api/wsdls/stamps_v49.wsdl
@@ -0,0 +1,3381 @@
+
+
+ Stamps.com Web Services for Individual Meters (SWS/IM) Version 49
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Get list of shipments.
+
+
+
+
+ Set CodeWord information
+
+
+
+
+ Register a new Stamps.com account.
+
+
+
+
+ Generate an envelope indicium.
+
+
+
+
+ Generate an indicium.
+
+
+
+
+ Generate an unfunded indicium.
+
+
+
+
+ Generate a mailing label sheet.
+
+
+
+
+ Generate NetStamps indicia.
+
+
+
+
+ Calculate a rate or a list of rates.
+
+
+
+
+ Request carrier pickup from USPS.
+
+
+
+
+ Change Plan.
+
+
+
+
+ Set auto-buy settings
+
+
+
+
+ Generate a SCAN form.
+
+
+
+
+ Return the list of available CodeWord types.
+
+
+
+
+ Cleanse an address.
+
+
+
+
+ Get list of shipments.
+
+
+
+
+ Get URL for a Stamps.com web page.
+
+
+
+
+ Recover Username.
+
+
+
+
+ Get list of supported countries.
+
+
+
+
+ Change Password.
+
+
+
+
+ Price Store Orders.
+
+
+
+
+ Place Store Orders.
+
+
+
+
+ Get NetStamps Images.
+
+
+
+
+ Get status of plan change.
+
+
+
+
+ Purchase additional postage.
+
+
+
+
+ Resubmit Purchase.
+
+
+
+
+ Get list of NetStamps layouts.
+
+
+
+
+ Get list of cost codes.
+
+
+
+
+ Authenticate with transfer authenticator.
+
+
+
+
+ Cancel a previously issued indicium.
+
+
+
+
+ Start a password reset by sending a temporary password to the e-mail address on file.
+
+
+
+
+ Finish a password reset, setting the permanent password to a new password.
+
+
+
+
+ Retrieve codeword questions for user for starting password reset.
+
+
+
+
+ Void an unfunded indicium.
+
+
+
+
+ Fund an unfunded indicium.
+
+
+
+
+ Initial authentication.
+
+
+
+
+ Get account information, including postage balance.
+
+
+
+
+ Get status of postage purchase.
+
+
+
+
+ Get tracking events for shipment.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Stamps.com Web Services for Individual Meters (SWS/IM) Version 49
+
+
+
+
+
+
+
+
diff --git a/delivery_stamps/models/delivery_stamps.py b/delivery_stamps/models/delivery_stamps.py
new file mode 100644
index 00000000..9965320d
--- /dev/null
+++ b/delivery_stamps/models/delivery_stamps.py
@@ -0,0 +1,297 @@
+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:
+ # has product with usps_exclude
+ if sum(1 for l in order.order_line if l.product_id.usps_exclude):
+ res.append(None)
+ continue
+
+ 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
Tracking Number :
' + label.TrackingNumber + ''
+ 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)
diff --git a/delivery_stamps/views/delivery_stamps_view.xml b/delivery_stamps/views/delivery_stamps_view.xml
new file mode 100644
index 00000000..bb1d07f2
--- /dev/null
+++ b/delivery_stamps/views/delivery_stamps_view.xml
@@ -0,0 +1,28 @@
+
+
+
+
+ delivery.carrier.form.provider.stamps
+ delivery.carrier
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/external/hibou-oca/purchase-workflow b/external/hibou-oca/purchase-workflow
new file mode 160000
index 00000000..5973bb87
--- /dev/null
+++ b/external/hibou-oca/purchase-workflow
@@ -0,0 +1 @@
+Subproject commit 5973bb878baba5656d08067079e07edd1ec6accc
diff --git a/external/hibou-oca/stock-logistics-workflow b/external/hibou-oca/stock-logistics-workflow
new file mode 160000
index 00000000..cc0b36e7
--- /dev/null
+++ b/external/hibou-oca/stock-logistics-workflow
@@ -0,0 +1 @@
+Subproject commit cc0b36e76c9ac0e508ef69edbc5572aaa72030e0
diff --git a/mrp_production_add/README.rst b/mrp_production_add/README.rst
new file mode 100755
index 00000000..7032b4b6
--- /dev/null
+++ b/mrp_production_add/README.rst
@@ -0,0 +1,26 @@
+*******************************
+Hibou - MRP Production Add Item
+*******************************
+
+Allows a user to add a new item to an in-progress Manufacturing Order (including generating PO procurements).
+For more information and add-ons, visit `Hibou.io `_.
+
+.. image:: https://cloud.githubusercontent.com/assets/744550/20810612/2f3eb514-b7bf-11e6-838f-6d6efb8f7484.png
+ :alt: 'MRP Production Add'
+ :width: 988
+ :align: left
+=============
+Main Features
+=============
+
+* Button above existing Consumed Materials to add new product.
+* Uses existing procurement group and routes to procure additional items.
+
+
+=======
+Licence
+=======
+
+Please see `LICENSE `_.
+
+Copyright Hibou Corp. 2016.
\ No newline at end of file
diff --git a/mrp_production_add/__init__.py b/mrp_production_add/__init__.py
new file mode 100755
index 00000000..40272379
--- /dev/null
+++ b/mrp_production_add/__init__.py
@@ -0,0 +1 @@
+from . import wizard
diff --git a/mrp_production_add/__manifest__.py b/mrp_production_add/__manifest__.py
new file mode 100755
index 00000000..0cd07a33
--- /dev/null
+++ b/mrp_production_add/__manifest__.py
@@ -0,0 +1,17 @@
+{
+ 'name': 'MRP Production Add Item',
+ 'author': 'Hibou Corp. ',
+ 'version': '11.0.1.0.0',
+ 'category': 'Manufacturing',
+ 'summary': 'Add Items to an existing Production',
+ 'description': """
+This module allows a production order to add additional items that are not on the product's BoM.
+ """,
+ 'website': 'https://hibou.io/',
+ 'depends': ['mrp'],
+ 'data': [
+ 'wizard/additem_wizard_view.xml',
+ 'views/mrp_production.xml',
+ ],
+ 'installable': True,
+}
diff --git a/mrp_production_add/views/mrp_production.xml b/mrp_production_add/views/mrp_production.xml
new file mode 100755
index 00000000..73c4aea1
--- /dev/null
+++ b/mrp_production_add/views/mrp_production.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ mrp.production.add_production_item.form.view
+ mrp.production
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mrp_production_add/wizard/__init__.py b/mrp_production_add/wizard/__init__.py
new file mode 100755
index 00000000..e895c7af
--- /dev/null
+++ b/mrp_production_add/wizard/__init__.py
@@ -0,0 +1 @@
+from . import additem_wizard
diff --git a/mrp_production_add/wizard/additem_wizard.py b/mrp_production_add/wizard/additem_wizard.py
new file mode 100755
index 00000000..5a9c8b18
--- /dev/null
+++ b/mrp_production_add/wizard/additem_wizard.py
@@ -0,0 +1,50 @@
+from odoo import api, fields, models
+from odoo.addons import decimal_precision as dp
+from odoo.exceptions import UserError
+
+
+class AddProductionItem(models.TransientModel):
+ _name = 'wiz.add.production.item'
+ _description = 'Add Production Item'
+
+ @api.model
+ def _default_production_id(self):
+ return self.env.context.get('active_id', False)
+
+ product_id = fields.Many2one('product.product', 'Product', required=True)
+ product_qty = fields.Float(
+ 'Product Quantity', digits=dp.get_precision('Product Unit of Measure'),
+ required=True,
+ default=1.0)
+ product_uom_id = fields.Many2one('product.uom', 'Unit of Measure')
+ production_id = fields.Many2one(
+ 'mrp.production', 'Production Order',
+ default=_default_production_id)
+
+ @api.onchange('product_id')
+ def _onchange_product_id(self):
+ for item in self:
+ if item.product_id:
+ item.product_uom_id = item.product_id.uom_id
+ else:
+ item.product_uom_id = False
+
+ @api.multi
+ def add_item(self):
+ for item in self:
+ if item.product_qty <= 0:
+ raise UserError('Please provide a positive quantity to add')
+
+ bom_line = self.env['mrp.bom.line'].new({
+ 'product_id': item.product_id.id,
+ 'product_qty': item.product_qty,
+ 'bom_id': item.production_id.bom_id.id,
+ 'product_uom_id': item.product_uom_id.id,
+ })
+
+ move = item.production_id._generate_raw_move(bom_line, {'qty': item.product_qty, 'parent_line': None})
+ item.production_id._adjust_procure_method()
+ move.write({'unit_factor': 0.0})
+ move._action_confirm()
+
+ return True
diff --git a/mrp_production_add/wizard/additem_wizard_view.xml b/mrp_production_add/wizard/additem_wizard_view.xml
new file mode 100755
index 00000000..5c766775
--- /dev/null
+++ b/mrp_production_add/wizard/additem_wizard_view.xml
@@ -0,0 +1,38 @@
+
+
+
+ view.create.add_production_item
+ wiz.add.production.item
+ form
+
+
+
+
+
+ Add Item View
+ wiz.add.production.item
+ form
+ form
+
+ new
+
+
\ No newline at end of file
diff --git a/product_catch_weight/__init__.py b/product_catch_weight/__init__.py
new file mode 100644
index 00000000..0650744f
--- /dev/null
+++ b/product_catch_weight/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/product_catch_weight/__manifest__.py b/product_catch_weight/__manifest__.py
new file mode 100644
index 00000000..76d3cea2
--- /dev/null
+++ b/product_catch_weight/__manifest__.py
@@ -0,0 +1,20 @@
+{
+ 'name': 'Product Catch Weight',
+ 'version': '11.0.1.0.0',
+ 'category': 'Warehouse',
+ 'depends': [
+ 'sale_stock',
+ 'purchase',
+ ],
+ 'description': """
+ """,
+ 'author': 'Hibou Corp.',
+ 'license': 'AGPL-3',
+ 'website': 'https://hibou.io/',
+ 'data': [
+ 'views/account_invoice_views.xml',
+ 'views/stock_views.xml',
+ ],
+ 'installable': True,
+ 'application': False,
+}
diff --git a/product_catch_weight/models/__init__.py b/product_catch_weight/models/__init__.py
new file mode 100644
index 00000000..5e099bc5
--- /dev/null
+++ b/product_catch_weight/models/__init__.py
@@ -0,0 +1,4 @@
+from . import account_invoice
+from . import product
+from . import stock_patch
+from . import stock
diff --git a/product_catch_weight/models/account_invoice.py b/product_catch_weight/models/account_invoice.py
new file mode 100644
index 00000000..ee8c9f92
--- /dev/null
+++ b/product_catch_weight/models/account_invoice.py
@@ -0,0 +1,48 @@
+from odoo import api, fields, models
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+class AccountInvoiceLine(models.Model):
+ _inherit = 'account.invoice.line'
+
+ catch_weight = fields.Float(string='Catch Weight', digits=(10, 4), compute='_compute_price', store=True)
+ catch_weight_uom_id = fields.Many2one('product.uom', related='product_id.catch_weight_uom_id')
+
+ @api.one
+ @api.depends('price_unit', 'discount', 'invoice_line_tax_ids', 'quantity',
+ 'product_id', 'invoice_id.partner_id', 'invoice_id.currency_id', 'invoice_id.company_id',
+ 'invoice_id.date_invoice', 'invoice_id.date')
+ def _compute_price(self):
+ currency = self.invoice_id and self.invoice_id.currency_id or None
+ price = self.price_unit * (1 - (self.discount or 0.0) / 100.0)
+
+ ratio = 1.0
+ qty_done_total = 0.0
+ catch_weight = 0.0
+ if self.invoice_id.type in ('out_invoice', 'out_refund'):
+ move_lines = self.sale_line_ids.mapped('move_ids.move_line_ids')
+ else:
+ move_lines = self.purchase_line_id.mapped('move_ids.move_line_ids')
+ for move_line in move_lines:
+ qty_done = move_line.qty_done
+ r = move_line.lot_id.catch_weight_ratio
+ ratio = ((ratio * qty_done_total) + (qty_done * r)) / (qty_done + qty_done_total)
+ qty_done_total += qty_done
+ catch_weight += move_line.lot_id.catch_weight
+ price = price * ratio
+ self.catch_weight = catch_weight
+
+ taxes = False
+ if self.invoice_line_tax_ids:
+ taxes = self.invoice_line_tax_ids.compute_all(price, currency, self.quantity, product=self.product_id,
+ partner=self.invoice_id.partner_id)
+ self.price_subtotal = price_subtotal_signed = taxes['total_excluded'] if taxes else self.quantity * price
+ self.price_total = taxes['total_included'] if taxes else self.price_subtotal
+ if self.invoice_id.currency_id and self.invoice_id.currency_id != self.invoice_id.company_id.currency_id:
+ price_subtotal_signed = self.invoice_id.currency_id.with_context(
+ date=self.invoice_id._get_currency_rate_date()).compute(price_subtotal_signed,
+ self.invoice_id.company_id.currency_id)
+ sign = self.invoice_id.type in ['in_refund', 'out_refund'] and -1 or 1
+ self.price_subtotal_signed = price_subtotal_signed * sign
\ No newline at end of file
diff --git a/product_catch_weight/models/product.py b/product_catch_weight/models/product.py
new file mode 100644
index 00000000..16cb4b91
--- /dev/null
+++ b/product_catch_weight/models/product.py
@@ -0,0 +1,7 @@
+from odoo import api, fields, models
+
+
+class ProductProduct(models.Model):
+ _inherit = 'product.template'
+
+ catch_weight_uom_id = fields.Many2one('product.uom', string='Catch Weight UOM')
diff --git a/product_catch_weight/models/stock.py b/product_catch_weight/models/stock.py
new file mode 100644
index 00000000..1ad5d3b6
--- /dev/null
+++ b/product_catch_weight/models/stock.py
@@ -0,0 +1,46 @@
+from odoo import api, fields, models
+
+
+class StockProductionLot(models.Model):
+ _inherit = 'stock.production.lot'
+
+ catch_weight_ratio = fields.Float(string='Catch Weight Ratio', digits=(10, 6), compute='_compute_catch_weight_ratio')
+ catch_weight = fields.Float(string='Catch Weight', digits=(10, 4))
+ catch_weight_uom_id = fields.Many2one('product.uom', related='product_id.catch_weight_uom_id')
+
+
+ @api.depends('catch_weight')
+ def _compute_catch_weight_ratio(self):
+ for lot in self:
+ if not lot.catch_weight_uom_id:
+ lot.catch_weight_ratio = 1.0
+ else:
+ lot.catch_weight_ratio = lot.catch_weight_uom_id._compute_quantity(lot.catch_weight,
+ lot.product_id.uom_id,
+ rounding_method='DOWN')
+
+
+class StockMove(models.Model):
+ _inherit = 'stock.move'
+
+ product_catch_weight_uom_id = fields.Many2one('product.uom', related="product_id.catch_weight_uom_id")
+
+ def _prepare_move_line_vals(self, quantity=None, reserved_quant=None):
+ vals = super(StockMove, self)._prepare_move_line_vals(quantity=quantity, reserved_quant=reserved_quant)
+ vals['catch_weight_uom_id'] = self.product_catch_weight_uom_id.id if self.product_catch_weight_uom_id else False
+ return vals
+
+ def action_show_details(self):
+ action = super(StockMove, self).action_show_details()
+ action['context']['show_catch_weight'] = bool(self.product_id.catch_weight_uom_id)
+ return action
+
+
+class StockMoveLine(models.Model):
+ _inherit = 'stock.move.line'
+
+ catch_weight_ratio = fields.Float(string='Catch Weight Ratio', digits=(10, 6), default=1.0)
+ catch_weight = fields.Float(string='Catch Weight', digits=(10,4))
+ catch_weight_uom_id = fields.Many2one('product.uom', string='Catch Weight UOM')
+ lot_catch_weight = fields.Float(related='lot_id.catch_weight')
+ lot_catch_weight_uom_id = fields.Many2one('product.uom', related='product_id.catch_weight_uom_id')
diff --git a/product_catch_weight/models/stock_patch.py b/product_catch_weight/models/stock_patch.py
new file mode 100644
index 00000000..fca04e4f
--- /dev/null
+++ b/product_catch_weight/models/stock_patch.py
@@ -0,0 +1,115 @@
+from odoo import fields
+from odoo.exceptions import UserError
+from odoo.tools.float_utils import float_round, float_compare, float_is_zero
+from odoo.addons.stock.models.stock_move_line import StockMoveLine
+
+
+def _action_done(self):
+ """ This method is called during a move's `action_done`. It'll actually move a quant from
+ the source location to the destination location, and unreserve if needed in the source
+ location.
+
+ This method is intended to be called on all the move lines of a move. This method is not
+ intended to be called when editing a `done` move (that's what the override of `write` here
+ is done.
+ """
+
+ # First, we loop over all the move lines to do a preliminary check: `qty_done` should not
+ # be negative and, according to the presence of a picking type or a linked inventory
+ # adjustment, enforce some rules on the `lot_id` field. If `qty_done` is null, we unlink
+ # the line. It is mandatory in order to free the reservation and correctly apply
+ # `action_done` on the next move lines.
+ ml_to_delete = self.env['stock.move.line']
+ for ml in self:
+ # Check here if `ml.qty_done` respects the rounding of `ml.product_uom_id`.
+ uom_qty = float_round(ml.qty_done, precision_rounding=ml.product_uom_id.rounding, rounding_method='HALF-UP')
+ precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+ qty_done = float_round(ml.qty_done, precision_digits=precision_digits, rounding_method='HALF-UP')
+ if float_compare(uom_qty, qty_done, precision_digits=precision_digits) != 0:
+ raise UserError(_('The quantity done for the product "%s" doesn\'t respect the rounding precision \
+ defined on the unit of measure "%s". Please change the quantity done or the \
+ rounding precision of your unit of measure.') % (
+ ml.product_id.display_name, ml.product_uom_id.name))
+
+ qty_done_float_compared = float_compare(ml.qty_done, 0, precision_rounding=ml.product_uom_id.rounding)
+ if qty_done_float_compared > 0:
+ if ml.product_id.tracking != 'none':
+ picking_type_id = ml.move_id.picking_type_id
+ if picking_type_id:
+ if picking_type_id.use_create_lots:
+ # If a picking type is linked, we may have to create a production lot on
+ # the fly before assigning it to the move line if the user checked both
+ # `use_create_lots` and `use_existing_lots`.
+ if ml.lot_name and not ml.lot_id:
+ lot_catch_weight = ml.catch_weight_uom_id._compute_quantity(ml.catch_weight, ml.product_id.catch_weight_uom_id, rounding_method='DOWN')
+ lot = self.env['stock.production.lot'].create(
+ {'name': ml.lot_name, 'product_id': ml.product_id.id, 'catch_weight': lot_catch_weight}
+ )
+ ml.write({'lot_id': lot.id})
+ elif not picking_type_id.use_create_lots and not picking_type_id.use_existing_lots:
+ # If the user disabled both `use_create_lots` and `use_existing_lots`
+ # checkboxes on the picking type, he's allowed to enter tracked
+ # products without a `lot_id`.
+ continue
+ elif ml.move_id.inventory_id:
+ # If an inventory adjustment is linked, the user is allowed to enter
+ # tracked products without a `lot_id`.
+ continue
+
+ if not ml.lot_id:
+ raise UserError(_('You need to supply a lot/serial number for %s.') % ml.product_id.name)
+ elif qty_done_float_compared < 0:
+ raise UserError(_('No negative quantities allowed'))
+ else:
+ ml_to_delete |= ml
+ ml_to_delete.unlink()
+
+ # Now, we can actually move the quant.
+ done_ml = self.env['stock.move.line']
+ for ml in self - ml_to_delete:
+ if ml.product_id.type == 'product':
+ Quant = self.env['stock.quant']
+ rounding = ml.product_uom_id.rounding
+
+ # if this move line is force assigned, unreserve elsewhere if needed
+ if not ml.location_id.should_bypass_reservation() and float_compare(ml.qty_done, ml.product_qty,
+ precision_rounding=rounding) > 0:
+ extra_qty = ml.qty_done - ml.product_qty
+ ml._free_reservation(ml.product_id, ml.location_id, extra_qty, lot_id=ml.lot_id,
+ package_id=ml.package_id, owner_id=ml.owner_id, ml_to_ignore=done_ml)
+ # unreserve what's been reserved
+ if not ml.location_id.should_bypass_reservation() and ml.product_id.type == 'product' and ml.product_qty:
+ try:
+ Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id,
+ package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
+ except UserError:
+ Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=False,
+ package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
+
+ # move what's been actually done
+ quantity = ml.product_uom_id._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id,
+ rounding_method='HALF-UP')
+ available_qty, in_date = Quant._update_available_quantity(ml.product_id, ml.location_id, -quantity,
+ lot_id=ml.lot_id, package_id=ml.package_id,
+ owner_id=ml.owner_id)
+ if available_qty < 0 and ml.lot_id:
+ # see if we can compensate the negative quants with some untracked quants
+ untracked_qty = Quant._get_available_quantity(ml.product_id, ml.location_id, lot_id=False,
+ package_id=ml.package_id, owner_id=ml.owner_id,
+ strict=True)
+ if untracked_qty:
+ taken_from_untracked_qty = min(untracked_qty, abs(quantity))
+ Quant._update_available_quantity(ml.product_id, ml.location_id, -taken_from_untracked_qty,
+ lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id)
+ Quant._update_available_quantity(ml.product_id, ml.location_id, taken_from_untracked_qty,
+ lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id)
+ Quant._update_available_quantity(ml.product_id, ml.location_dest_id, quantity, lot_id=ml.lot_id,
+ package_id=ml.result_package_id, owner_id=ml.owner_id, in_date=in_date)
+ done_ml |= ml
+ # Reset the reserved quantity as we just moved it to the destination location.
+ (self - ml_to_delete).with_context(bypass_reservation_update=True).write({
+ 'product_uom_qty': 0.00,
+ 'date': fields.Datetime.now(),
+ })
+
+StockMoveLine._action_done = _action_done
diff --git a/product_catch_weight/tests/__init__.py b/product_catch_weight/tests/__init__.py
new file mode 100644
index 00000000..0edad729
--- /dev/null
+++ b/product_catch_weight/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_catch_weight
diff --git a/product_catch_weight/tests/test_catch_weight.py b/product_catch_weight/tests/test_catch_weight.py
new file mode 100644
index 00000000..1b13cb55
--- /dev/null
+++ b/product_catch_weight/tests/test_catch_weight.py
@@ -0,0 +1,158 @@
+import logging
+# from odoo.addons.stock.tests.test_move2 import TestPickShip
+from odoo import fields
+from odoo.tests.common import TransactionCase
+
+_logger = logging.getLogger(__name__)
+
+
+class TestPicking(TransactionCase):
+ def setUp(self):
+ super(TestPicking, self).setUp()
+ self.nominal_weight = 50.0
+ self.partner1 = self.env.ref('base.res_partner_2')
+ self.stock_location = self.env.ref('stock.stock_location_stock')
+ self.ref_uom_id = self.env.ref('product.product_uom_kgm')
+ self.product_uom_id = self.env['product.uom'].create({
+ 'name': '50 ref',
+ 'category_id': self.ref_uom_id.category_id.id,
+ 'uom_type': 'bigger',
+ 'factor_inv': self.nominal_weight,
+ })
+ self.product1 = self.env['product.product'].create({
+ 'name': 'Product 1',
+ 'type': 'product',
+ 'tracking': 'serial',
+ 'list_price': 100.0,
+ 'standard_price': 50.0,
+ 'taxes_id': [(5, 0, 0)],
+ 'uom_id': self.product_uom_id.id,
+ 'uom_po_id': self.product_uom_id.id,
+ 'catch_weight_uom_id': self.ref_uom_id.id,
+ })
+
+
+ # def test_creation(self):
+ # self.productA.tracking = 'serial'
+ # lot = self.env['stock.production.lot'].create({
+ # 'product_id': self.productA.id,
+ # 'name': '123456789',
+ # })
+ #
+ # lot.catch_weight_ratio = 0.8
+ # _logger.warn(lot.xxxcatch_weight_ratio)
+
+
+
+ # def test_delivery(self):
+ # self.productA.tracking = 'serial'
+ # picking_pick, picking_pack, picking_ship = self.create_pick_pack_ship()
+ # stock_location = self.env['stock.location'].browse(self.stock_location)
+ # lot = self.env['stock.production.lot'].create({
+ # 'product_id': self.productA.id,
+ # 'name': '123456789',
+ # 'catch_weight_ratio': 0.8,
+ # })
+ # self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 1.0, lot_id=lot)
+
+ def test_so_invoice(self):
+ ref_weight = 45.0
+ lot = self.env['stock.production.lot'].create({
+ 'product_id': self.product1.id,
+ 'name': '123456789',
+ 'catch_weight': ref_weight,
+ })
+ self.assertAlmostEqual(lot.catch_weight_ratio, ref_weight / self.nominal_weight)
+ self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1.0, lot_id=lot)
+ so = self.env['sale.order'].create({
+ 'partner_id': self.partner1.id,
+ 'partner_invoice_id': self.partner1.id,
+ 'partner_shipping_id': self.partner1.id,
+ 'order_line': [(0, 0, {'product_id': self.product1.id})],
+ })
+ so.action_confirm()
+ self.assertTrue(so.state in ('sale', 'done'))
+ self.assertEqual(len(so.picking_ids), 1)
+ picking = so.picking_ids
+ self.assertEqual(picking.state, 'assigned')
+ self.assertEqual(picking.move_lines.move_line_ids.lot_id, lot)
+ picking.move_lines.move_line_ids.qty_done = 1.0
+ picking.button_validate()
+ self.assertEqual(picking.state, 'done')
+
+ inv_id = so.action_invoice_create()
+ inv = self.env['account.invoice'].browse(inv_id)
+ self.assertAlmostEqual(inv.amount_total, lot.catch_weight_ratio * self.product1.list_price)
+
+ def test_so_invoice2(self):
+ ref_weight1 = 45.0
+ ref_weight2 = 51.0
+ lot1 = self.env['stock.production.lot'].create({
+ 'product_id': self.product1.id,
+ 'name': '1-low',
+ 'catch_weight': ref_weight1,
+ })
+ lot2 = self.env['stock.production.lot'].create({
+ 'product_id': self.product1.id,
+ 'name': '1-high',
+ 'catch_weight': ref_weight2,
+ })
+ self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1.0, lot_id=lot1)
+ self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1.0, lot_id=lot2)
+ so = self.env['sale.order'].create({
+ 'partner_id': self.partner1.id,
+ 'partner_invoice_id': self.partner1.id,
+ 'partner_shipping_id': self.partner1.id,
+ 'order_line': [(0, 0, {'product_id': self.product1.id, 'product_uom_qty': 2.0})],
+ })
+ so.action_confirm()
+ self.assertTrue(so.state in ('sale', 'done'))
+ self.assertEqual(len(so.picking_ids), 1)
+ picking = so.picking_ids
+ self.assertEqual(picking.state, 'assigned')
+ self.assertEqual(picking.move_lines.move_line_ids.mapped('lot_id'), lot1 + lot2)
+ for line in picking.move_lines.move_line_ids:
+ line.qty_done = 1.0
+ picking.button_validate()
+ self.assertEqual(picking.state, 'done')
+
+ inv_id = so.action_invoice_create()
+ inv = self.env['account.invoice'].browse(inv_id)
+ self.assertAlmostEqual(inv.amount_total, self.product1.list_price * (lot1.catch_weight_ratio + lot2.catch_weight_ratio))
+
+ def test_po_invoice(self):
+ ref_weight1 = 45.0
+ ref_weight2 = 51.0
+ weights = (ref_weight1, ref_weight2)
+ price = self.product1.standard_price
+ po = self.env['purchase.order'].create({
+ 'partner_id': self.partner1.id,
+ 'order_line': [(0, 0, {
+ 'product_id': self.product1.id,
+ 'product_qty': 2.0,
+ 'name': 'Test',
+ 'date_planned': fields.Datetime.now(),
+ 'product_uom': self.product1.uom_po_id.id,
+ 'price_unit': price,
+ })]
+ })
+ po.button_confirm()
+ self.assertEqual(po.state, 'purchase')
+ self.assertEqual(len(po.picking_ids), 1)
+
+ picking = po.picking_ids
+ for i, line in enumerate(picking.move_lines.move_line_ids):
+ line.write({'lot_name': str(i), 'qty_done': 1.0, 'catch_weight': weights[i]})
+ picking.button_validate()
+ self.assertEqual(picking.state, 'done')
+
+ inv = self.env['account.invoice'].create({
+ 'type': 'in_invoice',
+ 'partner_id': self.partner1.id,
+ 'purchase_id': po.id,
+ })
+ inv.purchase_order_change()
+ self.assertEqual(len(inv.invoice_line_ids), 1)
+ self.assertEqual(inv.invoice_line_ids.quantity, 2.0)
+ self.assertAlmostEqual(inv.amount_total, price * sum(w / self.nominal_weight for w in weights))
+
diff --git a/product_catch_weight/views/account_invoice_views.xml b/product_catch_weight/views/account_invoice_views.xml
new file mode 100644
index 00000000..8eb57740
--- /dev/null
+++ b/product_catch_weight/views/account_invoice_views.xml
@@ -0,0 +1,69 @@
+
+
+
+ account.invoice.form.inherit
+ account.invoice
+
+
+
+
+
+
+
+
+
+ account.invoice.supplier.form.inherit
+ account.invoice
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Catch Weight |
+ CW Unit Price |
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+ /
+
+ |
+
+
+ |
+ |
+
+
+
+
+
+
+ |
+ |
+
+
+
+
\ No newline at end of file
diff --git a/product_catch_weight/views/stock_views.xml b/product_catch_weight/views/stock_views.xml
new file mode 100644
index 00000000..40e2d9dd
--- /dev/null
+++ b/product_catch_weight/views/stock_views.xml
@@ -0,0 +1,62 @@
+
+
+
+ stock.production.lot.form.inherit
+ stock.production.lot
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ stock.move.operations.form.inherit
+ stock.move
+
+
+
+
+
+
+ {'tree_view_ref': 'stock.view_stock_move_line_operation_tree', 'default_product_uom_id': product_uom, 'default_picking_id': picking_id, 'default_move_id': id, 'default_product_id': product_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id, 'default_catch_weight_uom_id': product_catch_weight_uom_id}
+
+
+
+
+ stock.move.line.operations.tree.inherit
+ stock.move.line
+
+
+
+
+
+
+
+
+
+
+
+ product.template.common.form.inherit
+ product.template
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/purchase_exception b/purchase_exception
new file mode 120000
index 00000000..b0e39c0b
--- /dev/null
+++ b/purchase_exception
@@ -0,0 +1 @@
+external/hibou-oca/purchase-workflow/purchase_exception
\ No newline at end of file
diff --git a/purchase_minimum_amount b/purchase_minimum_amount
new file mode 120000
index 00000000..2a4e4214
--- /dev/null
+++ b/purchase_minimum_amount
@@ -0,0 +1 @@
+external/hibou-oca/purchase-workflow/purchase_minimum_amount
\ No newline at end of file
diff --git a/purchase_order_approval_block b/purchase_order_approval_block
new file mode 120000
index 00000000..85ef0317
--- /dev/null
+++ b/purchase_order_approval_block
@@ -0,0 +1 @@
+external/hibou-oca/purchase-workflow/purchase_order_approval_block
\ No newline at end of file
diff --git a/stock_split_picking b/stock_split_picking
new file mode 120000
index 00000000..45277b6c
--- /dev/null
+++ b/stock_split_picking
@@ -0,0 +1 @@
+external/hibou-oca/stock-logistics-workflow/stock_split_picking
\ No newline at end of file