Merge branch 'mig/12.0/oca_auditlog_and_purchase' into '12.0'

mig/12.0/oca_auditlog_and_purchase into 12.0

See merge request hibou-io/hibou-odoo/suite!533
This commit is contained in:
Jared Kipe
2020-11-04 20:16:32 +00:00
70 changed files with 14837 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,30 @@
{
'name': 'Account Invoice Change',
'author': 'Hibou Corp. <hello@hibou.io>',
'version': '12.0.1.0.0',
'category': 'Accounting',
'sequence': 95,
'summary': 'Technical foundation for changing invoices.',
'description': """
Technical foundation for changing invoices.
Creates wizard and permissions for making invoice changes that can be
handled by other individual modules.
This module implements, as examples, how to change the Salesperson and Date fields.
Abstractly, individual 'changes' should come from specific 'fields' or capability
modules that handle the consequences of changing that field in whatever state the
the invoice is currently in.
""",
'website': 'https://hibou.io/',
'depends': [
'account',
],
'data': [
'wizard/invoice_change_views.xml',
],
'installable': True,
'application': False,
}

View File

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

View File

@@ -0,0 +1,60 @@
from odoo.addons.account.tests.account_test_users import AccountTestUsers
from odoo import fields
class TestInvoiceChange(AccountTestUsers):
def test_invoice_change_basic(self):
self.account_invoice_obj = self.env['account.invoice']
self.payment_term = self.env.ref('account.account_payment_term_advance')
self.journalrec = self.env['account.journal'].search([('type', '=', 'sale')])[0]
self.partner3 = self.env.ref('base.res_partner_3')
account_user_type = self.env.ref('account.data_account_type_receivable')
self.account_rec1_id = self.account_model.sudo(self.account_manager.id).create(dict(
code="cust_acc",
name="customer account",
user_type_id=account_user_type.id,
reconcile=True,
))
invoice_line_data = [
(0, 0,
{
'product_id': self.env.ref('product.product_product_5').id,
'quantity': 10.0,
'account_id': self.env['account.account'].search(
[('user_type_id', '=', self.env.ref('account.data_account_type_revenue').id)], limit=1).id,
'name': 'product test 5',
'price_unit': 100.00,
}
)
]
self.invoice_basic = self.account_invoice_obj.sudo(self.account_user.id).create(dict(
name="Test Customer Invoice",
reference_type="none",
payment_term_id=self.payment_term.id,
journal_id=self.journalrec.id,
partner_id=self.partner3.id,
account_id=self.account_rec1_id.id,
invoice_line_ids=invoice_line_data
))
self.assertEqual(self.invoice_basic.state, 'draft')
self.invoice_basic.action_invoice_open()
self.assertEqual(self.invoice_basic.state, 'open')
self.assertEqual(self.invoice_basic.date, fields.Date.today())
self.assertEqual(self.invoice_basic.user_id, self.account_user)
self.assertEqual(self.invoice_basic.move_id.date, fields.Date.today())
self.assertEqual(self.invoice_basic.move_id.line_ids[0].date, fields.Date.today())
ctx = {'active_model': 'account.invoice', 'active_ids': [self.invoice_basic.id]}
change = self.env['account.invoice.change'].with_context(ctx).create({})
self.assertEqual(change.date, self.invoice_basic.date)
self.assertEqual(change.user_id, self.invoice_basic.user_id)
change_date = '2018-01-01'
change_user = self.env.user
change.write({'user_id': change_user.id, 'date': change_date})
change.affect_change()
self.assertEqual(self.invoice_basic.date, change_date)
self.assertEqual(self.invoice_basic.user_id, change_user)
self.assertEqual(self.invoice_basic.move_id.date, change_date)
self.assertEqual(self.invoice_basic.move_id.line_ids[0].date, change_date)

View File

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

View File

@@ -0,0 +1,56 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class InvoiceChangeWizard(models.TransientModel):
_name = 'account.invoice.change'
_description = 'Invoice Change'
invoice_id = fields.Many2one('account.invoice', string='Invoice', readonly=True, required=True)
invoice_company_id = fields.Many2one('res.company', readonly=True, related='invoice_id.company_id')
user_id = fields.Many2one('res.users', string='Salesperson')
date = fields.Date(string='Accounting Date')
@api.model
def default_get(self, fields):
rec = super(InvoiceChangeWizard, self).default_get(fields)
context = dict(self._context or {})
active_model = context.get('active_model')
active_ids = context.get('active_ids')
# Checks on context parameters
if not active_model or not active_ids:
raise UserError(
_("Programmation error: wizard action executed without active_model or active_ids in context."))
if active_model != 'account.invoice':
raise UserError(_(
"Programmation error: the expected model for this action is 'account.invoice'. The provided one is '%d'.") % active_model)
# Checks on received invoice records
invoice = self.env[active_model].browse(active_ids)
if len(invoice) != 1:
raise UserError(_("Invoice Change expects only one invoice."))
rec.update({
'invoice_id': invoice.id,
'user_id': invoice.user_id.id,
'date': invoice.date,
})
return rec
def _new_invoice_vals(self):
vals = {}
if self.invoice_id.user_id != self.user_id:
vals['user_id'] = self.user_id.id
if self.invoice_id.date != self.date:
vals['date'] = self.date
return vals
@api.multi
def affect_change(self):
self.ensure_one()
vals = self._new_invoice_vals()
if vals:
self.invoice_id.write(vals)
if 'date' in vals and self.invoice_id.move_id:
self.invoice_id.move_id.write({'date': vals['date']})
return True

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="account_invoice_change_form" model="ir.ui.view">
<field name="name">Invoice Change</field>
<field name="model">account.invoice.change</field>
<field name="arch" type="xml">
<form string="Invoice Change">
<group>
<group name="group_left">
<field name="invoice_id" invisible="1"/>
<field name="invoice_company_id" invisible="1"/>
<field name="user_id"/>
<field name="date"/>
</group>
<group name="group_right"/>
</group>
<footer>
<button name="affect_change" string="Change" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-default" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_view_account_invoice_change" model="ir.actions.act_window">
<field name="name">Invoice Change Wizard</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">account.invoice.change</field>
<field name="view_type">form</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<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="//field[@name='state']" position="before">
<button name="%(action_view_account_invoice_change)d" string="Change"
type="action" class="btn-secondary"
attrs="{'invisible': [('state', 'in', ('sale', 'done', 'cancel'))]}"
context="{'default_invoice_id': id}"
groups="account.group_account_manager" />
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,23 @@
{
'name': 'Account Invoice Change - Analytic',
'author': 'Hibou Corp. <hello@hibou.io>',
'version': '12.0.1.0.0',
'category': 'Accounting',
'sequence': 95,
'summary': 'Change Analytic Account on Invoice.',
'description': """
Adds fields and functionality to change the analytic account on all invoice lines
and subsequent documents.
""",
'website': 'https://hibou.io/',
'depends': [
'account_invoice_change',
'analytic',
],
'data': [
'wizard/invoice_change_views.xml',
],
'installable': True,
'application': False,
}

View File

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

View File

@@ -0,0 +1,46 @@
from odoo.addons.account_invoice_change.tests.test_invoice_change import TestInvoiceChange
class TestWizard(TestInvoiceChange):
def test_invoice_change_basic(self):
self.analytic_account = self.env['account.analytic.account'].create({
'name': 'test account',
})
self.analytic_account2 = self.env['account.analytic.account'].create({
'name': 'test account2',
})
super(TestWizard, self).test_invoice_change_basic()
# Tests Adding an Analytic Account
self.assertFalse(self.invoice_basic.invoice_line_ids.mapped('account_analytic_id'))
ctx = {'active_model': 'account.invoice', 'active_ids': [self.invoice_basic.id]}
change = self.env['account.invoice.change'].with_context(ctx).create({})
change.analytic_account_id = self.analytic_account
change.affect_change()
self.assertTrue(self.invoice_basic.invoice_line_ids.mapped('account_analytic_id'))
self.assertEqual(self.invoice_basic.move_id.mapped('line_ids.analytic_account_id'), self.analytic_account)
# Tests Removing Analytic Account
new_invoice = self.invoice_basic.copy()
new_invoice.invoice_line_ids.account_analytic_id = self.analytic_account
new_invoice.action_invoice_open()
self.assertEqual(new_invoice.state, 'open')
self.assertEqual(new_invoice.move_id.mapped('line_ids.analytic_account_id'), self.analytic_account)
ctx = {'active_model': 'account.invoice', 'active_ids': [new_invoice.id]}
change = self.env['account.invoice.change'].with_context(ctx).create({})
change.analytic_account_id = False
change.affect_change()
self.assertFalse(new_invoice.invoice_line_ids.mapped('account_analytic_id'))
self.assertFalse(new_invoice.move_id.mapped('line_ids.analytic_account_id'))
# Tests Changing Analytic Account
new_invoice = self.invoice_basic.copy()
new_invoice.invoice_line_ids.account_analytic_id = self.analytic_account
new_invoice.action_invoice_open()
self.assertEqual(new_invoice.state, 'open')
self.assertEqual(new_invoice.move_id.mapped('line_ids.analytic_account_id'), self.analytic_account)
ctx = {'active_model': 'account.invoice', 'active_ids': [new_invoice.id]}
change = self.env['account.invoice.change'].with_context(ctx).create({})
change.analytic_account_id = self.analytic_account2
change.affect_change()
self.assertEqual(new_invoice.move_id.mapped('line_ids.analytic_account_id'), self.analytic_account2)

View File

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

View File

@@ -0,0 +1,41 @@
from odoo import api, fields, models, _
class InvoiceChangeWizard(models.TransientModel):
_inherit = 'account.invoice.change'
analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account')
def _analytic_account_id(self, invoice):
analytics = invoice.invoice_line_ids.mapped('account_analytic_id')
if len(analytics):
return analytics[0].id
return False
@api.model
def default_get(self, fields):
rec = super(InvoiceChangeWizard, self).default_get(fields)
invoice = self.env['account.invoice'].browse(rec['invoice_id'])
rec.update({
'analytic_account_id': self._analytic_account_id(invoice),
})
return rec
@api.multi
def affect_change(self):
old_analytic_id = self._analytic_account_id(self.invoice_id)
res = super(InvoiceChangeWizard, self).affect_change()
self._affect_analytic_change(old_analytic_id)
return res
def _affect_analytic_change(self, old_analytic_id):
if old_analytic_id != self.analytic_account_id.id:
self.invoice_id.invoice_line_ids \
.filtered(lambda l: l.account_analytic_id.id == old_analytic_id) \
.write({'account_analytic_id': self.analytic_account_id.id})
if self.invoice_id.move_id:
lines_to_affect = self.invoice_id.move_id \
.line_ids.filtered(lambda l: l.analytic_account_id.id == old_analytic_id)
lines_to_affect.write({'analytic_account_id': self.analytic_account_id.id})
lines_to_affect.create_analytic_lines()

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="account_invoice_change_form_inherit" model="ir.ui.view">
<field name="name">account.invoice.change.form.inherit</field>
<field name="model">account.invoice.change</field>
<field name="inherit_id" ref="account_invoice_change.account_invoice_change_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='group_right']" position="inside">
<field name="analytic_account_id" domain="[('company_id', '=', invoice_company_id)]"/>
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,19 @@
{
'name': 'Hibou Fedex Shipping',
'version': '12.0.1.0.0',
'category': 'Stock',
'author': "Hibou Corp.",
'license': 'AGPL-3',
'website': 'https://hibou.io/',
'depends': [
'delivery_fedex',
'delivery_hibou',
],
'data': [
'views/stock_views.xml',
],
'demo': [
],
'installable': True,
'application': False,
}

View File

@@ -0,0 +1,2 @@
from . import delivery_fedex
from . import stock

View File

@@ -0,0 +1,456 @@
import logging
from odoo import fields, models, tools, _
from odoo.exceptions import UserError, ValidationError
from odoo.addons.delivery_fedex.models.delivery_fedex import _convert_curr_iso_fdx
from .fedex_request import FedexRequest
pdf = tools.pdf
_logger = logging.getLogger(__name__)
class DeliveryFedex(models.Model):
_inherit = 'delivery.carrier'
fedex_service_type = fields.Selection(selection_add=[
('GROUND_HOME_DELIVERY', 'GROUND_HOME_DELIVERY'),
('FEDEX_EXPRESS_SAVER', 'FEDEX_EXPRESS_SAVER'),
])
def _get_fedex_is_third_party(self, order=None, picking=None):
third_party_account = self.get_third_party_account(order=order, picking=picking)
if third_party_account:
if not third_party_account.delivery_type == 'fedex':
raise ValidationError('Non-FedEx Shipping Account indicated during FedEx shipment.')
return True
return False
def _get_fedex_payment_account_number(self, order=None, picking=None):
"""
Common hook to customize what Fedex Account number to use.
:return: FedEx Account Number
"""
# Provided by Hibou Odoo Suite `delivery_hibou`
third_party_account = self.get_third_party_account(order=order, picking=picking)
if third_party_account:
if not third_party_account.delivery_type == 'fedex':
raise ValidationError('Non-FedEx Shipping Account indicated during FedEx shipment.')
return third_party_account.name
return self.fedex_account_number
def _get_fedex_account_number(self, order=None, picking=None):
if order:
# third_party_account = self.get_third_party_account(order=order, picking=picking)
# if third_party_account:
# if not third_party_account.delivery_type == 'fedex':
# raise ValidationError('Non-FedEx Shipping Account indicated during FedEx shipment.')
# return third_party_account.name
if order.warehouse_id.fedex_account_number:
return order.warehouse_id.fedex_account_number
return self.fedex_account_number
if picking:
if picking.picking_type_id.warehouse_id.fedex_account_number:
return picking.picking_type_id.warehouse_id.fedex_account_number
return self.fedex_account_number
def _get_fedex_meter_number(self, order=None, picking=None):
if order:
if order.warehouse_id.fedex_meter_number:
return order.warehouse_id.fedex_meter_number
return self.fedex_meter_number
if picking:
if picking.picking_type_id.warehouse_id.fedex_meter_number:
return picking.picking_type_id.warehouse_id.fedex_meter_number
return self.fedex_meter_number
def _get_fedex_recipient_is_residential(self, partner):
if self.fedex_service_type.find('HOME') >= 0:
return True
return not partner.is_company
"""
Overrides to use Hibou Delivery methods to get shipper etc. and to add 'transit_days' to result.
"""
def fedex_rate_shipment(self, order):
max_weight = self._fedex_convert_weight(self.fedex_default_packaging_id.max_weight, self.fedex_weight_unit)
price = 0.0
is_india = order.partner_shipping_id.country_id.code == 'IN' and order.company_id.partner_id.country_id.code == 'IN'
# Estimate weight of the sales order; will be definitely recomputed on the picking field "weight"
est_weight_value = sum([(line.product_id.weight * line.product_uom_qty) for line in order.order_line]) or 0.0
weight_value = self._fedex_convert_weight(est_weight_value, self.fedex_weight_unit)
# Some users may want to ship very lightweight items; in order to give them a rating, we round the
# converted weight of the shipping to the smallest value accepted by FedEx: 0.01 kg or lb.
# (in the case where the weight is actually 0.0 because weights are not set, don't do this)
if weight_value > 0.0:
weight_value = max(weight_value, 0.01)
order_currency = order.currency_id
superself = self.sudo()
# Hibou Delivery methods for collecting details in an overridable way
shipper_company = superself.get_shipper_company(order=order)
shipper_warehouse = superself.get_shipper_warehouse(order=order)
recipient = superself.get_recipient(order=order)
acc_number = superself._get_fedex_account_number(order=order)
meter_number = superself._get_fedex_meter_number(order=order)
order_name = superself.get_order_name(order=order)
residential = self._get_fedex_recipient_is_residential(recipient)
date_planned = None
if self.env.context.get('date_planned'):
date_planned = self.env.context.get('date_planned')
# Authentication stuff
srm = FedexRequest(self.log_xml, request_type="rating", prod_environment=self.prod_environment)
srm.web_authentication_detail(superself.fedex_developer_key, superself.fedex_developer_password)
srm.client_detail(acc_number, meter_number)
# Build basic rating request and set addresses
srm.transaction_detail(order_name)
srm.shipment_request(
self.fedex_droppoff_type,
self.fedex_service_type,
self.fedex_default_packaging_id.shipper_package_code,
self.fedex_weight_unit,
self.fedex_saturday_delivery,
)
pkg = self.fedex_default_packaging_id
srm.set_currency(_convert_curr_iso_fdx(order_currency.name))
srm.set_shipper(shipper_company, shipper_warehouse)
srm.set_recipient(recipient, residential=residential)
if max_weight and weight_value > max_weight:
total_package = int(weight_value / max_weight)
last_package_weight = weight_value % max_weight
for sequence in range(1, total_package + 1):
srm.add_package(
max_weight,
package_code=pkg.shipper_package_code,
package_height=pkg.height,
package_width=pkg.width,
package_length=pkg.length,
sequence_number=sequence,
mode='rating',
)
if last_package_weight:
total_package = total_package + 1
srm.add_package(
last_package_weight,
package_code=pkg.shipper_package_code,
package_height=pkg.height,
package_width=pkg.width,
package_length=pkg.length,
sequence_number=total_package,
mode='rating',
)
srm.set_master_package(weight_value, total_package)
else:
srm.add_package(
weight_value,
package_code=pkg.shipper_package_code,
package_height=pkg.height,
package_width=pkg.width,
package_length=pkg.length,
mode='rating',
)
srm.set_master_package(weight_value, 1)
# Commodities for customs declaration (international shipping)
if self.fedex_service_type in ['INTERNATIONAL_ECONOMY', 'INTERNATIONAL_PRIORITY'] or is_india:
total_commodities_amount = 0.0
commodity_country_of_manufacture = order.warehouse_id.partner_id.country_id.code
for line in order.order_line.filtered(lambda l: l.product_id.type in ['product', 'consu']):
commodity_amount = line.price_total / line.product_uom_qty
total_commodities_amount += (commodity_amount * line.product_uom_qty)
commodity_description = line.product_id.name
commodity_number_of_piece = '1'
commodity_weight_units = self.fedex_weight_unit
commodity_weight_value = self._fedex_convert_weight(line.product_id.weight * line.product_uom_qty, self.fedex_weight_unit)
commodity_quantity = line.product_uom_qty
commodity_quantity_units = 'EA'
# DO NOT FORWARD PORT AFTER 12.0
if getattr(line.product_id, 'hs_code', False):
commodity_harmonized_code = line.product_id.hs_code or ''
else:
commodity_harmonized_code = ''
srm._commodities(_convert_curr_iso_fdx(order_currency.name), commodity_amount, commodity_number_of_piece, commodity_weight_units, commodity_weight_value, commodity_description, commodity_country_of_manufacture, commodity_quantity, commodity_quantity_units, commodity_harmonized_code)
srm.customs_value(_convert_curr_iso_fdx(order_currency.name), total_commodities_amount, "NON_DOCUMENTS")
srm.duties_payment(order.warehouse_id.partner_id.country_id.code, superself.fedex_account_number)
request = srm.rate(date_planned=date_planned)
warnings = request.get('warnings_message')
if warnings:
_logger.info(warnings)
if not request.get('errors_message'):
if _convert_curr_iso_fdx(order_currency.name) in request['price']:
price = request['price'][_convert_curr_iso_fdx(order_currency.name)]
else:
_logger.info("Preferred currency has not been found in FedEx response")
company_currency = order.company_id.currency_id
if _convert_curr_iso_fdx(company_currency.name) in request['price']:
amount = request['price'][_convert_curr_iso_fdx(company_currency.name)]
price = company_currency._convert(amount, order_currency, order.company_id, order.date_order or fields.Date.today())
else:
amount = request['price']['USD']
price = company_currency._convert(amount, order_currency, order.company_id, order.date_order or fields.Date.today())
else:
return {'success': False,
'price': 0.0,
'error_message': _('Error:\n%s') % request['errors_message'],
'warning_message': False}
return {'success': True,
'price': price,
'error_message': False,
'transit_days': request.get('transit_days', False),
'date_delivered': request.get('date_delivered', False),
'warning_message': _('Warning:\n%s') % warnings if warnings else False}
"""
Overrides to use Hibou Delivery methods to get shipper etc. and add insurance.
"""
def fedex_send_shipping(self, pickings):
res = []
for picking in pickings:
srm = FedexRequest(self.log_xml, request_type="shipping", prod_environment=self.prod_environment)
superself = self.sudo()
shipper_company = superself.get_shipper_company(picking=picking)
shipper_warehouse = superself.get_shipper_warehouse(picking=picking)
recipient = superself.get_recipient(picking=picking)
acc_number = superself._get_fedex_account_number(picking=picking)
meter_number = superself._get_fedex_meter_number(picking=picking)
payment_acc_number = superself._get_fedex_payment_account_number()
order_name = superself.get_order_name(picking=picking)
attn = superself.get_attn(picking=picking)
insurance_value = superself.get_insurance_value(picking=picking)
residential = self._get_fedex_recipient_is_residential(recipient)
srm.web_authentication_detail(superself.fedex_developer_key, superself.fedex_developer_password)
srm.client_detail(acc_number, meter_number)
# Not the actual reference. Using `shipment_name` during `add_package` calls.
srm.transaction_detail(picking.id)
package_type = picking.package_ids and picking.package_ids[0].packaging_id.shipper_package_code or self.fedex_default_packaging_id.shipper_package_code
srm.shipment_request(self.fedex_droppoff_type, self.fedex_service_type, package_type, self.fedex_weight_unit, self.fedex_saturday_delivery)
srm.set_currency(_convert_curr_iso_fdx(picking.company_id.currency_id.name))
srm.set_shipper(shipper_company, shipper_warehouse)
srm.set_recipient(recipient, attn=attn, residential=residential)
srm.shipping_charges_payment(payment_acc_number, third_party=bool(self.get_third_party_account(picking=picking)))
# Commonly this needs to be modified, e.g. for doc tabs. Do not want to have to patch this entire method.
srm.shipment_label('COMMON2D', self.fedex_label_file_type, self.fedex_label_stock_type, 'TOP_EDGE_OF_TEXT_FIRST', 'SHIPPING_LABEL_FIRST')
order = picking.sale_id
company = shipper_company
order_currency = picking.sale_id.currency_id or picking.company_id.currency_id
net_weight = self._fedex_convert_weight(picking.shipping_weight, self.fedex_weight_unit)
# Commodities for customs declaration (international shipping)
if self.fedex_service_type in ['INTERNATIONAL_ECONOMY', 'INTERNATIONAL_PRIORITY'] or (picking.partner_id.country_id.code == 'IN' and picking.picking_type_id.warehouse_id.partner_id.country_id.code == 'IN'):
commodity_currency = order_currency
total_commodities_amount = 0.0
commodity_country_of_manufacture = picking.picking_type_id.warehouse_id.partner_id.country_id.code
for operation in picking.move_line_ids:
commodity_amount = operation.move_id.sale_line_id.price_unit or operation.product_id.list_price
total_commodities_amount += (commodity_amount * operation.qty_done)
commodity_description = operation.product_id.name
commodity_number_of_piece = '1'
commodity_weight_units = self.fedex_weight_unit
commodity_weight_value = self._fedex_convert_weight(operation.product_id.weight * operation.qty_done, self.fedex_weight_unit)
commodity_quantity = operation.qty_done
commodity_quantity_units = 'EA'
# DO NOT FORWARD PORT AFTER 12.0
if getattr(operation.product_id, 'hs_code', False):
commodity_harmonized_code = operation.product_id.hs_code or ''
else:
commodity_harmonized_code = ''
srm._commodities(_convert_curr_iso_fdx(commodity_currency.name), commodity_amount, commodity_number_of_piece, commodity_weight_units, commodity_weight_value, commodity_description, commodity_country_of_manufacture, commodity_quantity, commodity_quantity_units, commodity_harmonized_code)
srm.customs_value(_convert_curr_iso_fdx(commodity_currency.name), total_commodities_amount, "NON_DOCUMENTS")
srm.duties_payment(shipper_warehouse.partner_id.country_id.code, acc_number)
package_count = len(picking.package_ids) or 1
# For india picking courier is not accepted without this details in label.
po_number = dept_number = False
if picking.partner_id.country_id.code == 'IN' and picking.picking_type_id.warehouse_id.partner_id.country_id.code == 'IN':
po_number = 'B2B' if picking.partner_id.commercial_partner_id.is_company else 'B2C'
dept_number = 'BILL D/T: SENDER'
# TODO RIM master: factorize the following crap
################
# Multipackage #
################
if package_count > 1:
# Note: Fedex has a complex multi-piece shipping interface
# - Each package has to be sent in a separate request
# - First package is called "master" package and holds shipping-
# related information, including addresses, customs...
# - Last package responses contains shipping price and code
# - If a problem happens with a package, every previous package
# of the shipping has to be cancelled separately
# (Why doing it in a simple way when the complex way exists??)
master_tracking_id = False
package_labels = []
carrier_tracking_ref = ""
for sequence, package in enumerate(picking.package_ids, start=1):
package_weight = self._fedex_convert_weight(package.shipping_weight, self.fedex_weight_unit)
packaging = package.packaging_id
# Hibou Delivery
# Add more details to package.
srm._add_package(
package_weight,
package_code=packaging.shipper_package_code,
package_height=packaging.height,
package_width=packaging.width,
package_length=packaging.length,
sequence_number=sequence,
po_number=po_number,
dept_number=dept_number,
ref=('%s-%d' % (order_name, sequence)),
insurance=insurance_value
)
srm.set_master_package(net_weight, package_count, master_tracking_id=master_tracking_id)
request = srm.process_shipment()
package_name = package.name or sequence
warnings = request.get('warnings_message')
if warnings:
_logger.info(warnings)
# First package
if sequence == 1:
if not request.get('errors_message'):
master_tracking_id = request['master_tracking_id']
package_labels.append((package_name, srm.get_label()))
carrier_tracking_ref = request['tracking_number']
else:
raise UserError(request['errors_message'])
# Intermediary packages
elif sequence > 1 and sequence < package_count:
if not request.get('errors_message'):
package_labels.append((package_name, srm.get_label()))
carrier_tracking_ref = carrier_tracking_ref + "," + request['tracking_number']
else:
raise UserError(request['errors_message'])
# Last package
elif sequence == package_count:
# recuperer le label pdf
if not request.get('errors_message'):
package_labels.append((package_name, srm.get_label()))
if _convert_curr_iso_fdx(order_currency.name) in request['price']:
carrier_price = request['price'][_convert_curr_iso_fdx(order_currency.name)]
else:
_logger.info("Preferred currency has not been found in FedEx response")
company_currency = picking.company_id.currency_id
if _convert_curr_iso_fdx(company_currency.name) in request['price']:
amount = request['price'][_convert_curr_iso_fdx(company_currency.name)]
carrier_price = company_currency._convert(
amount, order_currency, company, order.date_order or fields.Date.today())
else:
amount = request['price']['USD']
carrier_price = company_currency._convert(
amount, order_currency, company, order.date_order or fields.Date.today())
carrier_tracking_ref = carrier_tracking_ref + "," + request['tracking_number']
logmessage = _("Shipment created into Fedex<br/>"
"<b>Tracking Numbers:</b> %s<br/>"
"<b>Packages:</b> %s") % (carrier_tracking_ref, ','.join([pl[0] for pl in package_labels]))
if self.fedex_label_file_type != 'PDF':
attachments = [('LabelFedex-%s.%s' % (pl[0], self.fedex_label_file_type), pl[1]) for pl in package_labels]
if self.fedex_label_file_type == 'PDF':
attachments = [('LabelFedex.pdf', pdf.merge_pdf([pl[1] for pl in package_labels]))]
picking.message_post(body=logmessage, attachments=attachments)
shipping_data = {'exact_price': carrier_price,
'tracking_number': carrier_tracking_ref}
res = res + [shipping_data]
else:
raise UserError(request['errors_message'])
# TODO RIM handle if a package is not accepted (others should be deleted)
###############
# One package #
###############
elif package_count == 1:
packaging = picking.package_ids[:1].packaging_id or picking.carrier_id.fedex_default_packaging_id
# Hibou Delivery
# Add more details to package.
srm._add_package(
net_weight,
package_code=packaging.shipper_package_code,
package_height=packaging.height,
package_width=packaging.width,
package_length=packaging.length,
po_number=po_number,
dept_number=dept_number,
ref=order_name,
insurance=insurance_value
)
srm.set_master_package(net_weight, 1)
# Ask the shipping to fedex
request = srm.process_shipment()
warnings = request.get('warnings_message')
if warnings:
_logger.info(warnings)
if not request.get('errors_message'):
if _convert_curr_iso_fdx(order_currency.name) in request['price']:
carrier_price = request['price'][_convert_curr_iso_fdx(order_currency.name)]
else:
_logger.info("Preferred currency has not been found in FedEx response")
company_currency = picking.company_id.currency_id
if _convert_curr_iso_fdx(company_currency.name) in request['price']:
amount = request['price'][_convert_curr_iso_fdx(company_currency.name)]
carrier_price = company_currency._convert(
amount, order_currency, company, order.date_order or fields.Date.today())
else:
amount = request['price']['USD']
carrier_price = company_currency._convert(
amount, order_currency, company, order.date_order or fields.Date.today())
carrier_tracking_ref = request['tracking_number']
logmessage = (_("Shipment created into Fedex <br/> <b>Tracking Number : </b>%s") % (
carrier_tracking_ref))
fedex_labels = [
('LabelFedex-%s-%s.%s' % (carrier_tracking_ref, index, self.fedex_label_file_type), label)
for index, label in enumerate(srm._get_labels(self.fedex_label_file_type))]
picking.message_post(body=logmessage, attachments=fedex_labels)
shipping_data = {'exact_price': carrier_price,
'tracking_number': carrier_tracking_ref}
res = res + [shipping_data]
else:
raise UserError(request['errors_message'])
##############
# No package #
##############
else:
raise UserError(_('No packages for this picking'))
return res

View File

@@ -0,0 +1,200 @@
import suds
from odoo.addons.delivery_fedex.models import fedex_request
import logging
_logger = logging.getLogger(__name__)
STATECODE_REQUIRED_COUNTRIES = fedex_request.STATECODE_REQUIRED_COUNTRIES
def sanitize_name(name):
if isinstance(name, str):
return name.replace('[', '').replace(']', '')
return 'Unknown'
class FedexRequest(fedex_request.FedexRequest):
_transit_days = {
'ONE_DAYS': 1,
'ONE_DAY': 1,
'TWO_DAYS': 2,
'THREE_DAYS': 3,
'FOUR_DAYS': 4,
'FIVE_DAYS': 5,
'SIX_DAYS': 6,
'SEVEN_DAYS': 7,
'EIGHT_DAYS': 8,
'NINE_DAYS': 9,
'TEN_DAYS': 10,
}
_service_transit_days = {
'FEDEX_2_DAY': 2,
'FEDEX_2_DAY_AM': 2,
'FIRST_OVERNIGHT': 1,
'PRIORITY_OVERNIGHT': 1,
'STANDARD_OVERNIGHT': 1,
}
def set_recipient(self, recipient_partner, attn=None, residential=False):
"""
Adds ATTN: and sanitizes against known 'illegal' common characters in names.
:param recipient_partner: default
:param attn: NEW add to contact name as an ' ATTN: $attn'
:param residential: NEW allow ground home delivery
:return:
"""
Contact = self.client.factory.create('Contact')
if recipient_partner.is_company:
Contact.PersonName = ''
Contact.CompanyName = sanitize_name(recipient_partner.name)
else:
Contact.PersonName = sanitize_name(recipient_partner.name)
Contact.CompanyName = sanitize_name(recipient_partner.parent_id.name or '')
if attn:
Contact.PersonName = Contact.PersonName + ' ATTN: ' + str(attn)
Contact.PhoneNumber = recipient_partner.phone or ''
Address = self.client.factory.create('Address')
Address.StreetLines = [recipient_partner.street or '', recipient_partner.street2 or '']
Address.City = recipient_partner.city or ''
if recipient_partner.country_id.code in STATECODE_REQUIRED_COUNTRIES:
Address.StateOrProvinceCode = recipient_partner.state_id.code or ''
else:
Address.StateOrProvinceCode = ''
Address.PostalCode = recipient_partner.zip or ''
Address.CountryCode = recipient_partner.country_id.code or ''
if residential:
Address.Residential = True
self.RequestedShipment.Recipient.Contact = Contact
self.RequestedShipment.Recipient.Address = Address
def add_package(self, weight_value, package_code=False, package_height=0, package_width=0, package_length=0, sequence_number=False, mode='shipping', ref=False, insurance=False):
# TODO remove in master and change the signature of a public method
return self._add_package(weight_value=weight_value, package_code=package_code, package_height=package_height, package_width=package_width,
package_length=package_length, sequence_number=sequence_number, mode=mode, po_number=False, dept_number=False, ref=ref, insurance=insurance)
def _add_package(self, weight_value, package_code=False, package_height=0, package_width=0, package_length=0, sequence_number=False, mode='shipping', po_number=False, dept_number=False, ref=False, insurance=False):
package = self.client.factory.create('RequestedPackageLineItem')
package_weight = self.client.factory.create('Weight')
package_weight.Value = weight_value
package_weight.Units = self.RequestedShipment.TotalWeight.Units
if ref:
customer_ref = self.client.factory.create('CustomerReference')
customer_ref.CustomerReferenceType = 'CUSTOMER_REFERENCE'
customer_ref.Value = str(ref)
package.CustomerReferences.append(customer_ref)
if insurance:
insured = self.client.factory.create('Money')
insured.Amount = insurance
# TODO at some point someone might need currency here
insured.Currency = 'USD'
package.InsuredValue = insured
package.PhysicalPackaging = 'BOX'
if package_code == 'YOUR_PACKAGING':
package.Dimensions.Height = package_height
package.Dimensions.Width = package_width
package.Dimensions.Length = package_length
# TODO in master, add unit in product packaging and perform unit conversion
package.Dimensions.Units = "IN" if self.RequestedShipment.TotalWeight.Units == 'LB' else 'CM'
if po_number:
po_reference = self.client.factory.create('CustomerReference')
po_reference.CustomerReferenceType = 'P_O_NUMBER'
po_reference.Value = po_number
package.CustomerReferences.append(po_reference)
if dept_number:
dept_reference = self.client.factory.create('CustomerReference')
dept_reference.CustomerReferenceType = 'DEPARTMENT_NUMBER'
dept_reference.Value = dept_number
package.CustomerReferences.append(dept_reference)
package.Weight = package_weight
if mode == 'rating':
package.GroupPackageCount = 1
if sequence_number:
package.SequenceNumber = sequence_number
else:
self.hasOnePackage = True
if mode == 'rating':
self.RequestedShipment.RequestedPackageLineItems.append(package)
else:
self.RequestedShipment.RequestedPackageLineItems = package
def shipping_charges_payment(self, shipping_charges_payment_account, third_party=False):
"""
Allow 'shipping_charges_payment_account' to be considered 'third_party'
:param shipping_charges_payment_account: default
:param third_party: NEW add to indicate that the 'shipping_charges_payment_account' is third party.
:return:
"""
self.RequestedShipment.ShippingChargesPayment.PaymentType = 'SENDER' if not third_party else 'THIRD_PARTY'
Payor = self.client.factory.create('Payor')
Payor.ResponsibleParty.AccountNumber = shipping_charges_payment_account
self.RequestedShipment.ShippingChargesPayment.Payor = Payor
# Rating stuff
def rate(self, date_planned=None):
"""
Response will contain 'transit_days' key with number of days.
:param date_planned: Planned Outgoing shipment. Used to have FedEx tell us how long it will take for the package to arrive.
:return:
"""
if date_planned:
self.RequestedShipment.ShipTimestamp = date_planned
formatted_response = {'price': {}}
del self.ClientDetail.Region
if self.hasCommodities:
self.RequestedShipment.CustomsClearanceDetail.Commodities = self.listCommodities
try:
self.response = self.client.service.getRates(WebAuthenticationDetail=self.WebAuthenticationDetail,
ClientDetail=self.ClientDetail,
TransactionDetail=self.TransactionDetail,
Version=self.VersionId,
RequestedShipment=self.RequestedShipment,
ReturnTransitAndCommit=True) # New ReturnTransitAndCommit for CommitDetails in response
if (self.response.HighestSeverity != 'ERROR' and self.response.HighestSeverity != 'FAILURE'):
if not getattr(self.response, "RateReplyDetails", False):
raise Exception("No rating found")
for rating in self.response.RateReplyDetails[0].RatedShipmentDetails:
formatted_response['price'][rating.ShipmentRateDetail.TotalNetFedExCharge.Currency] = rating.ShipmentRateDetail.TotalNetFedExCharge.Amount
if len(self.response.RateReplyDetails[0].RatedShipmentDetails) == 1:
if 'CurrencyExchangeRate' in self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail:
formatted_response['price'][self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail.CurrencyExchangeRate.FromCurrency] = self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail.TotalNetFedExCharge.Amount / self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail.CurrencyExchangeRate.Rate
# Hibou Delivery Planning
if hasattr(self.response.RateReplyDetails[0], 'DeliveryTimestamp') and self.response.RateReplyDetails[0].DeliveryTimestamp:
formatted_response['date_delivered'] = self.response.RateReplyDetails[0].DeliveryTimestamp
elif hasattr(self.response.RateReplyDetails[0], 'CommitDetails') and hasattr(self.response.RateReplyDetails[0].CommitDetails[0], 'CommitTimestamp'):
formatted_response['date_delivered'] = self.response.RateReplyDetails[0].CommitDetails[0].CommitTimestamp
formatted_response['transit_days'] = self._service_transit_days.get(self.response.RateReplyDetails[0].CommitDetails[0].ServiceType, 0)
elif hasattr(self.response.RateReplyDetails[0], 'CommitDetails') and hasattr(self.response.RateReplyDetails[0].CommitDetails[0], 'TransitTime'):
transit_days = self.response.RateReplyDetails[0].CommitDetails[0].TransitTime
transit_days = self._transit_days.get(transit_days, 0)
formatted_response['transit_days'] = transit_days
else:
errors_message = '\n'.join([("%s: %s" % (n.Code, n.Message)) for n in self.response.Notifications if (n.Severity == 'ERROR' or n.Severity == 'FAILURE')])
formatted_response['errors_message'] = errors_message
if any([n.Severity == 'WARNING' for n in self.response.Notifications]):
warnings_message = '\n'.join([("%s: %s" % (n.Code, n.Message)) for n in self.response.Notifications if n.Severity == 'WARNING'])
formatted_response['warnings_message'] = warnings_message
except suds.WebFault as fault:
formatted_response['errors_message'] = fault
except IOError:
formatted_response['errors_message'] = "Fedex Server Not Found"
except Exception as e:
formatted_response['errors_message'] = e.args[0]
return formatted_response

View File

@@ -0,0 +1,8 @@
from odoo import api, fields, models
class StockWarehouse(models.Model):
_inherit = 'stock.warehouse'
fedex_account_number = fields.Char(string='FedEx Account Number')
fedex_meter_number = fields.Char(string='FedEx Meter Number')

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="fedex_view_warehouse" model="ir.ui.view">
<field name="name">stock.warehouse</field>
<field name="model">stock.warehouse</field>
<field name="inherit_id" ref="stock.view_warehouse" />
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after">
<field name="fedex_account_number"/>
<field name="fedex_meter_number"/>
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,26 @@
{
'name': 'GLS Netherlands Shipping',
'summary': 'Create and print your shipping labels with GLS from the Netherlands.',
'version': '12.0.1.0.0',
'author': "Hibou Corp.",
'category': 'Warehouse',
'license': 'AGPL-3',
'images': [],
'website': "https://hibou.io",
'description': """
GLS Netherlands Shipping
========================
Create and print your shipping labels with GLS from the Netherlands.
""",
'depends': [
'delivery_hibou',
],
'demo': [],
'data': [
'views/delivery_gls_nl_view.xml',
],
'auto_install': False,
'installable': True,
}

View File

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

View File

@@ -0,0 +1,294 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from .gls_nl_request import GLSNLRequest
from requests import HTTPError
from base64 import decodebytes
from csv import reader as csv_reader
class ProductPackaging(models.Model):
_inherit = 'product.packaging'
package_carrier_type = fields.Selection(selection_add=[('gls_nl', 'GLS Netherlands')])
class ProviderGLSNL(models.Model):
_inherit = 'delivery.carrier'
GLS_NL_SOFTWARE_NAME = 'Odoo'
GLS_NL_SOFTWARE_VER = '12.0'
GLS_NL_COUNTRY_NOT_FOUND = 'GLS_NL_COUNTRY_NOT_FOUND'
delivery_type = fields.Selection(selection_add=[('gls_nl', 'GLS Netherlands')])
gls_nl_username = fields.Char(string='GLS NL Username', groups='base.group_system')
gls_nl_password = fields.Char(string='GLS NL Password', groups='base.group_system')
gls_nl_labeltype = fields.Selection([
('zpl', 'ZPL'),
('pdf', 'PDF'),
], string='GLS NL Label Type')
gls_nl_express = fields.Selection([
('t9', 'Delivery before 09:00 on weekdays'),
('t12', 'Delivery before 12:00 on weekdays'),
('t17', 'Delivery before 17:00 on weekdays'),
('s9', 'Delivery before 09:00 on Saturday'),
('s12', 'Delivery before 12:00 on Saturday'),
('s17', 'Delivery before 17:00 on Saturday'),
], string='GLS NL Express', help='Express service tier (leave blank for regular)')
gls_nl_rate_id = fields.Many2one('ir.attachment', string='GLS NL Rates')
def button_gls_nl_test_rates(self):
self.ensure_one()
if not self.gls_nl_rate_id:
raise UserError(_('No GLS NL Rate file is attached.'))
rate_data = self._gls_nl_process_rates()
weight_col_count = len(rate_data['w'])
row_count = len(rate_data['r'])
country_col = rate_data['c']
country_model = self.env['res.country']
for row in rate_data['r']:
country = country_model.search([('code', '=', row[country_col])], limit=1)
if not country:
raise ValidationError(_('Could not locate country by code: "%s" for row: %s') % (row[country_col], row))
for w, i in rate_data['w'].items():
try:
cost = float(row[i])
except ValueError:
raise ValidationError(_('Could not process cost for row: %s') % (row, ))
raise ValidationError(_('Looks good! %s weights, %s countries located.') % (weight_col_count, row_count))
def _gls_nl_process_rates(self):
"""
'w' key will be weights to row index map
'c' key will be the country code index
'r' key will be rows from the original that can use indexes above
:return:
"""
datab = decodebytes(self.gls_nl_rate_id.datas)
csv_data = datab.decode()
csv_data = csv_data.replace('\r', '')
csv_lines = csv_data.splitlines()
header = [csv_lines[0]]
body = csv_lines[1:]
data = {'w': {}, 'r': []}
for row in csv_reader(header):
for i, col in enumerate(row):
if col == 'Country':
data['c'] = i
else:
try:
weight = float(col)
data['w'][weight] = i
except ValueError:
pass
if 'c' not in data:
raise ValidationError(_('Could not locate "Country" column.'))
if not data['w']:
raise ValidationError(_('Could not locate any weight columns.'))
for row in csv_reader(body):
data['r'].append(row)
return data
def _gls_nl_rate(self, country_code, weight):
if weight < 0.0:
return 0.0
rate_data = self._gls_nl_process_rates()
country_col = rate_data['c']
rate = None
country_found = False
for row in rate_data['r']:
if row[country_col] == country_code:
country_found = True
for w, i in rate_data['w'].items():
if weight <= w:
try:
rate = float(row[i])
break
except ValueError:
pass
else:
# our w, i will be the last weight and rate.
try:
# Return Max rate + remaining weight rated
return float(row[i]) + self._gls_nl_rate(country_code, weight-w)
except ValueError:
pass
break
if rate is None and not country_found:
return self.GLS_NL_COUNTRY_NOT_FOUND
return rate
def gls_nl_rate_shipment(self, order):
recipient = self.get_recipient(order=order)
rate = None
dest_country = recipient.country_id.code
est_weight_value = sum([(line.product_id.weight * line.product_uom_qty) for line in order.order_line]) or 0.0
if dest_country:
rate = self._gls_nl_rate(dest_country, est_weight_value)
# Handle errors and rate conversions.
error_message = None
if not dest_country or rate == self.GLS_NL_COUNTRY_NOT_FOUND:
error_message = _('Destination country not found: "%s"') % (dest_country, )
if rate is None or error_message:
if not error_message:
error_message = _('Rate not found for weight: "%s"') % (est_weight_value, )
return {'success': False,
'price': 0.0,
'error_message': error_message,
'warning_message': False}
euro_currency = self.env['res.currency'].search([('name', '=', 'EUR')], limit=1)
if euro_currency and order.currency_id and euro_currency != order.currency_id:
rate = euro_currency._convert(rate,
order.currency_id,
order.company_id,
order.date_order or fields.Date.today())
return {'success': True,
'price': rate,
'error_message': False,
'warning_message': False}
def _get_gls_nl_service(self):
return GLSNLRequest(self.prod_environment)
def _gls_nl_make_address(self, partner):
# Addresses look like
# {
# 'name1': '',
# 'name2': '',
# 'name3': '',
# 'street': '',
# 'houseNo': '',
# 'houseNoExt': '',
# 'zipCode': '',
# 'city': '',
# 'countrycode': '',
# 'contact': '',
# 'phone': '',
# 'email': '',
# }
address = {}
pieces = partner.street.split(' ')
street = ' '.join(pieces[:-1]).strip(' ,')
house = pieces[-1]
address['name1'] = partner.name
address['street'] = street
address['houseNo'] = house
if partner.street2:
address['houseNoExt'] = partner.street2
address['zipCode'] = partner.zip
address['city'] = partner.city
address['countrycode'] = partner.country_id.code
if partner.phone:
address['phone'] = partner.phone
if partner.email:
address['email'] = partner.email
return address
def gls_nl_send_shipping(self, pickings):
res = []
sudoself = self.sudo()
service = sudoself._get_gls_nl_service()
for picking in pickings:
#company = self.get_shipper_company(picking=picking) # Requester not needed currently
from_ = self.get_shipper_warehouse(picking=picking)
to = self.get_recipient(picking=picking)
total_rate = 0.0
request_body = {
'labelType': sudoself.gls_nl_labeltype,
'username': sudoself.gls_nl_username,
'password': sudoself.gls_nl_password,
'shiptype': 'p', # note not shipType, 'f' for Freight
'trackingLinkType': 's',
# 'customerNo': '', # needed if there are more 'customer numbers' attached to a single MyGLS API Account
'reference': picking.name,
'addresses': {
'pickupAddress': self._gls_nl_make_address(from_),
'deliveryAddress': self._gls_nl_make_address(to),
#'requesterAddress': {}, # Not needed currently
},
'units': [],
'services': {},
'shippingDate': fields.Date.to_string(fields.Date.today()),
'shippingSystemName': self.GLS_NL_SOFTWARE_NAME,
'shippingSystemVersion': self.GLS_NL_SOFTWARE_VER,
}
if sudoself.gls_nl_express:
request_body['services']['expressService'] = sudoself.gls_nl_express
# Build out units
# Units look like:
# {
# 'unitId': 'A',
# 'unitType': '', # only for freight
# 'weight': 0.0,
# 'additionalInfo1': '',
# 'additionalInfo2': '',
# }
if picking.package_ids:
for package in picking.package_ids:
rate = self._gls_nl_rate(to.country_id.code, package.shipping_weight or 0.0)
if rate and rate != self.GLS_NL_COUNTRY_NOT_FOUND:
total_rate += rate
unit = {
'unitId': package.name,
'weight': package.shipping_weight,
}
request_body['units'].append(unit)
else:
rate = self._gls_nl_rate(to.country_id.code, picking.shipping_weight or 0.0)
if rate and rate != self.GLS_NL_COUNTRY_NOT_FOUND:
total_rate += rate
unit = {
'unitId': picking.name,
'weight': picking.shipping_weight,
}
request_body['units'].append(unit)
try:
# Create label
label = service.create_label(request_body)
trackings = []
uniq_nos = []
attachments = []
for i, unit in enumerate(label['units'], 1):
trackings.append(unit['unitNo'])
uniq_nos.append(unit['uniqueNo'])
attachments.append(('LabelGLSNL-%s-%s.%s' % (unit['unitNo'], i, sudoself.gls_nl_labeltype), unit['label']))
tracking = ', '.join(set(trackings))
logmessage = _("Shipment created into GLS NL<br/>"
"<b>Tracking Number:</b> %s<br/>"
"<b>UniqueNo:</b> %s") % (tracking, ', '.join(set(uniq_nos)))
picking.message_post(body=logmessage, attachments=attachments)
shipping_data = {'exact_price': total_rate, 'tracking_number': tracking}
res.append(shipping_data)
except HTTPError as e:
raise ValidationError(e)
return res
def gls_nl_get_tracking_link(self, pickings):
return 'https://gls-group.eu/EU/en/parcel-tracking?match=%s' % pickings.carrier_tracking_ref
def gls_nl_cancel_shipment(self, picking):
sudoself = self.sudo()
service = sudoself._get_gls_nl_service()
try:
request_body = {
'unitNo': picking.carrier_tracking_ref,
'username': sudoself.gls_nl_username,
'password': sudoself.gls_nl_password,
'shiptype': 'p',
'shippingSystemName': self.GLS_NL_SOFTWARE_NAME,
'shippingSystemVersion': self.GLS_NL_SOFTWARE_VER,
}
service.delete_label(request_body)
picking.message_post(body=_('Shipment N° %s has been cancelled' % picking.carrier_tracking_ref))
picking.write({'carrier_tracking_ref': '', 'carrier_price': 0.0})
except HTTPError as e:
raise ValidationError(e)

View File

@@ -0,0 +1,36 @@
import requests
from json import dumps
class GLSNLRequest:
def __init__(self, production):
self.production = production
self.api_key = '234a6d4ad5fd4d039526a8a1074051ee' if production else 'f80d41c6f7d542878c9c0a4295de7a6a'
self.url = 'https://api.gls.nl/V1/api' if production else 'https://api.gls.nl/Test/V1/api'
self.headers = self._make_headers()
def _make_headers(self):
return {
'Content-Type': 'application/json',
'Ocp-Apim-Subscription-Key': self.api_key,
}
def post_request(self, endpoint, body):
if not self.production and body.get('username') == 'test':
# Override to test credentials
body['username'] = 'apitest1@gls-netherlands.com'
body['password'] = '9PMev9qM'
url = self.url + endpoint
result = requests.request('POST', url, headers=self.headers, data=dumps(body))
if result.status_code != 200:
raise requests.HTTPError(result.text)
return result.json()
def create_label(self, body):
return self.post_request('/Label/Create', body)
def confirm_label(self, body):
return self.post_request('/Label/Confirm', body)
def delete_label(self, body):
return self.post_request('/Label/Delete', body)

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_delivery_carrier_form_with_provider_gls_nl" model="ir.ui.view">
<field name="name">delivery.carrier.form.provider.gls_nl</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="GLS NL Configuration" attrs="{'invisible': [('delivery_type', '!=', 'gls_nl')]}">
<group>
<group>
<field name="gls_nl_username" attrs="{'required': [('delivery_type', '=', 'gls_nl')]}" />
<field name="gls_nl_password" attrs="{'required': [('delivery_type', '=', 'gls_nl')]}" password="True"/>
</group>
<group>
<button name="button_gls_nl_test_rates" type="object" string="Test Rates" class="bnt-primary" attrs="{'invisible': [('gls_nl_rate_id', '=', False)]}"/>
<field name="gls_nl_rate_id"/>
<field name="gls_nl_labeltype" attrs="{'required': [('delivery_type', '==', 'gls_nl')]}"/>
<field name="gls_nl_express"/>
</group>
</group>
<group string='GLS NL Tutorial'>
<ul>
<li>
<b>Go to <a href='https://api-portal.gls.nl/' target='_blank'>GLS Netherlands API</a> to create an API capable account.</b>
<br/><br/>
</li>
<li>
<p>You can use the username of "test" and any password to use default test credentials (only when not in Production mode).
This allows you to put in your production password and simply replace the username with "test" to use the API default test credentials.<br/>
(as of 2019-07-28 it is known that actual credentials will not work on the test environment, and the test environment is not available at all times of the day)</p>
</li>
<li>
<b>Rates can be uploaded with the following format.</b>
<ul>
<li>Header row with One Column named "Country". Rows should have 2 letter country codes in this column.</li>
<li>Header row with numeric values in ascending value to the right (weight in kg). Rows should have the rate in Euros if the order/package is less than the header weight.</li>
<li>Columns not matching the mentioned headers will be ignored and can be used for comments or maintainer data.</li>
</ul>
<p>Example:</p>
<pre>Country Name,Country,2,5,15,32
Belgium,BE,4.7,4.95,5.9,9.7
Germany,DE,4.15,5.3,6.4,12.8
</pre>
<p>Rating a package going to Germany weighing <b>4.5kg</b> (less than 5kg) will return <b>5.30 EUR</b> (converted to order currency).</p>
<p>It is recommended to name the attachment appropriately (e.g. <b>GLS_RATES_2019.csv</b>), and find/use the same attachment on other delivery methods (e.g. with express settings and higher margin).</p>
<br/><br/>
</li>
<li>
Use the <b>Test Rates</b> button to perform a thorough test (weights parse as numeric for all rows, country is round in Odoo database).
<br/><br/>
</li>
</ul>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,26 @@
{
'name': 'Stamps.com (USPS) Shipping',
'summary': 'Send your shippings through Stamps.com and track them online.',
'version': '12.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_hibou',
],
'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,32 @@
Copyright (c) 2019 by Hibou Corp.
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 = 84
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,301 @@
# -*- 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("AddOnV15")
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_extended_postage_info(self):
return self.create("ExtendedPostageInfoV1")
def create_shipping(self):
"""Create a new shipping object.
"""
return self.create("RateV31")
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, extended_postage_info=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, ExtendedPostageInfo=extended_postage_info)
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.AddOnV15.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,329 @@
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',
]
STAMPS_INTEGRATION_ID = 'f62cb4f0-aa07-4701-a1dd-f0e7c853ed3c'
class ProductPackaging(models.Model):
_inherit = 'product.packaging'
package_carrier_type = fields.Selection(selection_add=[('stamps', 'Stamps.com')])
stamps_cubic_pricing = fields.Boolean(string="Stamps.com Use Cubic Pricing")
class ProviderStamps(models.Model):
_inherit = 'delivery.carrier'
delivery_type = fields.Selection(selection_add=[('stamps', 'Stamps.com (USPS)')])
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 _stamps_package_is_cubic_pricing(self, package=None):
if not package:
return self.stamps_default_packaging_id.stamps_cubic_pricing
return package.packaging_id.stamps_cubic_pricing
def _stamps_package_dimensions(self, package=None):
if not package:
package_type = self.stamps_default_packaging_id
else:
package_type = package.packaging_id
return package_type.length, package_type.width, package_type.height
def _get_stamps_service(self):
sudoself = self.sudo()
config = StampsConfiguration(integration_id=STAMPS_INTEGRATION_ID,
username=sudoself.stamps_username,
password=sudoself.stamps_password,
wsdl=('testing' if not sudoself.prod_environment else None))
return StampsService(configuration=config)
def _stamps_convert_weight(self, weight):
""" weight always expressed in database units (KG/LBS) """
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/lbs.')
get_param = self.env['ir.config_parameter'].sudo().get_param
product_weight_in_lbs_param = get_param('product.weight_in_lbs')
if product_weight_in_lbs_param == '1':
return weight
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.strftime('%Y-%m-%d') if date_planned else date.today().isoformat()
ret_val.FromZIPCode = self.get_shipper_warehouse(order=order).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
ret_val.ContentType = 'Merchandise'
return ret_val
def _stamps_get_addresses_for_picking(self, picking):
company = self.get_shipper_company(picking=picking)
from_ = self.get_shipper_warehouse(picking=picking)
to = self.get_recipient(picking=picking)
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)
l, w, h = self._stamps_package_dimensions(package=package)
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.CubicPricing = self._stamps_package_is_cubic_pricing(package=package)
ret_val.Length = l
ret_val.Width = w
ret_val.Height = h
ret_val.ServiceType = self.stamps_service_type
ret_val.WeightLb = weight
ret_val.ContentType = 'Merchandise'
ret.append((package.name + ret_val.ShipDate + str(ret_val.WeightLb), ret_val))
if not ret:
weight = self._stamps_convert_weight(picking.shipping_weight)
l, w, h = self._stamps_package_dimensions()
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.CubicPricing = self._stamps_package_is_cubic_pricing()
ret_val.Length = l
ret_val.Width = w
ret_val.Height = h
ret_val.ServiceType = self.stamps_service_type
ret_val.WeightLb = weight
ret_val.ContentType = 'Merchandise'
ret.append((picking.name + ret_val.ShipDate + str(ret_val.WeightLb), ret_val))
return ret
def stamps_get_shipping_price_from_so(self, orders):
res = self.stamps_get_shipping_price_for_plan(orders, date.today().isoformat())
return map(lambda r: r[0] if r else 0.0, res)
def stamps_get_shipping_price_for_plan(self, orders, date_planned):
res = []
service = self._get_stamps_service()
for order in orders:
shipping = self._get_stamps_shipping_for_order(service, order, date_planned)
rates = service.get_rates(shipping)
if rates and len(rates) >= 1:
rate = rates[0]
price = float(rate.Amount)
if order.currency_id.name != 'USD':
quote_currency = self.env['res.currency'].search([('name', '=', 'USD')], limit=1)
price = quote_currency.compute(rate.Amount, order.currency_id)
delivery_days = rate.DeliverDays
if delivery_days.find('-') >= 0:
delivery_days = delivery_days.split('-')
transit_days = int(delivery_days[-1])
else:
transit_days = int(delivery_days)
date_delivered = None
if date_planned and transit_days > 0:
date_delivered = self.calculate_date_delivered(date_planned, transit_days)
res = res + [(price, transit_days, date_delivered)]
continue
res = res + [(0.0, 0, None)]
return res
def stamps_rate_shipment(self, order):
self.ensure_one()
result = {
'success': False,
'price': 0.0,
'error_message': 'Error Retrieving Response from Stamps.com',
'warning_message': False
}
date_planned = None
if self.env.context.get('date_planned'):
date_planned = self.env.context.get('date_planned')
rate = self.stamps_get_shipping_price_for_plan(order, date_planned)
if rate:
price, transit_time, date_delivered = rate[0]
result.update({
'success': True,
'price': price,
'error_message': False,
'transit_time': transit_time,
'date_delivered': date_delivered,
})
return result
return result
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.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.AddOnV15 = [add_on, add_on2]
extended_postage_info = service.create_extended_postage_info()
if self.is_amazon(picking=picking):
extended_postage_info.bridgeProfileType = 'Amazon MWS'
label = service.get_label(from_address, to_address, shipping,
transaction_id=txn_id, image_type=self.stamps_image_type,
extended_postage_info=extended_postage_info)
package_labels.append((txn_id, 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,38 @@
<?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_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>
<record id="product_packaging_delivery_form" model="ir.ui.view">
<field name="name">stamps.product.packaging.form.delivery</field>
<field name="model">product.packaging</field>
<field name="inherit_id" ref="delivery.product_packaging_delivery_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='max_weight']" position='after'>
<field name="stamps_cubic_pricing"/>
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,18 @@
{
'name': 'Hibou UPS Shipping',
'version': '12.0.1.0.0',
'category': 'Stock',
'author': "Hibou Corp.",
'license': 'AGPL-3',
'website': 'https://hibou.io/',
'depends': [
'delivery_ups',
'delivery_hibou',
],
'data': [
],
'demo': [
],
'installable': True,
'application': False,
}

View File

@@ -0,0 +1,2 @@
from . import delivery_ups
from . import ups_request_patch

View File

@@ -0,0 +1,218 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.addons.delivery_ups.models.ups_request import UPSRequest, Package
from odoo.tools import pdf
class ProviderUPS(models.Model):
_inherit = 'delivery.carrier'
def _get_ups_is_third_party(self, order=None, picking=None):
third_party_account = self.get_third_party_account(order=order, picking=picking)
if third_party_account:
if not third_party_account.delivery_type == 'ups':
raise ValidationError('Non-UPS Shipping Account indicated during UPS shipment.')
return True
if order and self.ups_bill_my_account and order.ups_carrier_account:
return True
return False
def _get_ups_account_number(self, order=None, picking=None):
"""
Common hook to customize what UPS Account number to use.
:return: UPS Account Number
"""
# Provided by Hibou Odoo Suite `delivery_hibou`
third_party_account = self.get_third_party_account(order=order, picking=picking)
if third_party_account:
if not third_party_account.delivery_type == 'ups':
raise ValidationError('Non-UPS Shipping Account indicated during UPS shipment.')
return third_party_account.name
if order and order.ups_carrier_account:
return order.ups_carrier_account
if picking and picking.sale_id.ups_carrier_account:
return picking.sale_id.ups_carrier_account
return self.ups_shipper_number
def _get_ups_carrier_account(self, picking):
# 3rd party billing should return False if not used.
account = self._get_ups_account_number(picking=picking)
return account if account != self.ups_shipper_number else False
"""
Overrides to use Hibou Delivery methods to get shipper etc. and to add 'transit_days' to result.
"""
def ups_rate_shipment(self, order):
superself = self.sudo()
srm = UPSRequest(self.log_xml, superself.ups_username, superself.ups_passwd, superself.ups_shipper_number, superself.ups_access_number, self.prod_environment)
ResCurrency = self.env['res.currency']
max_weight = self.ups_default_packaging_id.max_weight
packages = []
total_qty = 0
total_weight = 0
for line in order.order_line.filtered(lambda line: not line.is_delivery):
total_qty += line.product_uom_qty
total_weight += line.product_id.weight * line.product_qty
if max_weight and total_weight > max_weight:
total_package = int(total_weight / max_weight)
last_package_weight = total_weight % max_weight
for seq in range(total_package):
packages.append(Package(self, max_weight))
if last_package_weight:
packages.append(Package(self, last_package_weight))
else:
packages.append(Package(self, total_weight))
shipment_info = {
'total_qty': total_qty # required when service type = 'UPS Worldwide Express Freight'
}
if self.ups_cod:
cod_info = {
'currency': order.partner_id.country_id.currency_id.name,
'monetary_value': order.amount_total,
'funds_code': self.ups_cod_funds_code,
}
else:
cod_info = None
# Hibou Delivery
shipper_company = self.get_shipper_company(order=order)
shipper_warehouse = self.get_shipper_warehouse(order=order)
recipient = self.get_recipient(order=order)
date_planned = None
if self.env.context.get('date_planned'):
date_planned = self.env.context.get('date_planned')
check_value = srm.check_required_value(shipper_company, shipper_warehouse, recipient, order=order)
if check_value:
return {'success': False,
'price': 0.0,
'error_message': check_value,
'warning_message': False}
ups_service_type = order.ups_service_type or self.ups_default_service_type
result = srm.get_shipping_price(
shipment_info=shipment_info, packages=packages, shipper=shipper_company, ship_from=shipper_warehouse,
ship_to=recipient, packaging_type=self.ups_default_packaging_id.shipper_package_code, service_type=ups_service_type,
saturday_delivery=self.ups_saturday_delivery, cod_info=cod_info, date_planned=date_planned)
if result.get('error_message'):
return {'success': False,
'price': 0.0,
'error_message': _('Error:\n%s') % result['error_message'],
'warning_message': False}
if order.currency_id.name == result['currency_code']:
price = float(result['price'])
else:
quote_currency = ResCurrency.search([('name', '=', result['currency_code'])], limit=1)
price = quote_currency._convert(
float(result['price']), order.currency_id, order.company_id, order.date_order or fields.Date.today())
# Hibou Delivery
if self._get_ups_is_third_party(order=order):
# Don't show delivery amount, if ups bill my account option is true
price = 0.0
return {'success': True,
'price': price,
'transit_days': result.get('transit_days', 0),
'error_message': False,
'warning_message': False}
def ups_send_shipping(self, pickings):
res = []
superself = self.sudo()
srm = UPSRequest(self.log_xml, superself.ups_username, superself.ups_passwd, superself.ups_shipper_number, superself.ups_access_number, self.prod_environment)
ResCurrency = self.env['res.currency']
for picking in pickings:
# Hibou Delivery
shipper_company = superself.get_shipper_company(picking=picking)
shipper_warehouse = superself.get_shipper_warehouse(picking=picking)
recipient = superself.get_recipient(picking=picking)
packages = []
package_names = []
if picking.package_ids:
# Create all packages
for package in picking.package_ids:
packages.append(Package(self, package.shipping_weight, quant_pack=package.packaging_id, name=package.name))
package_names.append(package.name)
# Create one package with the rest (the content that is not in a package)
if picking.weight_bulk:
packages.append(Package(self, picking.weight_bulk))
invoice_line_total = 0
for move in picking.move_lines:
invoice_line_total += picking.company_id.currency_id.round(move.product_id.lst_price * move.product_qty)
shipment_info = {
'description': superself.get_order_name(picking=picking),
'total_qty': sum(sml.qty_done for sml in picking.move_line_ids),
'ilt_monetary_value': '%d' % invoice_line_total,
'itl_currency_code': self.env.user.company_id.currency_id.name,
'phone': recipient.mobile or recipient.phone,
}
if picking.sale_id and picking.sale_id.carrier_id != picking.carrier_id:
ups_service_type = picking.carrier_id.ups_default_service_type or picking.ups_service_type or superself.ups_default_service_type
else:
ups_service_type = picking.ups_service_type or superself.ups_default_service_type
# Hibou Delivery
ups_carrier_account = superself._get_ups_carrier_account(picking)
if picking.carrier_id.ups_cod:
cod_info = {
'currency': picking.partner_id.country_id.currency_id.name,
'monetary_value': picking.sale_id.amount_total,
'funds_code': superself.ups_cod_funds_code,
}
else:
cod_info = None
check_value = srm.check_required_value(shipper_company, shipper_warehouse, recipient, picking=picking)
if check_value:
raise UserError(check_value)
package_type = picking.package_ids and picking.package_ids[0].packaging_id.shipper_package_code or self.ups_default_packaging_id.shipper_package_code
result = srm.send_shipping(
shipment_info=shipment_info, packages=packages, shipper=shipper_company, ship_from=shipper_warehouse,
ship_to=recipient, packaging_type=package_type, service_type=ups_service_type, label_file_type=self.ups_label_file_type, ups_carrier_account=ups_carrier_account,
saturday_delivery=picking.carrier_id.ups_saturday_delivery, cod_info=cod_info)
if result.get('error_message'):
raise UserError(result['error_message'])
order = picking.sale_id
company = order.company_id or picking.company_id or self.env.user.company_id
currency_order = picking.sale_id.currency_id
if not currency_order:
currency_order = picking.company_id.currency_id
if currency_order.name == result['currency_code']:
price = float(result['price'])
else:
quote_currency = ResCurrency.search([('name', '=', result['currency_code'])], limit=1)
price = quote_currency._convert(
float(result['price']), currency_order, company, order.date_order or fields.Date.today())
package_labels = []
for track_number, label_binary_data in result.get('label_binary_data').items():
package_labels = package_labels + [(track_number, label_binary_data)]
carrier_tracking_ref = "+".join([pl[0] for pl in package_labels])
logmessage = _("Shipment created into UPS<br/>"
"<b>Tracking Numbers:</b> %s<br/>"
"<b>Packages:</b> %s") % (carrier_tracking_ref, ','.join(package_names))
if superself.ups_label_file_type != 'GIF':
attachments = [('LabelUPS-%s.%s' % (pl[0], superself.ups_label_file_type), pl[1]) for pl in package_labels]
if superself.ups_label_file_type == 'GIF':
attachments = [('LabelUPS.pdf', pdf.merge_pdf([pl[1] for pl in package_labels]))]
picking.message_post(body=logmessage, attachments=attachments)
shipping_data = {
'exact_price': price,
'tracking_number': carrier_tracking_ref}
res = res + [shipping_data]
return res

View File

@@ -0,0 +1,112 @@
import suds
from odoo.addons.delivery_ups.models.ups_request import UPSRequest
SUDS_VERSION = suds.__version__
def patched_get_shipping_price(self, shipment_info, packages, shipper, ship_from, ship_to, packaging_type, service_type,
saturday_delivery, cod_info, date_planned=False):
client = self._set_client(self.rate_wsdl, 'Rate', 'RateRequest')
request = client.factory.create('ns0:RequestType')
request.RequestOption = 'Rate'
classification = client.factory.create('ns2:CodeDescriptionType')
classification.Code = '00' # Get rates for the shipper account
classification.Description = 'Get rates for the shipper account'
namespace = 'ns2'
shipment = client.factory.create('{}:ShipmentType'.format(namespace))
# Hibou Delivery
if date_planned:
if not isinstance(date_planned, str):
date_planned = str(date_planned)
shipment.DeliveryTimeInformation = client.factory.create('{}:TimeInTransitRequestType'.format(namespace))
shipment.DeliveryTimeInformation.Pickup = client.factory.create('{}:PickupType'.format(namespace))
shipment.DeliveryTimeInformation.Pickup.Date = date_planned.split(' ')[0]
# End
for package in self.set_package_detail(client, packages, packaging_type, namespace, ship_from, ship_to, cod_info):
shipment.Package.append(package)
shipment.Shipper.Name = shipper.name or ''
shipment.Shipper.Address.AddressLine = [shipper.street or '', shipper.street2 or '']
shipment.Shipper.Address.City = shipper.city or ''
shipment.Shipper.Address.PostalCode = shipper.zip or ''
shipment.Shipper.Address.CountryCode = shipper.country_id.code or ''
if shipper.country_id.code in ('US', 'CA', 'IE'):
shipment.Shipper.Address.StateProvinceCode = shipper.state_id.code or ''
shipment.Shipper.ShipperNumber = self.shipper_number or ''
# shipment.Shipper.Phone.Number = shipper.phone or ''
shipment.ShipFrom.Name = ship_from.name or ''
shipment.ShipFrom.Address.AddressLine = [ship_from.street or '', ship_from.street2 or '']
shipment.ShipFrom.Address.City = ship_from.city or ''
shipment.ShipFrom.Address.PostalCode = ship_from.zip or ''
shipment.ShipFrom.Address.CountryCode = ship_from.country_id.code or ''
if ship_from.country_id.code in ('US', 'CA', 'IE'):
shipment.ShipFrom.Address.StateProvinceCode = ship_from.state_id.code or ''
# shipment.ShipFrom.Phone.Number = ship_from.phone or ''
shipment.ShipTo.Name = ship_to.name or ''
shipment.ShipTo.Address.AddressLine = [ship_to.street or '', ship_to.street2 or '']
shipment.ShipTo.Address.City = ship_to.city or ''
shipment.ShipTo.Address.PostalCode = ship_to.zip or ''
shipment.ShipTo.Address.CountryCode = ship_to.country_id.code or ''
if ship_to.country_id.code in ('US', 'CA', 'IE'):
shipment.ShipTo.Address.StateProvinceCode = ship_to.state_id.code or ''
# shipment.ShipTo.Phone.Number = ship_to.phone or ''
if not ship_to.commercial_partner_id.is_company:
shipment.ShipTo.Address.ResidentialAddressIndicator = suds.null()
shipment.Service.Code = service_type or ''
shipment.Service.Description = 'Service Code'
if service_type == "96":
shipment.NumOfPieces = int(shipment_info.get('total_qty'))
if saturday_delivery:
shipment.ShipmentServiceOptions.SaturdayDeliveryIndicator = saturday_delivery
else:
shipment.ShipmentServiceOptions = ''
shipment.ShipmentRatingOptions.NegotiatedRatesIndicator = 1
try:
# Get rate using for provided detail
response = client.service.ProcessRate(Request=request, CustomerClassification=classification, Shipment=shipment)
# Check if ProcessRate is not success then return reason for that
if response.Response.ResponseStatus.Code != "1":
return self.get_error_message(response.Response.ResponseStatus.Code,
response.Response.ResponseStatus.Description)
result = {}
result['currency_code'] = response.RatedShipment[0].TotalCharges.CurrencyCode
# Some users are qualified to receive negotiated rates
negotiated_rate = 'NegotiatedRateCharges' in response.RatedShipment[0] and response.RatedShipment[
0].NegotiatedRateCharges.TotalCharge.MonetaryValue or None
result['price'] = negotiated_rate or response.RatedShipment[0].TotalCharges.MonetaryValue
# Hibou Delivery
if hasattr(response.RatedShipment[0], 'GuaranteedDelivery') and hasattr(response.RatedShipment[0].GuaranteedDelivery, 'BusinessDaysInTransit'):
result['transit_days'] = int(response.RatedShipment[0].GuaranteedDelivery.BusinessDaysInTransit)
# End
return result
except suds.WebFault as e:
# childAtPath behaviour is changing at version 0.6
prefix = ''
if SUDS_VERSION >= "0.6":
prefix = '/Envelope/Body/Fault'
return self.get_error_message(
e.document.childAtPath(prefix + '/detail/Errors/ErrorDetail/PrimaryErrorCode/Code').getText(),
e.document.childAtPath(prefix + '/detail/Errors/ErrorDetail/PrimaryErrorCode/Description').getText())
except IOError as e:
return self.get_error_message('0', 'UPS Server Not Found:\n%s' % e)
UPSRequest.get_shipping_price = patched_get_shipping_price

1
helpdesk_rma/__init__.py Normal file
View File

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

21
helpdesk_rma/__manifest__.py Executable file
View File

@@ -0,0 +1,21 @@
{
'name': 'Helpdesk RMA',
'summary': 'Adds RMA functionality to the Helpdesk App',
'version': '12.0.1.0.0',
'author': "Hibou Corp.",
'category': 'Helpdesk',
'license': 'AGPL-3',
'images': [],
'website': "https://hibou.io",
'description': "Adds functionality to the Helpdesk App",
'depends': [
'helpdesk',
'rma',
],
'demo': [],
'data': [
'views/helpdesk_views.xml',
],
'auto_install': False,
'installable': True,
}

View File

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

View File

@@ -0,0 +1,26 @@
from odoo import api, models, fields
from logging import getLogger
_logger = getLogger(__name__)
class Ticket(models.Model):
_inherit = 'helpdesk.ticket'
rma_count = fields.Integer(compute='_compute_rma_count')
def _compute_rma_count(self):
for ticket in self:
if ticket.partner_id:
ticket.rma_count = self.env['rma.rma'].search_count([('partner_id', 'child_of', ticket.partner_id.id)])
else:
ticket.rma_count = 0
def action_partner_rma(self):
self.ensure_one()
action = self.env.ref('rma.action_rma_rma').read()[0]
action['context'] = {
'search_default_partner_id': self.partner_id.id,
}
return action

View File

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

View File

@@ -0,0 +1,14 @@
from odoo.tests import common
class TestHelpdesk(common.TransactionCase):
# We need to test Stage, Ticket, Server, and Filter Classes
# We created a couple fields per model and a couple methods
# for our functionality implementation
def setUp(self):
pass
def test_helpdesk_filter(self):
pass

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Helpdesk Ticket -->
<record id="helpdesk_ticket_view_form_inherit" model="ir.ui.view">
<field name="name">helpdesk.ticket.form.inherit</field>
<field name="model">helpdesk.ticket</field>
<field name="inherit_id" ref="helpdesk.helpdesk_ticket_view_form" />
<field name="arch" type="xml">
<xpath expr="//button[@name='toggle_active']" position="before">
<button class="oe_stat_button" type="object" name="action_partner_rma"
context="{'search_default_partner_id': partner_id}"
attrs="{'invisible': [('partner_id', '=', False)]}"
icon="fa-cubes">
<field string="RMAs" name="rma_count" widget="statinfo"/>
</button>
</xpath>
</field>
</record>
</odoo>

View File

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

22
helpdesk_sales/__manifest__.py Executable file
View File

@@ -0,0 +1,22 @@
{
'name': 'Helpdesk Sales',
'summary': 'Adds smart button on Helpdesk Tickets to see and create Sale Orders',
'version': '12.0.1.0.0',
'author': "Hibou Corp.",
'category': 'Helpdesk',
'license': 'AGPL-3',
'images': [],
'website': "https://hibou.io",
'description': "Adds smart button on Helpdesk Tickets to see and create Sale Orders",
'depends': [
'helpdesk',
'sale',
'sale_management',
],
'demo': [],
'data': [
'views/helpdesk_views.xml',
],
'auto_install': False,
'installable': True,
}

View File

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

View File

@@ -0,0 +1,15 @@
from odoo import api, models, fields
class Ticket(models.Model):
_inherit = 'helpdesk.ticket'
sale_order_count = fields.Integer(related='partner_id.sale_order_count', string='# of Sale Orders')
def action_partner_sales(self):
self.ensure_one()
action = self.env.ref('sale.act_res_partner_2_sale_order').read()[0]
action['context'] = {
'search_default_partner_id': self.partner_id.id,
}
return action

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Helpdesk Ticket -->
<record id="helpdesk_ticket_view_form_inherit" model="ir.ui.view">
<field name="name">helpdesk.ticket.form.inherit</field>
<field name="model">helpdesk.ticket</field>
<field name="inherit_id" ref="helpdesk.helpdesk_ticket_view_form" />
<field name="arch" type="xml">
<xpath expr="//button[@name='toggle_active']" position="before">
<button class="oe_stat_button" type="object" name="action_partner_sales"
context="{'search_default_partner_id': partner_id}"
attrs="{'invisible': [('partner_id', '=', False)]}"
icon="fa-usd">
<field string="Sales" name="sale_order_count" widget="statinfo"/>
</button>
</xpath>
</field>
</record>
</odoo>

1
pos_elavon/__init__.py Normal file
View File

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

View File

@@ -0,0 +1,41 @@
{
'name': 'Elavon Payment Services',
'version': '12.0.1.0.0',
'category': 'Point of Sale',
'sequence': 6,
'summary': 'Credit card support for Point Of Sale',
'description': """
Allow credit card POS payments
==============================
This module allows customers to pay for their orders with credit cards.
The transactions are processed by Elavon.
An Elavon merchant account is necessary. It allows the following:
* Fast payment by just swiping a credit card while on the payment screen
* Combining of cash payments and credit card payments
* Cashback
* Supported cards: Visa, MasterCard, American Express, Discover
""",
'depends': [
'web',
'barcodes',
'pos_sale',
],
'website': 'https://hibou.io',
'data': [
'data/pos_elavon_data.xml',
'security/ir.model.access.csv',
'views/pos_elavon_templates.xml',
'views/pos_elavon_views.xml',
'views/pos_config_setting_views.xml',
],
'demo': [
'data/pos_elavon_demo.xml',
],
'qweb': [
'static/src/xml/pos_elavon.xml',
],
'installable': True,
'auto_install': False,
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="barcode_rule_credit" model="barcode.rule">
<field name="name">Magnetic Credit Card</field>
<field name="barcode_nomenclature_id" ref="barcodes.default_barcode_nomenclature"/>
<field name="sequence">85</field>
<field name="type">credit</field>
<field name="encoding">any</field>
<field name="pattern">%.*</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0"?>
<odoo>
<data>
<!-- Elavon Test Account -->
<!-- This is a test account for testing with test cards and cannot be used in a live environment -->
<record id="pos_elavon_configuration" model="pos_elavon.configuration">
<field name="name">Elavon Demo</field>
<field name="merchant_id">123</field>
<field name="merchant_user_id">POS</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,2 @@
from . import pos_elavon
from . import pos_elavon_transaction

View File

@@ -0,0 +1,82 @@
from odoo import models, fields, api, _
from odoo.tools.float_utils import float_compare
class BarcodeRule(models.Model):
_inherit = 'barcode.rule'
type = fields.Selection(selection_add=[
('credit', 'Credit Card')
])
class CRMTeam(models.Model):
_inherit = 'crm.team'
pos_elavon_merchant_pin = fields.Char(string='POS Elavon Merchant PIN')
class PosElavonConfiguration(models.Model):
_name = 'pos_elavon.configuration'
name = fields.Char(required=True, help='Name of this Elavon configuration')
merchant_id = fields.Char(string='Merchant ID', required=True, help='ID of the merchant to authenticate him on the payment provider server')
merchant_user_id = fields.Char(string='Merchant User ID', required=True, help='User ID, e.g. POS')
class AccountBankStatementLine(models.Model):
_inherit = "account.bank.statement.line"
elavon_card_number = fields.Char(string='Card Number', help='Masked credit card.')
elavon_txn_id = fields.Char(string='Elavon Transaction ID')
class AccountJournal(models.Model):
_inherit = 'account.journal'
pos_elavon_config_id = fields.Many2one('pos_elavon.configuration', string='Elavon Credentials',
help='The configuration of Elavon that can be used with this journal.')
class PosOrder(models.Model):
_inherit = "pos.order"
@api.model
def _payment_fields(self, ui_paymentline):
fields = super(PosOrder, self)._payment_fields(ui_paymentline)
fields.update({
'elavon_card_number': ui_paymentline.get('elavon_card_number'),
'elavon_txn_id': ui_paymentline.get('elavon_txn_id'),
})
return fields
def add_payment(self, data):
statement_id = super(PosOrder, self).add_payment(data)
statement_lines = self.env['account.bank.statement.line'].search([('statement_id', '=', statement_id),
('pos_statement_id', '=', self.id),
('journal_id', '=', data['journal'])])
statement_lines = statement_lines.filtered(lambda line: float_compare(line.amount, data['amount'],
precision_rounding=line.journal_currency_id.rounding) == 0)
# we can get multiple statement_lines when there are >1 credit
# card payments with the same amount. In that case it doesn't
# matter which statement line we pick, just pick one that
# isn't already used.
for line in statement_lines:
if not line.elavon_card_number:
line.elavon_card_number = data.get('elavon_card_number')
line.elavon_txn_id = data.get('elavon_txn_id')
break
return statement_id
class AutoVacuum(models.AbstractModel):
_inherit = 'ir.autovacuum'
@api.model
def power_on(self, *args, **kwargs):
self.env['pos_elavon.elavon_transaction'].cleanup_old_tokens()
return super(AutoVacuum, self).power_on(*args, **kwargs)

View File

@@ -0,0 +1,128 @@
from datetime import date, timedelta
import requests
import werkzeug
from odoo import models, api, service
from odoo.tools.translate import _
from odoo.exceptions import UserError
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, misc
class ElavonTransaction(models.Model):
_name = 'pos_elavon.elavon_transaction'
def _get_pos_session(self):
pos_session = self.env['pos.session'].search([('state', '=', 'opened'), ('user_id', '=', self.env.uid)], limit=1)
if not pos_session:
raise UserError(_("No opened point of sale session for user %s found") % self.env.user.name)
pos_session.login()
return pos_session
def _get_pos_elavon_config_id(self, config, journal_id):
journal = config.journal_ids.filtered(lambda r: r.id == journal_id)
if journal and journal.pos_elavon_config_id:
return journal.pos_elavon_config_id
else:
raise UserError(_("No Elavon configuration associated with the journal."))
def _setup_request(self, data):
# todo: in master make the client include the pos.session id and use that
pos_session = self._get_pos_session()
config = pos_session.config_id
pos_elavon_config = self._get_pos_elavon_config_id(config, data['journal_id'])
data['ssl_merchant_id'] = pos_elavon_config.sudo().merchant_id
data['ssl_user_id'] = pos_elavon_config.sudo().merchant_user_id
# Load from team
data['ssl_pin'] = config.sudo().crm_team_id.pos_elavon_merchant_pin
data['ssl_show_form'] = 'false'
data['ssl_result_format'] = 'ascii'
def _do_request(self, data):
if not data['ssl_merchant_id'] or not data['ssl_user_id'] or not data['ssl_pin']:
return "not setup"
response = ''
url = 'https://api.convergepay.com/VirtualMerchant/process.do'
if self.env['ir.config_parameter'].sudo().get_param('pos_elavon.enable_test_env'):
url = 'https://api.demo.convergepay.com/VirtualMerchantDemo/process.do'
try:
r = requests.post(url, data=data, timeout=500)
r.raise_for_status()
response = werkzeug.utils.unescape(r.content.decode())
except Exception:
response = "timeout"
return response
def _do_reversal_or_voidsale(self, data, is_voidsale):
try:
self._setup_request(data)
except UserError:
return "internal error"
# Can we voidsale?
#data['is_voidsale'] = is_voidsale
data['ssl_transaction_type'] = 'CCVOID'
response = self._do_request(data)
return response
@api.model
def do_payment(self, data):
try:
self._setup_request(data)
except UserError:
return "internal error"
data['ssl_transaction_type'] = 'CCSALE'
response = self._do_request(data)
return response
@api.model
def do_reversal(self, data):
return self._do_reversal_or_voidsale(data, False)
@api.model
def do_voidsale(self, data):
return self._do_reversal_or_voidsale(data, True)
@api.model
def do_return(self, data):
try:
self._setup_request(data)
except UserError:
return "internal error"
data['ssl_transaction_type'] = 'CCRETURN'
response = self._do_request(data)
return response
@api.model
def do_credit(self, data):
try:
self._setup_request(data)
except UserError:
return "internal error"
data['ssl_transaction_type'] = 'CCCREDIT'
response = self._do_request(data)
return response
# One time (the ones we use) Elavon tokens are required to be
# deleted after 6 months
# This is a from Mercury, probably not needed anymore.
@api.model
def cleanup_old_tokens(self):
expired_creation_date = (date.today() - timedelta(days=6 * 30)).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
for order in self.env['pos.order'].search([('create_date', '<', expired_creation_date)]):
order.ref_no = ""
order.record_no = ""

View File

@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_pos_elavon_configuration,elavon.configuration,model_pos_elavon_configuration,point_of_sale.group_pos_manager,1,1,1,1
access_pos_elavon_elavon_transaction,elavon.transaction,model_pos_elavon_elavon_transaction,,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_pos_elavon_configuration elavon.configuration model_pos_elavon_configuration point_of_sale.group_pos_manager 1 1 1 1
3 access_pos_elavon_elavon_transaction elavon.transaction model_pos_elavon_elavon_transaction 1 0 0 0

View File

@@ -0,0 +1,25 @@
.pos .paymentline.selected.o_pos_elavon_swipe_pending, .pos .paymentline.o_pos_elavon_swipe_pending {
background: rgb(239, 153, 65);
}
.pos .col-tendered.edit.o_pos_elavon_swipe_pending {
color: rgb(239, 153, 65);
box-shadow: 0px 0px 0px 3px rgb(239, 153, 65);
}
.pos .paymentline .elavon_manual_transaction {
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.5);
background-color: rgba(255,255,255, 0.2);
display: block;
text-align: center;
margin: 6px 0 0 0;
padding: 2px 5px;
}
.pos .payment-manual-transaction label {
margin-top: 6px;
display: block;
}
.pos .payment-manual-transaction label span {
font-size: 75%;
}

View File

@@ -0,0 +1,693 @@
odoo.define('pos_elavon.pos_elavon', function (require) {
"use strict";
var core = require('web.core');
var rpc = require('web.rpc');
var screens = require('point_of_sale.screens');
var gui = require('point_of_sale.gui');
var pos_model = require('point_of_sale.models');
var _t = core._t;
var BarcodeEvents = require('barcodes.BarcodeEvents').BarcodeEvents;
var PopupWidget = require('point_of_sale.popups');
var ScreenWidget = screens.ScreenWidget;
var PaymentScreenWidget = screens.PaymentScreenWidget;
pos_model.load_fields("account.journal", "pos_elavon_config_id");
pos_model.PosModel = pos_model.PosModel.extend({
getOnlinePaymentJournals: function () {
var self = this;
var online_payment_journals = [];
$.each(this.journals, function (i, val) {
if (val.pos_elavon_config_id) {
online_payment_journals.push({label:self.getCashRegisterByJournalID(val.id).journal_id[1], item:val.id});
}
});
return online_payment_journals;
},
getCashRegisterByJournalID: function (journal_id) {
var cashregister_return;
$.each(this.cashregisters, function (index, cashregister) {
if (cashregister.journal_id[0] === journal_id) {
cashregister_return = cashregister;
}
});
return cashregister_return;
},
decodeMagtek: function (s) {
if (s.indexOf('%B') < 0) {
return 0;
}
var to_return = {};
to_return['ssl_card_number'] = s.substr(s.indexOf('%B') + 2, s.indexOf('^') - 2);
var temp = s.substr(s.indexOf('^') + 1, s.length);
name = temp.substr(0, temp.indexOf('/'));
var exp = temp.substr(temp.indexOf('^') + 1, 4);
to_return['ssl_exp_date'] = exp.substr(2, 2) + exp.substr(0, 2)
to_return['ssl_card_present'] = 'Y'
to_return['ssl_track_data'] = s
return to_return
},
decodeElavonResponse: function (data) {
var lines = data.split(/\r?\n/);
var to_return = {}
lines.forEach(function (line){
var eq = line.indexOf('=');
if (eq > 0) {
var name = line.substr(0, eq);
var value = line.substr(eq+1);
to_return[name] = value;
}
});
return to_return;
}
});
var _paylineproto = pos_model.Paymentline.prototype;
pos_model.Paymentline = pos_model.Paymentline.extend({
init_from_JSON: function (json) {
_paylineproto.init_from_JSON.apply(this, arguments);
this.paid = true;
this.elavon_swipe_pending = json.elavon_swipe_pending;
this.elavon_card_number = json.elavon_card_number;
this.elavon_card_brand = json.elavon_card_brand;
this.elavon_txn_id = json.elavon_txn_id;
this.set_credit_card_name();
},
export_as_JSON: function () {
return _.extend(_paylineproto.export_as_JSON.apply(this, arguments), {
paid: this.paid,
elavon_swipe_pending: this.elavon_swipe_pending,
elavon_card_number: this.elavon_card_number,
elavon_card_brand: this.elavon_card_brand,
elavon_txn_id: this.elavon_txn_id,
});
},
set_credit_card_name: function () {
if (this.elavon_card_number) {
this.name = this.elavon_card_brand + ' ' + this.elavon_card_number;
}
}
});
// Popup to show all transaction state for the payment.
var PaymentTransactionPopupWidget = PopupWidget.extend({
template: 'PaymentTransactionPopupWidget',
show: function (options) {
var self = this;
this._super(options);
options.transaction.then(function (data) {
if (data.auto_close) {
setTimeout(function () {
self.gui.close_popup();
}, 2000);
} else {
self.close();
self.$el.find('.popup').append('<div class="footer"><div class="button cancel">Ok</div></div>');
}
self.$el.find('p.body').html(data.message);
}).progress(function (data) {
self.$el.find('p.body').html(data.message);
});
}
});
gui.define_popup({name:'payment-transaction', widget: PaymentTransactionPopupWidget});
// Popup to record manual CC entry
var PaymentManualTransactionPopupWidget = PopupWidget.extend({
template: 'PaymentManualTransactionPopupWidget',
show: function (options) {
this._super(options);
this.setup_transaction_callback();
},
setup_transaction_callback: function(){
var self = this;
this.options.transaction.then(function (data) {
if (data.auto_close) {
setTimeout(function () {
self.gui.close_popup();
}, 2000);
}
self.$el.find('p.body').html(data.message);
}).progress(function (data) {
self.$el.find('p.body').html(data.message);
});
},
click_confirm: function(){
var values = {
ssl_card_number: this.$('input[name="card_number"]').val(),
ssl_exp_date: this.$('input[name="exp_date"]').val(),
ssl_cvv2cvc2: this.$('input[name="cvv2cvc2"]').val(),
};
if ( this.options.transaction.state() != 'pending' ) {
this.options.transaction = $.Deferred();
this.setup_transaction_callback();
}
if( this.options.confirm ){
this.options.confirm.call(this, values, this.options.transaction);
}
},
});
gui.define_popup({name:'payment-manual-transaction', widget: PaymentManualTransactionPopupWidget});
// On all screens, if a card is swipped, return a popup error.
ScreenWidget.include({
credit_error_action: function () {
this.gui.show_popup('error-barcode',_t('Go to payment screen to use cards'));
},
show: function () {
this._super();
if(this.pos.getOnlinePaymentJournals().length !== 0) {
this.pos.barcode_reader.set_action_callback('credit', _.bind(this.credit_error_action, this));
}
}
});
// On Payment screen, allow electronic payments
PaymentScreenWidget.include({
// Override init because it eats all keyboard input and we need it for popups...
init: function(parent, options) {
var self = this;
this._super(parent, options);
// This is a keydown handler that prevents backspace from
// doing a back navigation. It also makes sure that keys that
// do not generate a keypress in Chrom{e,ium} (eg. delete,
// backspace, ...) get passed to the keypress handler.
this.keyboard_keydown_handler = function(event){
if (event.keyCode === 8 || event.keyCode === 46) { // Backspace and Delete
// don't prevent delete if a popup is up
if (self.gui.has_popup()) {
return;
}
event.preventDefault();
// These do not generate keypress events in
// Chrom{e,ium}. Even if they did, we just called
// preventDefault which will cancel any keypress that
// would normally follow. So we call keyboard_handler
// explicitly with this keydown event.
self.keyboard_handler(event);
}
};
// This keyboard handler listens for keypress events. It is
// also called explicitly to handle some keydown events that
// do not generate keypress events.
this.keyboard_handler = function(event){
// On mobile Chrome BarcodeEvents relies on an invisible
// input being filled by a barcode device. Let events go
// through when this input is focused.
if (self.gui.has_popup()) {
return;
}
if (BarcodeEvents.$barcodeInput && BarcodeEvents.$barcodeInput.is(":focus")) {
return;
}
var key = '';
if (event.type === "keypress") {
if (event.keyCode === 13) { // Enter
self.validate_order();
} else if ( event.keyCode === 190 || // Dot
event.keyCode === 110 || // Decimal point (numpad)
event.keyCode === 188 || // Comma
event.keyCode === 46 ) { // Numpad dot
key = self.decimal_point;
} else if (event.keyCode >= 48 && event.keyCode <= 57) { // Numbers
key = '' + (event.keyCode - 48);
} else if (event.keyCode === 45) { // Minus
key = '-';
} else if (event.keyCode === 43) { // Plus
key = '+';
}
} else { // keyup/keydown
if (event.keyCode === 46) { // Delete
key = 'CLEAR';
} else if (event.keyCode === 8) { // Backspace
key = 'BACKSPACE';
}
}
self.payment_input(key);
event.preventDefault();
};
},
// end init override...
// How long we wait for the odoo server to deliver the response of
// a Elavon transaction
server_timeout_in_ms: 120000,
// How many Elavon transactions we send without receiving a
// response
server_retries: 3,
_get_swipe_pending_line: function () {
var i = 0;
var lines = this.pos.get_order().get_paymentlines();
for (i = 0; i < lines.length; i++) {
if (lines[i].elavon_swipe_pending) {
return lines[i];
}
}
return 0;
},
// Hunt around for an existing line by amount etc.
_does_credit_payment_line_exist: function (amount, card_number, card_brand, card_owner_name) {
var i = 0;
var lines = this.pos.get_order().get_paymentlines();
for (i = 0; i < lines.length; i++) {
if (lines[i].get_amount() === amount &&
lines[i].elavon_card_number === card_number &&
lines[i].elavon_card_brand === card_brand) {
return true;
}
}
return false;
},
retry_elavon_transaction: function (def, response, retry_nr, can_connect_to_server, callback, args) {
var self = this;
var message = "";
if (retry_nr < self.server_retries) {
if (response) {
message = "Retry #" + (retry_nr + 1) + "...<br/><br/>" + response.message;
} else {
message = "Retry #" + (retry_nr + 1) + "...";
}
def.notify({
message: message
});
setTimeout(function () {
callback.apply(self, args);
}, 1000);
} else {
if (response) {
// what?
//message = "Error " + response.error + ": " + lookUpCodeTransaction["TimeoutError"][response.error] + "<br/>" + response.message;
} else {
if (can_connect_to_server) {
message = _t("No response from Elavon (Elavon down?)");
} else {
message = _t("No response from server (connected to network?)");
}
}
def.resolve({
message: message,
auto_close: false
});
}
},
// Handler to manage the card reader string
credit_code_transaction: function (parsed_result, old_deferred, retry_nr) {
var order = this.pos.get_order();
if(this.pos.getOnlinePaymentJournals().length === 0) {
return;
}
var self = this;
var transaction = {};
var swipe_pending_line = self._get_swipe_pending_line();
var purchase_amount = 0;
if (swipe_pending_line) {
purchase_amount = swipe_pending_line.get_amount();
} else {
purchase_amount = self.pos.get_order().get_due();
}
// handle manual or swiped
if (parsed_result.ssl_card_number) {
transaction = parsed_result;
} else {
var decodedMagtek = self.pos.decodeMagtek(parsed_result.code);
if (!decodedMagtek) {
this.gui.show_popup('error', {
'title': _t('Could not read card'),
'body': _t('This can be caused by a badly executed swipe or by not having your keyboard layout set to US QWERTY (not US International).'),
});
return;
}
transaction = decodedMagtek;
}
var endpoint = 'do_payment';
if (purchase_amount < 0.0) {
purchase_amount = -purchase_amount;
endpoint = 'do_credit';
}
transaction['ssl_amount'] = purchase_amount.toFixed(2); // This is the format and type that Elavon is expecting
transaction['ssl_invoice_number'] = self.pos.get_order().uid;
transaction['journal_id'] = parsed_result.journal_id;
var def = old_deferred || new $.Deferred();
retry_nr = retry_nr || 0;
// show the transaction popup.
// the transaction deferred is used to update transaction status
// if we have a previous deferred it indicates that this is a retry
if (! old_deferred) {
self.gui.show_popup('payment-transaction', {
transaction: def
});
def.notify({
message: _t('Handling transaction...'),
});
}
rpc.query({
model: 'pos_elavon.elavon_transaction',
method: endpoint,
args: [transaction],
}, {
timeout: self.server_timeout_in_ms,
})
.then(function (data) {
// if not receiving a response from Elavon, we should retry
if (data === "timeout") {
self.retry_elavon_transaction(def, null, retry_nr, true, self.credit_code_transaction, [parsed_result, def, retry_nr + 1]);
return;
}
if (data === "not setup") {
def.resolve({
message: _t("Please setup your Elavon merchant account.")
});
return;
}
if (data === "internal error") {
def.resolve({
message: _t("Odoo error while processing transaction.")
});
return;
}
var result = self.pos.decodeElavonResponse(data);
result.journal_id = parsed_result.journal_id;
// Example error data:
// errorCode=4025
// errorName=Invalid Credentials
// errorMessage=The credentials supplied in the authorization request are invalid.
var approval_code = result.ssl_approval_code;
if (endpoint == 'do_payment' && (!approval_code || !approval_code.trim())) {
def.resolve({
message: "Error (" + (result.ssl_result_message || result.errorName) + ")",
auto_close: false,
});
}
// handle ssl_approval_code (only seen empty ones so far)
if (false /* duplicate transaction detected */) {
def.resolve({
message: result.ssl_result_message,
auto_close: true,
});
}
// any other edge cases or failures?
if (result.ssl_result_message == 'APPROVAL' || result.ssl_result_message == 'PARTIAL APPROVAL') {
var order = self.pos.get_order();
if (swipe_pending_line) {
order.select_paymentline(swipe_pending_line);
} else {
order.add_paymentline(self.pos.getCashRegisterByJournalID(parsed_result.journal_id));
}
var amount = parseFloat(result.ssl_amount);
if (endpoint == 'do_credit') {
amount = -amount;
}
order.selected_paymentline.set_amount(amount);
order.selected_paymentline.paid = true;
order.selected_paymentline.elavon_swipe_pending = false;
order.selected_paymentline.elavon_card_number = result.ssl_card_number;
order.selected_paymentline.elavon_card_brand = result.ssl_card_short_description;
if (result.ssl_card_short_description) {
order.selected_paymentline.elavon_card_brand = result.ssl_card_short_description;
} else {
order.selected_paymentline.elavon_card_brand = result.ssl_card_type;
}
order.selected_paymentline.elavon_txn_id = result.ssl_txn_id;
// maybe approval code....
order.selected_paymentline.set_credit_card_name();
self.order_changes();
self.reset_input();
self.render_paymentlines();
order.trigger('change', order);
def.resolve({
message: result.ssl_result_message + ' : ' + order.selected_paymentline.elavon_txn_id,
auto_close: true,
});
}
}).fail(function (type, error) {
self.retry_elavon_transaction(def, null, retry_nr, false, self.credit_code_transaction, [parsed_result, def, retry_nr + 1]);
});
},
credit_code_cancel: function () {
return;
},
credit_code_action: function (parsed_result) {
var self = this;
var online_payment_journals = this.pos.getOnlinePaymentJournals();
if (online_payment_journals.length === 1) {
parsed_result.journal_id = online_payment_journals[0].item;
self.credit_code_transaction(parsed_result);
} else { // this is for supporting another payment system like elavon
this.gui.show_popup('selection',{
title: 'Pay ' + this.pos.get_order().get_due().toFixed(2) + ' with : ',
list: online_payment_journals,
confirm: function (item) {
parsed_result.journal_id = item;
self.credit_code_transaction(parsed_result);
},
cancel: self.credit_code_cancel,
});
}
},
remove_paymentline_by_ref: function (line) {
this.pos.get_order().remove_paymentline(line);
this.reset_input();
this.render_paymentlines();
},
do_reversal: function (line, is_voidsale, old_deferred, retry_nr) {
var def = old_deferred || new $.Deferred();
var self = this;
retry_nr = retry_nr || 0;
// show the transaction popup.
// the transaction deferred is used to update transaction status
this.gui.show_popup('payment-transaction', {
transaction: def
});
// TODO Maybe do this, as it might be convenient to store the data in json and then do updates to it
// var request_data = _.extend({
// 'transaction_type': 'Credit',
// 'transaction_code': 'VoidSaleByRecordNo',
// }, line.elavon_data);
// TODO Do we need these options?
// var message = "";
// var rpc_method = "";
//
// if (is_voidsale) {
// message = _t("Reversal failed, sending VoidSale...");
// rpc_method = "do_voidsale";
// } else {
// message = _t("Sending reversal...");
// rpc_method = "do_reversal";
// }
var request_data = {
'ssl_txn_id': line.elavon_txn_id,
'journal_id': line.cashregister.journal_id[0],
};
if (! old_deferred) {
def.notify({
message: 'Sending reversal...',
});
}
rpc.query({
model: 'pos_elavon.elavon_transaction',
method: 'do_reversal',
args: [request_data],
}, {
timeout: self.server_timeout_in_ms
})
.then(function (data) {
if (data === "timeout") {
self.retry_elavon_transaction(def, null, retry_nr, true, self.do_reversal, [line, is_voidsale, def, retry_nr + 1]);
return;
}
if (data === "internal error") {
def.resolve({
message: _t("Odoo error while processing transaction.")
});
return;
}
var result = self.pos.decodeElavonResponse(data);
if (result.ssl_result_message == 'APPROVAL') {
def.resolve({
message: 'Reversal succeeded.'
});
self.remove_paymentline_by_ref(line);
return;
}
if (result.errorCode == '5040') {
// Already removed.
def.resolve({
message: 'Invalid Transaction ID. This probably means that it was already reversed.',
});
self.remove_paymentline_by_ref(line);
return;
}
def.resolve({
message: 'Unknown message check console logs. ' + result.ssl_result_message,
});
}).fail(function (type, error) {
self.retry_elavon_transaction(def, null, retry_nr, false, self.do_reversal, [line, is_voidsale, def, retry_nr + 1]);
});
},
click_delete_paymentline: function (cid) {
var lines = this.pos.get_order().get_paymentlines();
for (var i = 0; i < lines.length; i++) {
if (lines[i].cid === cid && lines[i].elavon_txn_id) {
this.do_reversal(lines[i], false);
return;
}
}
this._super(cid);
},
// make sure there is only one paymentline waiting for a swipe
click_paymentmethods: function (id) {
var order = this.pos.get_order();
var cashregister = null;
for (var i = 0; i < this.pos.cashregisters.length; i++) {
if (this.pos.cashregisters[i].journal_id[0] === id){
cashregister = this.pos.cashregisters[i];
break;
}
}
if (cashregister.journal.pos_elavon_config_id) {
var pending_swipe_line = this._get_swipe_pending_line();
if (pending_swipe_line) {
this.gui.show_popup('error',{
'title': _t('Error'),
'body': _t('One credit card swipe already pending.'),
});
} else {
this._super(id);
order.selected_paymentline.elavon_swipe_pending = true;
this.render_paymentlines();
order.trigger('change', order); // needed so that export_to_JSON gets triggered
}
} else {
this._super(id);
}
},
click_elavon_manual_transaction: function (id) {
var self = this;
var def = new $.Deferred();
var pending_swipe_line = this._get_swipe_pending_line();
if (!pending_swipe_line) {
this.gui.show_popup('error',{
'title': _t('Error'),
'body': _t('No swipe pending payment line for manual transaction.'),
});
return;
}
this.gui.show_popup('payment-manual-transaction', {
transaction: def,
confirm: function(card_details, deffered) {
card_details.journal_id = pending_swipe_line.cashregister.journal.id;
self.credit_code_transaction(card_details, deffered);
def.notify({message: _t('Handling transaction...')});
},
});
},
show: function () {
this._super();
if (this.pos.getOnlinePaymentJournals().length !== 0) {
this.pos.barcode_reader.set_action_callback('credit', _.bind(this.credit_code_action, this));
}
},
render_paymentlines: function() {
this._super();
var self = this;
self.$('.paymentlines-container').on('click', '.elavon_manual_transaction', function(){
self.click_elavon_manual_transaction();
});
},
// before validating, get rid of any paymentlines that are waiting
// on a swipe.
validate_order: function(force_validation) {
if (this.pos.get_order().is_paid() && ! this.invoicing) {
var lines = this.pos.get_order().get_paymentlines();
for (var i = 0; i < lines.length; i++) {
if (lines[i].elavon_swipe_pending) {
this.pos.get_order().remove_paymentline(lines[i]);
this.render_paymentlines();
}
}
}
this._super(force_validation);
}
});
});

View File

@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" inherit_id="point_of_sale.template">
<t t-name="PaymentTransactionPopupWidget">
<div class="modal-dialog">
<div class="popup">
<p class="title">Electronic Payment</p>
<p class="body"></p>
</div>
</div>
</t>
<t t-name="PaymentManualTransactionPopupWidget">
<div class="modal-dialog">
<div class="popup payment-manual-transaction">
<p class="title">Manual Electronic Payment</p>
<label for="card_number">Card Number</label>
<input name="card_number" type="text" t-att-value="widget.options.card_number || ''" placeholder="4355111122223333" autocomplete="off"></input>
<label for="exp_date">Expiration Date <span>(4 digits)</span></label>
<input name="exp_date" type="text" t-att-value="widget.options.exp_date || ''" placeholder="0223" autocomplete="off"></input>
<label for="cvv2cvc2">Card Security Code <span>(3 or 4 digits)</span></label>
<input name="cvv2cvc2" type="text" t-att-value="widget.options.cvv2cvc2 || ''" placeholder="003" autocomplete="off"></input>
<p class="body"></p>
<div class="footer">
<div class="button confirm">
Ok
</div>
<div class="button cancel">
Cancel
</div>
</div>
</div>
</div>
</t>
<t t-extend="PaymentScreen-Paymentlines">
<t t-jquery=".col-name" t-operation="inner">
<t t-if="line.cashregister.journal.type === 'bank'">
<t t-if="line.elavon_swipe_pending">
<div>WAITING FOR SWIPE</div>
</t>
<t t-if="! line.elavon_swipe_pending">
<t t-esc='line.name' />
</t>
<t t-else="">
<span class="btn btn-small elavon_manual_transaction">Manual</span>
</t>
</t>
<t t-if="line.cashregister.journal.type !== 'bank'">
<t t-esc='line.name' />
</t>
</t>
<t t-jquery="tbody tr.paymentline.selected">
this.removeAttr('class');
this.attr('t-attf-class', 'paymentline selected #{line.elavon_swipe_pending ? \'o_pos_elavon_swipe_pending\' : \'\'}');
</t>
<t t-jquery="tbody tr.paymentline[t-att-data-cid*='line.cid']">
this.removeAttr('class');
this.attr('t-attf-class', 'paymentline #{line.elavon_swipe_pending ? \'o_pos_elavon_swipe_pending\' : \'\'}');
</t>
<t t-jquery="tbody tr td.col-tendered.edit">
this.removeAttr('class');
this.attr('t-attf-class', 'col-tendered edit #{line.elavon_swipe_pending ? \'o_pos_elavon_swipe_pending\' : \'\'}');
</t>
</t>
<t t-name="PosElavonSignature">
<t t-foreach="paymentlines" t-as="paymentline">
<t t-if="!gift &amp;&amp; paymentline.elavon_data &amp;&amp; ! printed_signature">
<br />
<div>CARDHOLDER WILL PAY CARD ISSUER</div>
<div>ABOVE AMOUNT PURSUANT</div>
<div>TO CARDHOLDER AGREEMENT</div>
<br />
<br />
<div>X______________________________</div>
<t t-set="printed_signature" t-value="true"/>
</t>
</t>
</t>
<t t-extend="XmlReceipt">
<t t-jquery="t[t-foreach*='paymentlines'][t-as*='line']" t-operation="append">
<t t-if="!gift &amp;&amp; line.elavon_data">
<line line-ratio="1">
<left><pre> APPROVAL CODE:</pre><t t-esc="line.elavon_auth_code"/></left>
</line>
</t>
</t>
<t t-jquery="receipt" t-operation="append">
<div>
<t t-call="PosElavonSignature"/>
</div>
</t>
</t>
<t t-extend="PosTicket">
<t t-jquery="t[t-foreach*='paymentlines'][t-as*='line']" t-operation="append">
<tr>
<td colspan="2">
<t t-if="!gift &amp;&amp; line.elavon_auth_code">
&amp;nbsp;&amp;nbsp;APPROVAL CODE: <t t-esc="line.elavon_auth_code"/>
</t>
</td>
</tr>
</t>
<t t-jquery="t[t-if*='receipt.footer']" t-operation="after">
<div class="pos-center-align">
<t t-call="PosElavonSignature"/>
</div>
</t>
</t>
</templates>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="pos_config_view_form_inherit_pos_elavon" model="ir.ui.view">
<field name="name">pos.config.form.inherit.elavon</field>
<field name="model">pos.config</field>
<field name="inherit_id" ref="point_of_sale.pos_config_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@id='payment_methods']" position="after">
<div class="col-12 col-lg-6 o_setting_box" id="pos_elavon">
<div class="o_setting_right_pane">
<span class="o_form_label">Elavon</span>
<div>
<button name="%(pos_elavon.action_configuration_form)d" icon="fa-arrow-right" type="action" string="Elavon Accounts" class="btn-link"/>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets" inherit_id="point_of_sale.assets">
<xpath expr="." position="inside">
<script type="text/javascript" src="/pos_elavon/static/src/js/pos_elavon.js"></script>
<link rel="stylesheet" href="/pos_elavon/static/src/css/pos_elavon.css" />
</xpath>
</template>
</odoo>

View File

@@ -0,0 +1,117 @@
<?xml version="1.0"?>
<odoo>
<record id="view_pos_elavon_configuration_form" model="ir.ui.view" >
<field name="name">Elavon Configurations</field>
<field name="model">pos_elavon.configuration</field>
<field name="arch" type="xml">
<form string="Card Reader">
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name"/></h1>
</div>
<div>
<p>
<i>Elavon Configurations</i> define what Elavon account will be used when
processing credit card transactions in the Point Of Sale. Setting up a Elavon
configuration will enable you to allow payments with various credit cards
(eg. Visa, MasterCard, Discovery, American Express, ...). After setting up this
configuration you should associate it with a Point Of Sale payment method.
</p><p>
We currently support standard card reader devices. It can be connected
directly to the Point Of Sale device or it can be connected to the POSBox.
</p><p>
Using the Elavon integration in the Point Of Sale is easy: just press the
associated payment method. After that the amount can be adjusted (eg. for cashback)
just like on any other payment line. Whenever the payment line is set up, a card
can be swiped through the card reader device.
</p><p>
For quickly handling orders: just swiping a credit card when on the payment screen
(without having pressed anything else) will charge the full amount of the order to
the card.
</p><p>
Note that you will need to setup a 'PIN' number on POS Teams.
</p>
</div>
<group col="2">
<field name="merchant_id"/>
<field name="merchant_user_id"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_pos_elavon_configuration_tree" model="ir.ui.view">
<field name="name">Elavon Configurations</field>
<field name="model">pos_elavon.configuration</field>
<field name="arch" type="xml">
<tree string="Card Reader">
<field name="name"/>
<field name="merchant_id"/>
</tree>
</field>
</record>
<record id="action_configuration_form" model="ir.actions.act_window">
<field name="name">Elavon Configurations</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">pos_elavon.configuration</field>
<field name="view_type">form</field>
<field name="view_mode">tree,kanban,form</field>
<field name="help" type="html">
<p class="oe_view_nocontent_create">
Click to configure your card reader.
</p>
</field>
</record>
<record id="view_account_journal_pos_user_form" model="ir.ui.view">
<field name="name">POS Journal</field>
<field name="model">account.journal</field>
<field name="inherit_id" ref="point_of_sale.view_account_journal_pos_user_form"></field>
<field name="arch" type="xml">
<xpath expr="//group[@name='amount_authorized']" position="after">
<group attrs="{'invisible': [('type', '!=', 'bank')]}">
<field name="pos_elavon_config_id"/>
</group>
</xpath>
</field>
</record>
<record id="view_account_bank_journal_form_inherited_pos_elavon" model="ir.ui.view">
<field name="name">account.bank.journal.form.inherited.pos.elavon</field>
<field name="model">account.journal</field>
<field name="inherit_id" ref="point_of_sale.view_account_bank_journal_form_inherited_pos"></field>
<field name="arch" type="xml">
<xpath expr="//field[@name='journal_user']" position="after">
<field name="pos_elavon_config_id" attrs="{'invisible': [('journal_user', '=', False)]}"/>
</xpath>
</field>
</record>
<record id="crm_team_view_form_inherit_pos_sale_elavon" model="ir.ui.view">
<field name="name">crm.team.form.pos.elavon</field>
<field name="model">crm.team</field>
<field name="inherit_id" ref="pos_sale.crm_team_view_form_inherit_pos_sale"></field>
<field name="arch" type="xml">
<xpath expr="//field[@name='company_id']" position="after">
<field name="pos_elavon_merchant_pin"/>
</xpath>
</field>
</record>
<record id="view_pos_order" model="ir.ui.view">
<field name="name">POS orders</field>
<field name="model">pos.order</field>
<field name="inherit_id" ref="point_of_sale.view_pos_pos_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='amount']" position="before">
<field name="elavon_card_number"/>
<field name="elavon_txn_id"/>
</xpath>
</field>
</record>
<menuitem parent="point_of_sale.menu_point_config_product" action="pos_elavon.action_configuration_form" id="menu_pos_pos_elavon_config" groups="base.group_no_one" sequence="35"/>
</odoo>