mirror of
https://gitlab.com/hibou-io/hibou-odoo/suite.git
synced 2025-01-20 12:37:31 +02:00
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:
1
account_invoice_change/__init__.py
Normal file
1
account_invoice_change/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import wizard
|
||||
30
account_invoice_change/__manifest__.py
Normal file
30
account_invoice_change/__manifest__.py
Normal 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,
|
||||
}
|
||||
1
account_invoice_change/tests/__init__.py
Normal file
1
account_invoice_change/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_invoice_change
|
||||
60
account_invoice_change/tests/test_invoice_change.py
Normal file
60
account_invoice_change/tests/test_invoice_change.py
Normal 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)
|
||||
1
account_invoice_change/wizard/__init__.py
Normal file
1
account_invoice_change/wizard/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import invoice_change
|
||||
56
account_invoice_change/wizard/invoice_change.py
Normal file
56
account_invoice_change/wizard/invoice_change.py
Normal 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
|
||||
48
account_invoice_change/wizard/invoice_change_views.xml
Normal file
48
account_invoice_change/wizard/invoice_change_views.xml
Normal 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>
|
||||
1
account_invoice_change_analytic/__init__.py
Normal file
1
account_invoice_change_analytic/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import wizard
|
||||
23
account_invoice_change_analytic/__manifest__.py
Normal file
23
account_invoice_change_analytic/__manifest__.py
Normal 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,
|
||||
}
|
||||
1
account_invoice_change_analytic/tests/__init__.py
Normal file
1
account_invoice_change_analytic/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_invoice_change
|
||||
46
account_invoice_change_analytic/tests/test_invoice_change.py
Normal file
46
account_invoice_change_analytic/tests/test_invoice_change.py
Normal 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)
|
||||
1
account_invoice_change_analytic/wizard/__init__.py
Normal file
1
account_invoice_change_analytic/wizard/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import invoice_change
|
||||
41
account_invoice_change_analytic/wizard/invoice_change.py
Normal file
41
account_invoice_change_analytic/wizard/invoice_change.py
Normal 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()
|
||||
@@ -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>
|
||||
1
delivery_fedex_hibou/__init__.py
Normal file
1
delivery_fedex_hibou/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
19
delivery_fedex_hibou/__manifest__.py
Normal file
19
delivery_fedex_hibou/__manifest__.py
Normal 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,
|
||||
}
|
||||
2
delivery_fedex_hibou/models/__init__.py
Normal file
2
delivery_fedex_hibou/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import delivery_fedex
|
||||
from . import stock
|
||||
456
delivery_fedex_hibou/models/delivery_fedex.py
Normal file
456
delivery_fedex_hibou/models/delivery_fedex.py
Normal 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
|
||||
200
delivery_fedex_hibou/models/fedex_request.py
Normal file
200
delivery_fedex_hibou/models/fedex_request.py
Normal 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
|
||||
8
delivery_fedex_hibou/models/stock.py
Normal file
8
delivery_fedex_hibou/models/stock.py
Normal 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')
|
||||
14
delivery_fedex_hibou/views/stock_views.xml
Normal file
14
delivery_fedex_hibou/views/stock_views.xml
Normal 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>
|
||||
1
delivery_gls_nl/__init__.py
Normal file
1
delivery_gls_nl/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
26
delivery_gls_nl/__manifest__.py
Normal file
26
delivery_gls_nl/__manifest__.py
Normal 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,
|
||||
}
|
||||
1
delivery_gls_nl/models/__init__.py
Normal file
1
delivery_gls_nl/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import delivery_gls_nl
|
||||
294
delivery_gls_nl/models/delivery_gls_nl.py
Normal file
294
delivery_gls_nl/models/delivery_gls_nl.py
Normal 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)
|
||||
36
delivery_gls_nl/models/gls_nl_request.py
Normal file
36
delivery_gls_nl/models/gls_nl_request.py
Normal 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)
|
||||
61
delivery_gls_nl/views/delivery_gls_nl_view.xml
Normal file
61
delivery_gls_nl/views/delivery_gls_nl_view.xml
Normal 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>
|
||||
1
delivery_stamps/__init__.py
Normal file
1
delivery_stamps/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
26
delivery_stamps/__manifest__.py
Normal file
26
delivery_stamps/__manifest__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
'name': 'Stamps.com (USPS) Shipping',
|
||||
'summary': 'Send your shippings through Stamps.com and track them online.',
|
||||
'version': '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,
|
||||
}
|
||||
1
delivery_stamps/models/__init__.py
Normal file
1
delivery_stamps/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import delivery_stamps
|
||||
32
delivery_stamps/models/api/LICENSE
Executable file
32
delivery_stamps/models/api/LICENSE
Executable 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.
|
||||
14
delivery_stamps/models/api/__init__.py
Executable file
14
delivery_stamps/models/api/__init__.py
Executable file
@@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
stamps
|
||||
~~~~~~
|
||||
|
||||
Stamps.com API.
|
||||
|
||||
:copyright: 2014 by Jonathan Zempel.
|
||||
:license: BSD, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
__author__ = "Jonathan Zempel"
|
||||
__license__ = "BSD"
|
||||
__version__ = "0.9.1"
|
||||
102
delivery_stamps/models/api/config.py
Executable file
102
delivery_stamps/models/api/config.py
Executable file
@@ -0,0 +1,102 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
stamps.config
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
Stamps.com configuration.
|
||||
|
||||
:copyright: 2014 by Jonathan Zempel.
|
||||
:license: BSD, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
from configparser import NoOptionError, NoSectionError, SafeConfigParser
|
||||
from urllib.request import pathname2url
|
||||
from urllib.parse import urljoin
|
||||
import os
|
||||
|
||||
|
||||
VERSION = 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
|
||||
301
delivery_stamps/models/api/services.py
Executable file
301
delivery_stamps/models/api/services.py
Executable 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
|
||||
149
delivery_stamps/models/api/tests.py
Executable file
149
delivery_stamps/models/api/tests.py
Executable file
@@ -0,0 +1,149 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
stamps.tests
|
||||
~~~~~~~~~~~~
|
||||
|
||||
Stamps.com API tests.
|
||||
|
||||
:copyright: 2014 by Jonathan Zempel.
|
||||
:license: BSD, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
from .config import StampsConfiguration
|
||||
from .services import StampsService
|
||||
from datetime import date, datetime
|
||||
from time import sleep
|
||||
from unittest import TestCase
|
||||
import logging
|
||||
import os
|
||||
|
||||
|
||||
logging.basicConfig()
|
||||
logging.getLogger("suds.client").setLevel(logging.DEBUG)
|
||||
file_path = os.path.abspath(__file__)
|
||||
directory_path = os.path.dirname(file_path)
|
||||
file_name = os.path.join(directory_path, "tests.cfg")
|
||||
CONFIGURATION = StampsConfiguration(wsdl="testing", file_name=file_name)
|
||||
|
||||
|
||||
def get_rate(service):
|
||||
"""Get a test rate.
|
||||
|
||||
:param service: Instance of the stamps service.
|
||||
"""
|
||||
ret_val = service.create_shipping()
|
||||
ret_val.ShipDate = date.today().isoformat()
|
||||
ret_val.FromZIPCode = "94107"
|
||||
ret_val.ToZIPCode = "20500"
|
||||
ret_val.PackageType = "Package"
|
||||
rate = service.get_rates(ret_val)[0]
|
||||
ret_val.Amount = rate.Amount
|
||||
ret_val.ServiceType = rate.ServiceType
|
||||
ret_val.DeliverDays = rate.DeliverDays
|
||||
ret_val.DimWeighting = rate.DimWeighting
|
||||
ret_val.Zone = rate.Zone
|
||||
ret_val.RateCategory = rate.RateCategory
|
||||
ret_val.ToState = rate.ToState
|
||||
add_on = service.create_add_on()
|
||||
add_on.AddOnType = "US-A-DC"
|
||||
ret_val.AddOns.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
|
||||
5321
delivery_stamps/models/api/wsdls/stamps_v84.test.wsdl
Normal file
5321
delivery_stamps/models/api/wsdls/stamps_v84.test.wsdl
Normal file
File diff suppressed because it is too large
Load Diff
5321
delivery_stamps/models/api/wsdls/stamps_v84.wsdl
Normal file
5321
delivery_stamps/models/api/wsdls/stamps_v84.wsdl
Normal file
File diff suppressed because it is too large
Load Diff
329
delivery_stamps/models/delivery_stamps.py
Normal file
329
delivery_stamps/models/delivery_stamps.py
Normal 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)
|
||||
38
delivery_stamps/views/delivery_stamps_view.xml
Normal file
38
delivery_stamps/views/delivery_stamps_view.xml
Normal 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>
|
||||
1
delivery_ups_hibou/__init__.py
Normal file
1
delivery_ups_hibou/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
18
delivery_ups_hibou/__manifest__.py
Normal file
18
delivery_ups_hibou/__manifest__.py
Normal 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,
|
||||
}
|
||||
2
delivery_ups_hibou/models/__init__.py
Normal file
2
delivery_ups_hibou/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import delivery_ups
|
||||
from . import ups_request_patch
|
||||
218
delivery_ups_hibou/models/delivery_ups.py
Normal file
218
delivery_ups_hibou/models/delivery_ups.py
Normal 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
|
||||
112
delivery_ups_hibou/models/ups_request_patch.py
Normal file
112
delivery_ups_hibou/models/ups_request_patch.py
Normal 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
1
helpdesk_rma/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
21
helpdesk_rma/__manifest__.py
Executable file
21
helpdesk_rma/__manifest__.py
Executable 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,
|
||||
}
|
||||
1
helpdesk_rma/models/__init__.py
Normal file
1
helpdesk_rma/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import helpdesk
|
||||
26
helpdesk_rma/models/helpdesk.py
Normal file
26
helpdesk_rma/models/helpdesk.py
Normal 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
|
||||
1
helpdesk_rma/tests/__init__.py
Normal file
1
helpdesk_rma/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_helpdesk
|
||||
14
helpdesk_rma/tests/test_helpdesk.py
Normal file
14
helpdesk_rma/tests/test_helpdesk.py
Normal 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
|
||||
21
helpdesk_rma/views/helpdesk_views.xml
Normal file
21
helpdesk_rma/views/helpdesk_views.xml
Normal 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>
|
||||
1
helpdesk_sales/__init__.py
Normal file
1
helpdesk_sales/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
22
helpdesk_sales/__manifest__.py
Executable file
22
helpdesk_sales/__manifest__.py
Executable 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,
|
||||
}
|
||||
1
helpdesk_sales/models/__init__.py
Normal file
1
helpdesk_sales/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import helpdesk
|
||||
15
helpdesk_sales/models/helpdesk.py
Normal file
15
helpdesk_sales/models/helpdesk.py
Normal 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
|
||||
21
helpdesk_sales/views/helpdesk_views.xml
Normal file
21
helpdesk_sales/views/helpdesk_views.xml
Normal 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
1
pos_elavon/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
41
pos_elavon/__manifest__.py
Normal file
41
pos_elavon/__manifest__.py
Normal 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,
|
||||
}
|
||||
13
pos_elavon/data/pos_elavon_data.xml
Normal file
13
pos_elavon/data/pos_elavon_data.xml
Normal 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>
|
||||
12
pos_elavon/data/pos_elavon_demo.xml
Normal file
12
pos_elavon/data/pos_elavon_demo.xml
Normal 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>
|
||||
2
pos_elavon/models/__init__.py
Normal file
2
pos_elavon/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import pos_elavon
|
||||
from . import pos_elavon_transaction
|
||||
82
pos_elavon/models/pos_elavon.py
Normal file
82
pos_elavon/models/pos_elavon.py
Normal 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)
|
||||
128
pos_elavon/models/pos_elavon_transaction.py
Normal file
128
pos_elavon/models/pos_elavon_transaction.py
Normal 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 = ""
|
||||
3
pos_elavon/security/ir.model.access.csv
Normal file
3
pos_elavon/security/ir.model.access.csv
Normal 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
|
||||
|
25
pos_elavon/static/src/css/pos_elavon.css
Normal file
25
pos_elavon/static/src/css/pos_elavon.css
Normal 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%;
|
||||
}
|
||||
693
pos_elavon/static/src/js/pos_elavon.js
Normal file
693
pos_elavon/static/src/js/pos_elavon.js
Normal 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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
114
pos_elavon/static/src/xml/pos_elavon.xml
Normal file
114
pos_elavon/static/src/xml/pos_elavon.xml
Normal 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 && paymentline.elavon_data && ! 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 && 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 && line.elavon_auth_code">
|
||||
&nbsp;&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>
|
||||
22
pos_elavon/views/pos_config_setting_views.xml
Normal file
22
pos_elavon/views/pos_config_setting_views.xml
Normal 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>
|
||||
11
pos_elavon/views/pos_elavon_templates.xml
Normal file
11
pos_elavon/views/pos_elavon_templates.xml
Normal 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>
|
||||
117
pos_elavon/views/pos_elavon_views.xml
Normal file
117
pos_elavon/views/pos_elavon_views.xml
Normal 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>
|
||||
Reference in New Issue
Block a user