Merge branch '11.0' into 11.0-test

This commit is contained in:
Kristen Marie Kulha
2018-09-14 10:54:13 -07:00
45 changed files with 8604 additions and 0 deletions

6
.gitmodules vendored
View File

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

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

View File

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

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

26
mrp_production_add/README.rst Executable file
View File

@@ -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 <https://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 <https://github.com/hibou-io/hibou-odoo-suite/blob/master/LICENSE>`_.
Copyright Hibou Corp. 2016.

1
mrp_production_add/__init__.py Executable file
View File

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

View File

@@ -0,0 +1,17 @@
{
'name': 'MRP Production Add Item',
'author': 'Hibou Corp. <hello@hibou.io>',
'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,
}

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data>
<record id="mrp_production_add_production_item_form_view" model="ir.ui.view">
<field name="name">mrp.production.add_production_item.form.view</field>
<field name="model">mrp.production</field>
<field name="inherit_id" ref="mrp.mrp_production_form_view" />
<field name="arch" type="xml">
<xpath expr="//field[@name='move_raw_ids']" position="before">
<group col="2" colspan="2">
<button name="%(action_add_production_item)d"
type="action"
attrs="{'invisible': [('state', 'in', ('cancel', 'done'))]}"
string="Add extra item"
class="oe_highlight"/>
</group>
</xpath>
</field>
</record>
</data>
</odoo>

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_create_add_production_item" model="ir.ui.view">
<field name="name">view.create.add_production_item</field>
<field name="model">wiz.add.production.item</field>
<field name="type">form</field>
<field name="arch" type="xml">
<form>
<group colspan="4" col="4">
<separator string="Add Product" colspan="4"/>
<field name="product_id" colspan="2"/>
<field name="product_uom_id" colspan="2"/>
<field name="product_qty" colspan="2"/>
<field name="production_id" colspan="2"
invisible="context.get('active_id')"/>
</group>
<footer>
<button class="oe_highlight"
name="add_item"
string="Add"
type="object" />
or
<button class="oe_link"
special="cancel"
string="Cancel" />
</footer>
</form>
</field>
</record>
<record id="action_add_production_item" model="ir.actions.act_window">
<field name="name">Add Item View</field>
<field name="res_model">wiz.add.production.item</field>
<field name="view_type">form</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_create_add_production_item" />
<field name="target">new</field>
</record>
</odoo>

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
from . import account_invoice
from . import product
from . import stock_patch
from . import stock

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="invoice_form_inherit" model="ir.ui.view">
<field name="name">account.invoice.form.inherit</field>
<field name="model">account.invoice</field>
<field name="inherit_id" ref="account.invoice_form"/>
<field name="arch" type="xml">
<xpath expr="//tree/field[@name='price_unit']" position="after">
<field name="catch_weight" attrs="{'invisible': [('catch_weight_uom_id', '=', False)]}"/>
<field name="catch_weight_uom_id" readonly="1" attrs="{'invisible': [('catch_weight_uom_id', '=', False)]}"/>
</xpath>
</field>
</record>
<record id="invoice_supplier_form_inherit" model="ir.ui.view">
<field name="name">account.invoice.supplier.form.inherit</field>
<field name="model">account.invoice</field>
<field name="inherit_id" ref="account.invoice_supplier_form"/>
<field name="arch" type="xml">
<xpath expr="//tree/field[@name='price_unit']" position="after">
<field name="catch_weight" attrs="{'invisible': [('catch_weight_uom_id', '=', False)]}"/>
<field name="catch_weight_uom_id" readonly="1" attrs="{'invisible': [('catch_weight_uom_id', '=', False)]}"/>
</xpath>
</field>
</record>
<template id="report_invoice_document_inherit" name="report_invoice_document_catch_weight" inherit_id="account.report_invoice_document">
<xpath expr="//thead/tr/th[4]" position="after">
<t t-if="o.invoice_line_ids.filtered(lambda l: l.catch_weight_uom_id)">
<th class="text-right">Catch Weight</th>
<th class="text-right">CW Unit Price</th>
</t>
</xpath>
<xpath expr="//tbody/tr[1]/td[4]" position="after">
<t t-if="o.invoice_line_ids.filtered(lambda l: l.catch_weight_uom_id)">
<t t-if="l.catch_weight_uom_id">
<td class="text-right">
<strong t-field="l.catch_weight"/>
<span t-field="l.catch_weight_uom_id"/>
<hr style="padding: 0; margin: 0;"/>
<t t-if="o.type in ('out_invoice', 'out_refund')" t-set="lots" t-value="l.sale_line_ids.mapped('move_ids.move_line_ids.lot_id')"/>
<t t-else="" t-set="lots" t-value="l.purchase_line_id.mapped('move_ids.move_line_ids.lot_id')"/>
<ul class="list-unstyled">
<li t-foreach="lots" t-as="lot">
<span t-field="lot.name"/>: <span t-field="lot.catch_weight"/>
</li>
</ul>
</td>
<td class="text-right">
<span t-esc="'{:0.2f}'.format(l.uom_id._compute_price(l.price_unit, l.catch_weight_uom_id))"/>
/
<span t-field="l.catch_weight_uom_id"/>
</td>
</t>
<t t-else="">
<td/>
<td/>
</t>
</t>
</xpath>
<xpath expr="//tbody/tr[2]/td[4]" position="after">
<t t-if="o.invoice_line_ids.filtered(lambda l: l.catch_weight_uom_id)">
<td/>
<td/>
</t>
</xpath>
</template>
</odoo>

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_production_lot_form_inherit" model="ir.ui.view">
<field name="name">stock.production.lot.form.inherit</field>
<field name="model">stock.production.lot</field>
<field name="inherit_id" ref="stock.view_production_lot_form" />
<field name="arch" type="xml">
<xpath expr="//field[@name='product_id']" position="after">
<field name="catch_weight_ratio"/>
<field name="catch_weight"/>
<field name="catch_weight_uom_id" readonly="1"/>
</xpath>
</field>
</record>
<!--<record id="view_move_line_form_inherit" model="ir.ui.view">-->
<!--<field name="name">stock.move.line.form.inherit</field>-->
<!--<field name="model">stock.move.line</field>-->
<!--<field name="inherit_id" ref="stock.view_move_line_form" />-->
<!--<field name="arch" type="xml">-->
<!--<xpath expr="//field[@name='lot_name']" position="after">-->
<!--<field name="lot_catch_weight_ratio" readonly="1"/>-->
<!--</xpath>-->
<!--</field>-->
<!--</record>-->
<record id="view_stock_move_operations_inherit" model="ir.ui.view">
<field name="name">stock.move.operations.form.inherit</field>
<field name="model">stock.move</field>
<field name="inherit_id" ref="stock.view_stock_move_operations"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='location_dest_id']" position="after">
<field name="product_catch_weight_uom_id" invisible="1"/>
</xpath>
<xpath expr="//field[@name='move_line_ids']" position="attributes">
<attribute name="context">{'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}</attribute>
</xpath>
</field>
</record>
<record id="view_stock_move_line_operation_tree_inherit" model="ir.ui.view">
<field name="name">stock.move.line.operations.tree.inherit</field>
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="stock.view_stock_move_line_operation_tree" />
<field name="arch" type="xml">
<xpath expr="//field[@name='lot_name']" position="after">
<field name="catch_weight" invisible="not context.get('show_lots_text') or not context.get('show_catch_weight')"/>
<field name="catch_weight_uom_id" invisible="not context.get('show_lots_text') or not context.get('show_catch_weight')"/>
<field name="lot_catch_weight" invisible="not context.get('show_lots_m2o') or not context.get('show_catch_weight')" readonly="1"/>
<field name="lot_catch_weight_uom_id" invisible="not context.get('show_lots_m2o') or not context.get('show_catch_weight')" readonly="1"/>
</xpath>
</field>
</record>
<record id="product_template_form_view_inherit" model="ir.ui.view">
<field name="name">product.template.common.form.inherit</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_form_view" />
<field name="arch" type="xml">
<xpath expr="//field[@name='uom_po_id']" position="after">
<field name="catch_weight_uom_id" attrs="{'invisible': [('tracking', '!=', 'serial')]}" help="Leave empty to not use catch weight."/>
</xpath>
</field>
</record>
</odoo>

1
purchase_exception Symbolic link
View File

@@ -0,0 +1 @@
external/hibou-oca/purchase-workflow/purchase_exception

1
purchase_minimum_amount Symbolic link
View File

@@ -0,0 +1 @@
external/hibou-oca/purchase-workflow/purchase_minimum_amount

View File

@@ -0,0 +1 @@
external/hibou-oca/purchase-workflow/purchase_order_approval_block

1
stock_split_picking Symbolic link
View File

@@ -0,0 +1 @@
external/hibou-oca/stock-logistics-workflow/stock_split_picking