changes
7
fusion_canada_post/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Nexa Systems Inc
|
||||
|
||||
## Changelog
|
||||
|
||||
### 19.0.1.0.0
|
||||
|
||||
- Initial version
|
||||
3
fusion_canada_post/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import models
|
||||
from . import wizard
|
||||
from . import fusion_cp_api
|
||||
36
fusion_canada_post/__manifest__.py
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
# App information
|
||||
"name": "Canada Post",
|
||||
"version": "19.0.2.0.0",
|
||||
"category": "Inventory/Delivery",
|
||||
"summary": "Canada Post shipping integration — live pricing, "
|
||||
"label generation, shipment tracking, and "
|
||||
"multi-package support.",
|
||||
"license": "OPL-1",
|
||||
"depends": ["stock_delivery", "sale_management", "mail"],
|
||||
# Views
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"data/ir_sequence_data.xml",
|
||||
"data/fusion_canada_post_data.xml",
|
||||
"views/fusion_cp_shipment_views.xml",
|
||||
"views/delivery_carrier_view.xml",
|
||||
"views/choose_delivery_carrier_views.xml",
|
||||
"views/sale_order_views.xml",
|
||||
"views/stock_picking_views.xml",
|
||||
"views/menus.xml",
|
||||
"views/res_config_settings_views.xml",
|
||||
],
|
||||
"images": ["static/description/canada_post_banner.gif"],
|
||||
# Author
|
||||
"author": "Nexa Systems Inc",
|
||||
"website": "https://nexasystems.ca",
|
||||
"maintainer": "Nexa Systems Inc",
|
||||
"support": "support@nexasystems.ca",
|
||||
# Technical
|
||||
"installable": True,
|
||||
"auto_install": False,
|
||||
"application": True,
|
||||
"price": "149.00",
|
||||
"currency": "USD",
|
||||
}
|
||||
169
fusion_canada_post/data/fusion_canada_post_data.xml
Normal file
@@ -0,0 +1,169 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo noupdate="0">
|
||||
|
||||
<!-- ── Standard Canada Post Box Sizes (inches / lbs) ── -->
|
||||
|
||||
<record id="fusion_cp_pkg_26x20x20" model="stock.package.type">
|
||||
<field name="name">CP Box 26×20×20 in, 35 lb</field>
|
||||
<field name="package_carrier_type">fusion_canada_post</field>
|
||||
<field name="packaging_length">26</field>
|
||||
<field name="width">20</field>
|
||||
<field name="height">20</field>
|
||||
<field name="max_weight">35</field>
|
||||
</record>
|
||||
|
||||
<record id="fusion_cp_pkg_26x26x9" model="stock.package.type">
|
||||
<field name="name">CP Box 26×26×9 in, 15 lb</field>
|
||||
<field name="package_carrier_type">fusion_canada_post</field>
|
||||
<field name="packaging_length">26</field>
|
||||
<field name="width">26</field>
|
||||
<field name="height">9</field>
|
||||
<field name="max_weight">15</field>
|
||||
</record>
|
||||
|
||||
<record id="fusion_cp_pkg_32x28x10" model="stock.package.type">
|
||||
<field name="name">CP Box 32×28×10 in, 12 lb</field>
|
||||
<field name="package_carrier_type">fusion_canada_post</field>
|
||||
<field name="packaging_length">32</field>
|
||||
<field name="width">28</field>
|
||||
<field name="height">10</field>
|
||||
<field name="max_weight">12</field>
|
||||
</record>
|
||||
|
||||
<record id="fusion_cp_pkg_34x28x10" model="stock.package.type">
|
||||
<field name="name">CP Box 34×28×10 in, 10 lb</field>
|
||||
<field name="package_carrier_type">fusion_canada_post</field>
|
||||
<field name="packaging_length">34</field>
|
||||
<field name="width">28</field>
|
||||
<field name="height">10</field>
|
||||
<field name="max_weight">10</field>
|
||||
</record>
|
||||
|
||||
<record id="fusion_cp_pkg_9x25x27" model="stock.package.type">
|
||||
<field name="name">CP Box 9×25×27 in, 16 lb</field>
|
||||
<field name="package_carrier_type">fusion_canada_post</field>
|
||||
<field name="packaging_length">9</field>
|
||||
<field name="width">25</field>
|
||||
<field name="height">27</field>
|
||||
<field name="max_weight">16</field>
|
||||
</record>
|
||||
|
||||
<record id="fusion_cp_pkg_9x25x29" model="stock.package.type">
|
||||
<field name="name">CP Box 9×25×29 in, 17 lb</field>
|
||||
<field name="package_carrier_type">fusion_canada_post</field>
|
||||
<field name="packaging_length">9</field>
|
||||
<field name="width">25</field>
|
||||
<field name="height">29</field>
|
||||
<field name="max_weight">17</field>
|
||||
</record>
|
||||
|
||||
<record id="fusion_cp_pkg_10x10x6" model="stock.package.type">
|
||||
<field name="name">CP Box 10×10×6 in, 35 lb</field>
|
||||
<field name="package_carrier_type">fusion_canada_post</field>
|
||||
<field name="packaging_length">10</field>
|
||||
<field name="width">10</field>
|
||||
<field name="height">6</field>
|
||||
<field name="max_weight">35</field>
|
||||
</record>
|
||||
|
||||
<record id="fusion_cp_pkg_25x15x30" model="stock.package.type">
|
||||
<field name="name">CP Box 25×15×30 in, 25 lb</field>
|
||||
<field name="package_carrier_type">fusion_canada_post</field>
|
||||
<field name="packaging_length">25</field>
|
||||
<field name="width">15</field>
|
||||
<field name="height">30</field>
|
||||
<field name="max_weight">25</field>
|
||||
</record>
|
||||
|
||||
<record id="fusion_cp_pkg_10x10x10" model="stock.package.type">
|
||||
<field name="name">CP Box 10×10×10 in, 2 lb</field>
|
||||
<field name="package_carrier_type">fusion_canada_post</field>
|
||||
<field name="packaging_length">10</field>
|
||||
<field name="width">10</field>
|
||||
<field name="height">10</field>
|
||||
<field name="max_weight">2</field>
|
||||
</record>
|
||||
|
||||
<record id="fusion_cp_pkg_28x15x30" model="stock.package.type">
|
||||
<field name="name">CP Box 28×15×30 in, 40 lb</field>
|
||||
<field name="package_carrier_type">fusion_canada_post</field>
|
||||
<field name="packaging_length">28</field>
|
||||
<field name="width">15</field>
|
||||
<field name="height">30</field>
|
||||
<field name="max_weight">40</field>
|
||||
</record>
|
||||
|
||||
<record id="fusion_cp_pkg_28x10x30" model="stock.package.type">
|
||||
<field name="name">CP Box 28×10×30 in, 16 lb</field>
|
||||
<field name="package_carrier_type">fusion_canada_post</field>
|
||||
<field name="packaging_length">28</field>
|
||||
<field name="width">10</field>
|
||||
<field name="height">30</field>
|
||||
<field name="max_weight">16</field>
|
||||
</record>
|
||||
|
||||
<record id="fusion_cp_pkg_22x10x6_cm" model="stock.package.type">
|
||||
<field name="name">CP Box 22×10×6 cm, 10 kg</field>
|
||||
<field name="package_carrier_type">fusion_canada_post</field>
|
||||
<field name="packaging_length">22</field>
|
||||
<field name="width">10</field>
|
||||
<field name="height">6</field>
|
||||
<field name="max_weight">10</field>
|
||||
</record>
|
||||
|
||||
<record id="fusion_cp_pkg_22x13x6" model="stock.package.type">
|
||||
<field name="name">CP Box 22×13×6 in, 10 lb</field>
|
||||
<field name="package_carrier_type">fusion_canada_post</field>
|
||||
<field name="packaging_length">22</field>
|
||||
<field name="width">13</field>
|
||||
<field name="height">6</field>
|
||||
<field name="max_weight">10</field>
|
||||
</record>
|
||||
|
||||
<record id="fusion_cp_pkg_12x10x5" model="stock.package.type">
|
||||
<field name="name">CP Box 12×10×5 in, 2 lb</field>
|
||||
<field name="package_carrier_type">fusion_canada_post</field>
|
||||
<field name="packaging_length">12</field>
|
||||
<field name="width">10</field>
|
||||
<field name="height">5</field>
|
||||
<field name="max_weight">2</field>
|
||||
</record>
|
||||
|
||||
<!-- Old default box — replaced with a standard size -->
|
||||
<record id="fusion_cp_packaging_BOX" model="stock.package.type">
|
||||
<field name="name">CP Box 10×10×10 in (Legacy)</field>
|
||||
<field name="package_carrier_type">fusion_canada_post</field>
|
||||
<field name="packaging_length">10</field>
|
||||
<field name="width">10</field>
|
||||
<field name="height">10</field>
|
||||
<field name="max_weight">2</field>
|
||||
</record>
|
||||
|
||||
<!-- ── Canada Post Delivery Product ── -->
|
||||
|
||||
<record id="product_product_delivery_fusion_canada_post" model="product.product">
|
||||
<field name="name">SHIPPING BY CANADA POST</field>
|
||||
<field name="default_code">CPSHIPPING</field>
|
||||
<field name="type">service</field>
|
||||
<field name="categ_id" ref="delivery.product_category_deliveries" />
|
||||
<field name="sale_ok" eval="False" />
|
||||
<field name="purchase_ok" eval="False" />
|
||||
<field name="list_price">0.0</field>
|
||||
</record>
|
||||
|
||||
<!-- ── Default Canada Post Carrier ── -->
|
||||
|
||||
<record id="delivery_carrier_fusion_canada_post" model="delivery.carrier">
|
||||
<field name="name">Canada Post</field>
|
||||
<field
|
||||
name="product_id"
|
||||
ref="fusion_canada_post.product_product_delivery_fusion_canada_post"
|
||||
/>
|
||||
<field name="delivery_type">fusion_canada_post</field>
|
||||
<field name="fusion_cp_type">commercial</field>
|
||||
<field name="fusion_cp_dimension_unit">in</field>
|
||||
<field name="option_code">SO</field>
|
||||
<field name="service_type">DOM.RP</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
12
fusion_canada_post/data/ir_sequence_data.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="seq_fusion_cp_shipment" model="ir.sequence">
|
||||
<field name="name">Fusion CP Shipment</field>
|
||||
<field name="code">fusion.cp.shipment</field>
|
||||
<field name="prefix">FCP-</field>
|
||||
<field name="padding">5</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
2
fusion_canada_post/fusion_cp_api/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import fusion_cp_response
|
||||
from . import utils
|
||||
164
fusion_canada_post/fusion_cp_api/fusion_cp_response.py
Normal file
@@ -0,0 +1,164 @@
|
||||
import lxml
|
||||
import datetime
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from odoo.addons.fusion_canada_post.fusion_cp_api.utils import get_dom_tree, python_2_unicode_compatible
|
||||
import json
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class ResponseDataObject():
|
||||
|
||||
def __init__(self, mydict, datetime_nodes=[]):
|
||||
self._load_dict(mydict, list(datetime_nodes))
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
||||
def __str__(self):
|
||||
return "%s" % self.__dict__
|
||||
|
||||
def has_key(self, name):
|
||||
try:
|
||||
getattr(self, name)
|
||||
return True
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
def get(self, name, default=None):
|
||||
try:
|
||||
return getattr(self, name)
|
||||
except AttributeError:
|
||||
return default
|
||||
|
||||
def _setattr(self, name, value, datetime_nodes):
|
||||
if name.lower() in datetime_nodes:
|
||||
try:
|
||||
ts = "%s %s" % (value.partition('T')[0], value.partition('T')[2].partition('.')[0])
|
||||
value = datetime.datetime.strptime(ts, '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
setattr(self, name, value)
|
||||
|
||||
def _load_dict(self, mydict, datetime_nodes):
|
||||
|
||||
for a in list(mydict.items()):
|
||||
|
||||
if isinstance(a[1], dict):
|
||||
o = ResponseDataObject(a[1], datetime_nodes)
|
||||
setattr(self, a[0], o)
|
||||
|
||||
elif isinstance(a[1], list):
|
||||
objs = []
|
||||
for i in a[1]:
|
||||
if i is None or isinstance(i, str) or isinstance(i, str):
|
||||
objs.append(i)
|
||||
else:
|
||||
objs.append(ResponseDataObject(i, datetime_nodes))
|
||||
|
||||
setattr(self, a[0], objs)
|
||||
else:
|
||||
self._setattr(a[0], a[1], datetime_nodes)
|
||||
|
||||
class Response():
|
||||
|
||||
def __init__(self, obj, verb=None, parse_response=True):
|
||||
self._obj = obj
|
||||
if parse_response:
|
||||
try:
|
||||
self._dom = self._parse_xml(obj.content)
|
||||
self._dict = self._etree_to_dict(self._dom)
|
||||
|
||||
if verb and 'Envelope' in list(self._dict.keys()):
|
||||
elem = self._dom.find('Body').find('%sResponse' % verb)
|
||||
if elem is not None:
|
||||
self._dom = elem
|
||||
|
||||
self._dict = self._dict['Envelope']['Body'].get('%sResponse' % verb, self._dict)
|
||||
elif verb:
|
||||
elem = self._dom.find('%sResponse' % verb)
|
||||
if elem is not None:
|
||||
self._dom = elem
|
||||
|
||||
self._dict = self._dict.get('%sResponse' % verb, self._dict)
|
||||
|
||||
self.reply = ResponseDataObject(self._dict,[])
|
||||
|
||||
except lxml.etree.XMLSyntaxError as e:
|
||||
_logger.debug('Response parse failed: %s' % e)
|
||||
self.reply = ResponseDataObject({}, [])
|
||||
else:
|
||||
self.reply = ResponseDataObject({}, [])
|
||||
|
||||
def _get_node_path(self, t):
|
||||
i = t
|
||||
path = []
|
||||
path.insert(0, i.tag)
|
||||
while 1:
|
||||
try:
|
||||
path.insert(0, i.getparent().tag)
|
||||
i = i.getparent()
|
||||
except AttributeError:
|
||||
break
|
||||
|
||||
return '.'.join(path)
|
||||
|
||||
@staticmethod
|
||||
def _pullval(v):
|
||||
if len(v) == 1:
|
||||
return v[0]
|
||||
else:
|
||||
return v
|
||||
|
||||
def _etree_to_dict(self, t):
|
||||
if type(t) == lxml.etree._Comment:
|
||||
return {}
|
||||
|
||||
# remove xmlns from nodes
|
||||
t.tag = self._get_node_tag(t)
|
||||
|
||||
d = {t.tag: {} if t.attrib else None}
|
||||
children = list(t)
|
||||
if children:
|
||||
dd = defaultdict(list)
|
||||
for dc in map(self._etree_to_dict, children):
|
||||
for k, v in list(dc.items()):
|
||||
dd[k].append(v)
|
||||
|
||||
d = {t.tag: dict((k, self._pullval(v)) for k, v in list(dd.items()))}
|
||||
|
||||
parent_path = self._get_node_path(t)
|
||||
for k in list(d[t.tag].keys()):
|
||||
path = "%s.%s" % (parent_path, k)
|
||||
|
||||
if t.attrib:
|
||||
d[t.tag].update(('_' + k, v) for k, v in list(t.attrib.items()))
|
||||
if t.text:
|
||||
text = t.text.strip()
|
||||
if children or t.attrib:
|
||||
if text:
|
||||
d[t.tag]['value'] = text
|
||||
else:
|
||||
d[t.tag] = text
|
||||
return d
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._obj, name)
|
||||
|
||||
def _parse_xml(self, xml):
|
||||
return get_dom_tree(xml)
|
||||
|
||||
def _get_node_tag(self, node):
|
||||
return node.tag.replace('{' + node.nsmap.get(node.prefix, '') + '}', '')
|
||||
|
||||
def dom(self, lxml=True):
|
||||
if not lxml:
|
||||
pass
|
||||
return self._dom
|
||||
|
||||
def dict(self):
|
||||
return self._dict
|
||||
|
||||
def json(self):
|
||||
return json.dumps(self.dict())
|
||||
215
fusion_canada_post/fusion_cp_api/utils.py
Normal file
@@ -0,0 +1,215 @@
|
||||
import sys
|
||||
from lxml import etree as ET
|
||||
|
||||
def parse_yaml(yaml_file):
|
||||
"""
|
||||
This is simple approach to parsing a yaml config that is only
|
||||
intended for this SDK as this only supports a very minimal subset
|
||||
of yaml options.
|
||||
"""
|
||||
|
||||
with open(yaml_file) as f:
|
||||
data = {None: {}}
|
||||
current_key = None
|
||||
|
||||
for line in f.readlines():
|
||||
|
||||
# ignore comments
|
||||
if line.startswith('#'):
|
||||
continue
|
||||
|
||||
# parse the header
|
||||
elif line[0].isalnum():
|
||||
key = line.strip().replace(':', '')
|
||||
current_key = key
|
||||
data[current_key] = {}
|
||||
|
||||
# parse the key: value line
|
||||
elif line[0].isspace():
|
||||
values = line.strip().split(':')
|
||||
|
||||
if len(values) == 2:
|
||||
cval = values[1].strip()
|
||||
|
||||
if cval == '0':
|
||||
cval = False
|
||||
elif cval == '1':
|
||||
cval = True
|
||||
|
||||
data[current_key][values[0].strip()] = cval
|
||||
return data
|
||||
|
||||
|
||||
def python_2_unicode_compatible(klass):
|
||||
"""
|
||||
A decorator that defines __unicode__ and __str__ methods under Python 2.
|
||||
Under Python 3 it does nothing.
|
||||
|
||||
To support Python 2 and 3 with a single code base, define a __str__ method
|
||||
returning text and apply this decorator to the class.
|
||||
"""
|
||||
if sys.version_info[0] < 3:
|
||||
if '__str__' not in klass.__dict__:
|
||||
raise ValueError("@python_2_unicode_compatible cannot be applied "
|
||||
"to %s because it doesn't define __str__()." %
|
||||
klass.__name__)
|
||||
klass.__unicode__ = klass.__str__
|
||||
klass.__str__ = lambda self: self.__unicode__().encode('utf-8')
|
||||
return klass
|
||||
|
||||
|
||||
def get_dom_tree(xml):
|
||||
tree = ET.fromstring(xml)
|
||||
return tree.getroottree().getroot()
|
||||
|
||||
def attribute_check(root):
|
||||
attrs = []
|
||||
value = None
|
||||
|
||||
if isinstance(root, dict):
|
||||
if '#text' in root:
|
||||
value = root['#text']
|
||||
if '@attrs' in root:
|
||||
for ak, av in sorted(root.pop('@attrs').items()):
|
||||
attrs.append(str('{0}="{1}"').format(ak, smart_encode(av)))
|
||||
|
||||
return attrs, value
|
||||
|
||||
def smart_encode(value):
|
||||
try:
|
||||
if sys.version_info[0] < 3:
|
||||
return str(value).encode('utf-8')
|
||||
else:
|
||||
return value
|
||||
|
||||
except UnicodeDecodeError:
|
||||
return value
|
||||
|
||||
|
||||
def to_xml(root):
|
||||
return dict2xml(root)
|
||||
|
||||
def dict2xml(root):
|
||||
xml = str('')
|
||||
if root is None:
|
||||
return xml
|
||||
|
||||
if isinstance(root, dict):
|
||||
for key in sorted(root.keys()):
|
||||
|
||||
if isinstance(root[key], dict):
|
||||
attrs, value = attribute_check(root[key])
|
||||
|
||||
if not value:
|
||||
value = dict2xml(root[key])
|
||||
elif isinstance(value, dict):
|
||||
value = dict2xml(value)
|
||||
|
||||
attrs_sp = str('')
|
||||
if len(attrs) > 0:
|
||||
attrs_sp = str(' ')
|
||||
|
||||
xml = str('{xml}<{tag}{attrs_sp}{attrs}>{value}</{tag}>') \
|
||||
.format(**{'tag': key, 'xml': str(xml), 'attrs': str(' ').join(attrs),
|
||||
'value': smart_encode(value), 'attrs_sp': attrs_sp})
|
||||
|
||||
elif isinstance(root[key], list):
|
||||
|
||||
for item in root[key]:
|
||||
attrs, value = attribute_check(item)
|
||||
|
||||
if not value:
|
||||
value = dict2xml(item)
|
||||
elif isinstance(value, dict):
|
||||
value = dict2xml(value)
|
||||
|
||||
attrs_sp = ''
|
||||
if len(attrs) > 0:
|
||||
attrs_sp = ' '
|
||||
|
||||
xml = str('{xml}<{tag}{attrs_sp}{attrs}>{value}</{tag}>') \
|
||||
.format(**{'xml': str(xml), 'tag': key, 'attrs': ' '.join(attrs), 'value': smart_encode(value),
|
||||
'attrs_sp': attrs_sp})
|
||||
|
||||
else:
|
||||
value = root[key]
|
||||
xml = str('{xml}<{tag}>{value}</{tag}>') \
|
||||
.format(**{'xml': str(xml), 'tag': key, 'value': smart_encode(value)})
|
||||
|
||||
elif isinstance(root, str) or isinstance(root, int) \
|
||||
or isinstance(root, str) or isinstance(root, int) \
|
||||
or isinstance(root, float):
|
||||
xml = str('{0}{1}').format(str(xml), root)
|
||||
else:
|
||||
raise Exception('Unable to serialize node of type %s (%s)' % \
|
||||
(type(root), root))
|
||||
|
||||
return xml
|
||||
|
||||
def getValue(response_dict, *args, **kwargs):
|
||||
args_a = [w for w in args]
|
||||
first = args_a[0]
|
||||
args_a.remove(first)
|
||||
|
||||
h = kwargs.get('mydict', {})
|
||||
if h:
|
||||
h = h.get(first, {})
|
||||
else:
|
||||
h = response_dict.get(first, {})
|
||||
|
||||
if len(args) == 1:
|
||||
try:
|
||||
return h.get('value', None)
|
||||
except:
|
||||
return h
|
||||
|
||||
last = args_a.pop()
|
||||
|
||||
for a in args_a:
|
||||
h = h.get(a, {})
|
||||
|
||||
h = h.get(last, {})
|
||||
|
||||
try:
|
||||
return h.get('value', None)
|
||||
except:
|
||||
return h
|
||||
|
||||
def getNodeText(node):
|
||||
"Returns the node's text string."
|
||||
|
||||
rc = []
|
||||
|
||||
if hasattr(node, 'childNodes'):
|
||||
for cn in node.childNodes:
|
||||
if cn.nodeType == cn.TEXT_NODE:
|
||||
rc.append(cn.data)
|
||||
elif cn.nodeType == cn.CDATA_SECTION_NODE:
|
||||
rc.append(cn.data)
|
||||
|
||||
return ''.join(rc)
|
||||
|
||||
def perftest_dict2xml():
|
||||
sample_dict = {
|
||||
'searchFilter': {'categoryId': {'#text': 222, '@attrs': {'site': 'US'}}},
|
||||
'paginationInput': {
|
||||
'pageNumber': '1',
|
||||
'pageSize': '25'
|
||||
},
|
||||
'itemFilter': [
|
||||
{'name': 'Condition',
|
||||
'value': 'Used'},
|
||||
{'name': 'LocatedIn',
|
||||
'value': 'GB'},
|
||||
],
|
||||
'sortOrder': 'StartTimeNewest'
|
||||
}
|
||||
xml = dict2xml(sample_dict)
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
import timeit
|
||||
|
||||
import doctest
|
||||
failure_count, test_count = doctest.testmod()
|
||||
sys.exit(failure_count)
|
||||
8
fusion_canada_post/models/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from . import delivery_carrier
|
||||
from . import product_packaging
|
||||
from . import res_company
|
||||
from . import fusion_cp_shipment
|
||||
from . import fusion_cp_tracking_event
|
||||
from . import fusion_cp_order_package
|
||||
from . import sale_order
|
||||
from . import stock_picking
|
||||
1185
fusion_canada_post/models/delivery_carrier.py
Normal file
41
fusion_canada_post/models/fusion_cp_order_package.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class FusionCPOrderPackage(models.Model):
|
||||
"""Stores per-package dimensions and service info on a sale order.
|
||||
|
||||
Created when the user confirms the "Add Shipping" wizard after
|
||||
defining one or more packages with dimensions.
|
||||
Used by ``send_shipping`` to apply the correct dimensions
|
||||
to each physical package on the delivery order.
|
||||
"""
|
||||
|
||||
_name = 'fusion.cp.order.package'
|
||||
_description = 'Canada Post Order Package'
|
||||
_order = 'sequence, id'
|
||||
|
||||
sale_order_id = fields.Many2one(
|
||||
'sale.order',
|
||||
string='Sale Order',
|
||||
ondelete='cascade',
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
package_type_id = fields.Many2one(
|
||||
'stock.package.type',
|
||||
string='Box Type',
|
||||
domain="[('package_carrier_type', '=', 'fusion_canada_post')]",
|
||||
)
|
||||
package_length = fields.Float(string='Length')
|
||||
package_width = fields.Float(string='Width')
|
||||
package_height = fields.Float(string='Height')
|
||||
weight = fields.Float(string='Weight')
|
||||
service_code = fields.Char(string='Service Code')
|
||||
service_name = fields.Char(string='Service')
|
||||
price = fields.Float(string='Shipping Cost', digits='Product Price')
|
||||
expected_delivery = fields.Char(string='Expected Delivery')
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
related='sale_order_id.currency_id',
|
||||
)
|
||||
506
fusion_canada_post/models/fusion_cp_shipment.py
Normal file
@@ -0,0 +1,506 @@
|
||||
import logging
|
||||
from datetime import datetime as dt_mod
|
||||
from lxml import etree
|
||||
from requests import request
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.addons.fusion_canada_post.fusion_cp_api.fusion_cp_response import Response
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionCpShipment(models.Model):
|
||||
_name = 'fusion.cp.shipment'
|
||||
_description = 'Canada Post Shipment'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'shipment_date desc, id desc'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
readonly=True,
|
||||
default=lambda self: _('New'),
|
||||
copy=False,
|
||||
)
|
||||
tracking_number = fields.Char(
|
||||
string='Tracking Number',
|
||||
index=True,
|
||||
readonly=True,
|
||||
copy=False,
|
||||
tracking=True,
|
||||
)
|
||||
shipment_id = fields.Char(
|
||||
string='CP Shipment ID',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
help='Canada Post internal shipment identifier',
|
||||
)
|
||||
sale_order_id = fields.Many2one(
|
||||
'sale.order',
|
||||
string='Sale Order',
|
||||
index=True,
|
||||
ondelete='set null',
|
||||
tracking=True,
|
||||
)
|
||||
picking_id = fields.Many2one(
|
||||
'stock.picking',
|
||||
string='Transfer',
|
||||
index=True,
|
||||
ondelete='set null',
|
||||
tracking=True,
|
||||
)
|
||||
carrier_id = fields.Many2one(
|
||||
'delivery.carrier',
|
||||
string='Carrier',
|
||||
ondelete='set null',
|
||||
)
|
||||
shipment_date = fields.Datetime(
|
||||
string='Shipment Date',
|
||||
default=fields.Datetime.now,
|
||||
tracking=True,
|
||||
)
|
||||
status = fields.Selection(
|
||||
[
|
||||
('draft', 'Draft'),
|
||||
('confirmed', 'Confirmed'),
|
||||
('shipped', 'Shipped'),
|
||||
('delivered', 'Delivered'),
|
||||
('cancelled', 'Cancelled'),
|
||||
],
|
||||
string='Status',
|
||||
default='confirmed',
|
||||
tracking=True,
|
||||
)
|
||||
# Label attachments (Many2one for storage)
|
||||
label_attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Printable Label (4x6)',
|
||||
ondelete='set null',
|
||||
help='The thermal printer label (4x6 format)',
|
||||
)
|
||||
full_label_attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Full Label (8.5x11)',
|
||||
ondelete='set null',
|
||||
help='Full page label with instructions and receipt',
|
||||
)
|
||||
receipt_attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Receipt',
|
||||
ondelete='set null',
|
||||
)
|
||||
commercial_invoice_attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Commercial Invoice',
|
||||
ondelete='set null',
|
||||
)
|
||||
# Shipment details
|
||||
shipping_cost = fields.Float(
|
||||
string='Shipping Cost',
|
||||
digits='Product Price',
|
||||
readonly=True,
|
||||
)
|
||||
service_type = fields.Char(
|
||||
string='Service Type',
|
||||
readonly=True,
|
||||
)
|
||||
weight = fields.Float(
|
||||
string='Weight',
|
||||
digits='Stock Weight',
|
||||
readonly=True,
|
||||
)
|
||||
package_name = fields.Char(
|
||||
string='Package',
|
||||
readonly=True,
|
||||
help='Name of the package this shipment covers',
|
||||
)
|
||||
# Address fields (computed)
|
||||
sender_name = fields.Char(
|
||||
string='Sender',
|
||||
compute='_compute_sender_fields',
|
||||
store=True,
|
||||
)
|
||||
sender_address = fields.Char(
|
||||
string='Sender Address',
|
||||
compute='_compute_sender_fields',
|
||||
store=True,
|
||||
)
|
||||
recipient_name = fields.Char(
|
||||
string='Recipient',
|
||||
compute='_compute_recipient_fields',
|
||||
store=True,
|
||||
)
|
||||
recipient_address = fields.Char(
|
||||
string='Recipient Address',
|
||||
compute='_compute_recipient_fields',
|
||||
store=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
# Tracking history
|
||||
tracking_event_ids = fields.One2many(
|
||||
'fusion.cp.tracking.event',
|
||||
'shipment_id',
|
||||
string='Tracking Events',
|
||||
)
|
||||
tracking_event_count = fields.Integer(
|
||||
string='Tracking Events',
|
||||
compute='_compute_tracking_event_count',
|
||||
)
|
||||
last_tracking_update = fields.Datetime(
|
||||
string='Last Tracking Update',
|
||||
readonly=True,
|
||||
)
|
||||
delivery_date = fields.Datetime(
|
||||
string='Delivery Date',
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
def _compute_tracking_event_count(self):
|
||||
for rec in self:
|
||||
rec.tracking_event_count = len(rec.tracking_event_ids)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code(
|
||||
'fusion.cp.shipment'
|
||||
) or _('New')
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.depends('picking_id', 'picking_id.picking_type_id.warehouse_id.partner_id')
|
||||
def _compute_sender_fields(self):
|
||||
for rec in self:
|
||||
partner = (
|
||||
rec.picking_id.picking_type_id.warehouse_id.partner_id
|
||||
if rec.picking_id
|
||||
else False
|
||||
)
|
||||
if partner:
|
||||
rec.sender_name = partner.name or ''
|
||||
parts = filter(None, [
|
||||
partner.street,
|
||||
partner.city,
|
||||
partner.state_id.name if partner.state_id else '',
|
||||
partner.zip,
|
||||
])
|
||||
rec.sender_address = ', '.join(parts)
|
||||
else:
|
||||
rec.sender_name = ''
|
||||
rec.sender_address = ''
|
||||
|
||||
@api.depends('picking_id', 'picking_id.partner_id')
|
||||
def _compute_recipient_fields(self):
|
||||
for rec in self:
|
||||
partner = rec.picking_id.partner_id if rec.picking_id else False
|
||||
if partner:
|
||||
rec.recipient_name = partner.name or ''
|
||||
parts = filter(None, [
|
||||
partner.street,
|
||||
partner.city,
|
||||
partner.state_id.name if partner.state_id else '',
|
||||
partner.zip,
|
||||
])
|
||||
rec.recipient_address = ', '.join(parts)
|
||||
else:
|
||||
rec.recipient_name = ''
|
||||
rec.recipient_address = ''
|
||||
|
||||
def action_open_sale_order(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Sale Order',
|
||||
'res_model': 'sale.order',
|
||||
'res_id': self.sale_order_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_open_picking(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Transfer',
|
||||
'res_model': 'stock.picking',
|
||||
'res_id': self.picking_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def _action_open_attachment(self, attachment):
|
||||
"""Open an attachment PDF in the browser viewer (new tab)."""
|
||||
self.ensure_one()
|
||||
if not attachment:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%s?download=false' % attachment.id,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_view_label(self):
|
||||
return self._action_open_attachment(self.label_attachment_id)
|
||||
|
||||
def action_view_full_label(self):
|
||||
return self._action_open_attachment(self.full_label_attachment_id)
|
||||
|
||||
def action_view_receipt(self):
|
||||
return self._action_open_attachment(self.receipt_attachment_id)
|
||||
|
||||
def action_view_commercial_invoice(self):
|
||||
return self._action_open_attachment(self.commercial_invoice_attachment_id)
|
||||
|
||||
# ── Tracking ──────────────────────────────────────────────
|
||||
|
||||
def action_refresh_tracking(self):
|
||||
"""Fetch latest tracking events from Canada Post VIS API."""
|
||||
self.ensure_one()
|
||||
if not self.tracking_number:
|
||||
raise ValidationError(
|
||||
_("No tracking number available for this shipment."))
|
||||
carrier = self.carrier_id
|
||||
if not carrier:
|
||||
raise ValidationError(
|
||||
_("No carrier linked to this shipment."))
|
||||
|
||||
# VIS tracking uses /vis/ path, not /rs/
|
||||
if carrier.prod_environment:
|
||||
base = "https://soa-gw.canadapost.ca"
|
||||
else:
|
||||
base = "https://ct.soa-gw.canadapost.ca"
|
||||
|
||||
url = "%s/vis/track/pin/%s/detail" % (base, self.tracking_number)
|
||||
headers = {
|
||||
'Accept': 'application/vnd.cpc.track-v2+xml',
|
||||
'Accept-language': 'en-CA',
|
||||
}
|
||||
|
||||
try:
|
||||
resp = request(
|
||||
method='GET', url=url, headers=headers,
|
||||
auth=(carrier.username, carrier.password))
|
||||
_logger.info("Tracking API %s → %s", url, resp.status_code)
|
||||
|
||||
if resp.status_code == 404:
|
||||
# "No Pin History" — normal for new/sandbox shipments
|
||||
self.last_tracking_update = fields.Datetime.now()
|
||||
self.message_post(
|
||||
body=_("No tracking history available yet for %s.") %
|
||||
self.tracking_number)
|
||||
return
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise ValidationError(
|
||||
_("Canada Post tracking error: %s %s\n%s") % (
|
||||
resp.status_code, resp.reason,
|
||||
resp.text[:500] if resp.text else ''))
|
||||
api_resp = Response(resp)
|
||||
result = api_resp.dict()
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ValidationError(
|
||||
_("Failed to fetch tracking: %s") % str(e))
|
||||
|
||||
self._process_tracking_events(result)
|
||||
|
||||
def _process_tracking_events(self, result):
|
||||
"""Parse CP tracking detail response and store events."""
|
||||
detail = result.get('tracking-detail', result)
|
||||
sig_events = detail.get('significant-events', {})
|
||||
occurrences = sig_events.get('occurrence', [])
|
||||
|
||||
# Single event returns dict, multiple returns list
|
||||
if isinstance(occurrences, dict):
|
||||
occurrences = [occurrences]
|
||||
|
||||
# Replace existing events
|
||||
self.tracking_event_ids.unlink()
|
||||
|
||||
vals_list = []
|
||||
for occ in occurrences:
|
||||
event_date_str = occ.get('event-date', '')
|
||||
event_time_str = occ.get('event-time', '')
|
||||
|
||||
# Build combined datetime for sorting
|
||||
event_datetime = False
|
||||
if event_date_str:
|
||||
try:
|
||||
if event_time_str:
|
||||
event_datetime = dt_mod.strptime(
|
||||
'%s %s' % (event_date_str, event_time_str),
|
||||
'%Y-%m-%d %H:%M:%S')
|
||||
else:
|
||||
event_datetime = dt_mod.strptime(
|
||||
event_date_str, '%Y-%m-%d')
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
vals_list.append({
|
||||
'shipment_id': self.id,
|
||||
'event_date': event_date_str or False,
|
||||
'event_time': event_time_str or '',
|
||||
'event_datetime': event_datetime,
|
||||
'event_description': occ.get('event-description', ''),
|
||||
'event_type': occ.get('event-type', ''),
|
||||
'event_site': occ.get('event-site', ''),
|
||||
'event_province': occ.get('event-province', ''),
|
||||
'signatory_name': occ.get('signatory-name', ''),
|
||||
})
|
||||
|
||||
if vals_list:
|
||||
self.env['fusion.cp.tracking.event'].create(vals_list)
|
||||
|
||||
self.last_tracking_update = fields.Datetime.now()
|
||||
self._update_status_from_tracking(detail)
|
||||
self.message_post(
|
||||
body=_("Tracking refreshed: %d events loaded.") % len(vals_list))
|
||||
|
||||
def _update_status_from_tracking(self, detail):
|
||||
"""Auto-update shipment status based on tracking data."""
|
||||
if self.status == 'cancelled':
|
||||
return
|
||||
|
||||
delivered_date = detail.get('actual-delivery-date', '')
|
||||
if delivered_date:
|
||||
self.status = 'delivered'
|
||||
try:
|
||||
self.delivery_date = dt_mod.strptime(
|
||||
delivered_date, '%Y-%m-%d')
|
||||
except (ValueError, TypeError):
|
||||
self.delivery_date = fields.Datetime.now()
|
||||
elif self.status == 'confirmed' and self.tracking_event_ids:
|
||||
self.status = 'shipped'
|
||||
|
||||
# ── Void & Reissue ─────────────────────────────────────
|
||||
|
||||
def action_void_shipment(self):
|
||||
"""Void this shipment via Canada Post API (DELETE endpoint)."""
|
||||
self.ensure_one()
|
||||
if self.status == 'cancelled':
|
||||
raise ValidationError(
|
||||
_("This shipment is already cancelled."))
|
||||
if not self.shipment_id:
|
||||
raise ValidationError(
|
||||
_("No CP shipment ID — cannot void."))
|
||||
|
||||
carrier = self.carrier_id
|
||||
if not carrier:
|
||||
raise ValidationError(
|
||||
_("No carrier linked to this shipment."))
|
||||
|
||||
if carrier.prod_environment:
|
||||
base = "https://soa-gw.canadapost.ca"
|
||||
else:
|
||||
base = "https://ct.soa-gw.canadapost.ca"
|
||||
customer = carrier.customer_number
|
||||
|
||||
if carrier.fusion_cp_type == 'commercial':
|
||||
url = "%s/rs/%s/%s/shipment/%s" % (
|
||||
base, customer, customer, self.shipment_id)
|
||||
accept_header = 'application/vnd.cpc.shipment-v8+xml'
|
||||
else:
|
||||
url = "%s/rs/%s/ncshipment/%s" % (
|
||||
base, customer, self.shipment_id)
|
||||
accept_header = 'application/vnd.cpc.ncshipment-v4+xml'
|
||||
|
||||
try:
|
||||
resp = request(
|
||||
method='DELETE', url=url,
|
||||
headers={
|
||||
'Accept': accept_header,
|
||||
'Accept-language': 'en-CA',
|
||||
},
|
||||
auth=(carrier.username, carrier.password))
|
||||
_logger.info(
|
||||
"Void Shipment %s → %s", url, resp.status_code)
|
||||
|
||||
if resp.status_code == 204:
|
||||
self.status = 'cancelled'
|
||||
self.message_post(
|
||||
body=_("Shipment voided successfully via "
|
||||
"Canada Post API."))
|
||||
else:
|
||||
# Parse CP error XML for a clean message
|
||||
error_msg = self._parse_cp_error_response(resp)
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ValidationError(
|
||||
_("Failed to void shipment: %s") % str(e))
|
||||
|
||||
def _parse_cp_error_response(self, resp):
|
||||
"""Parse a Canada Post error XML response and raise a clean
|
||||
ValidationError with the human-readable description.
|
||||
|
||||
If the response cannot be parsed, falls back to the raw text.
|
||||
"""
|
||||
description = ''
|
||||
try:
|
||||
root = etree.fromstring(resp.content)
|
||||
# Strip namespace for easy tag matching
|
||||
ns = {'cp': root.nsmap.get(None, '')}
|
||||
if ns['cp']:
|
||||
msgs = root.findall('.//cp:message', ns)
|
||||
else:
|
||||
msgs = root.findall('.//message')
|
||||
parts = []
|
||||
for msg in msgs:
|
||||
desc_el = msg.find(
|
||||
'cp:description', ns) if ns['cp'] else msg.find(
|
||||
'description')
|
||||
if desc_el is not None and desc_el.text:
|
||||
parts.append(desc_el.text.strip())
|
||||
if parts:
|
||||
description = '\n'.join(parts)
|
||||
except Exception:
|
||||
description = ''
|
||||
|
||||
if not description:
|
||||
description = (resp.text[:500]
|
||||
if resp.text else 'Unknown error')
|
||||
|
||||
raise ValidationError(
|
||||
_("Canada Post: %s") % description)
|
||||
|
||||
def action_reissue_shipment(self):
|
||||
"""Void current shipment and create a new one."""
|
||||
self.ensure_one()
|
||||
if self.status != 'cancelled':
|
||||
self.action_void_shipment()
|
||||
if not self.picking_id:
|
||||
raise ValidationError(
|
||||
_("No transfer linked — cannot reissue."))
|
||||
picking = self.picking_id
|
||||
carrier = picking.carrier_id or self.carrier_id
|
||||
if not carrier:
|
||||
raise ValidationError(
|
||||
_("No carrier found for reissue."))
|
||||
result = carrier.send_shipping(picking)
|
||||
if result:
|
||||
picking.carrier_tracking_ref = result[0].get(
|
||||
'tracking_number', '')
|
||||
self.message_post(
|
||||
body=_("Shipment reissued. See new shipment record."))
|
||||
|
||||
def action_track_on_canada_post(self):
|
||||
"""Open the Canada Post tracking website in a new tab."""
|
||||
self.ensure_one()
|
||||
if not self.tracking_number:
|
||||
raise ValidationError(
|
||||
_("No tracking number available for this shipment."))
|
||||
base_link = (
|
||||
self.carrier_id.tracking_link
|
||||
if self.carrier_id and self.carrier_id.tracking_link
|
||||
else 'https://www.canadapost.ca/trackweb/en#/resultList?searchFor=')
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '%s%s' % (base_link, self.tracking_number),
|
||||
'target': 'new',
|
||||
}
|
||||
44
fusion_canada_post/models/fusion_cp_tracking_event.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class FusionCpTrackingEvent(models.Model):
|
||||
_name = 'fusion.cp.tracking.event'
|
||||
_description = 'Canada Post Tracking Event'
|
||||
_order = 'event_datetime desc, id desc'
|
||||
_rec_name = 'event_description'
|
||||
|
||||
shipment_id = fields.Many2one(
|
||||
'fusion.cp.shipment',
|
||||
string='Shipment',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
event_date = fields.Date(
|
||||
string='Event Date',
|
||||
)
|
||||
event_time = fields.Char(
|
||||
string='Event Time',
|
||||
help='Time from Canada Post (HH:MM:SS)',
|
||||
)
|
||||
event_datetime = fields.Datetime(
|
||||
string='Date/Time',
|
||||
help='Combined date and time for sorting',
|
||||
)
|
||||
event_description = fields.Char(
|
||||
string='Description',
|
||||
)
|
||||
event_type = fields.Char(
|
||||
string='Event Type',
|
||||
help='Canada Post event type code',
|
||||
)
|
||||
event_site = fields.Char(
|
||||
string='Location',
|
||||
)
|
||||
event_province = fields.Char(
|
||||
string='Province',
|
||||
)
|
||||
signatory_name = fields.Char(
|
||||
string='Signatory',
|
||||
help='Name of person who signed for delivery',
|
||||
)
|
||||
7
fusion_canada_post/models/product_packaging.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class PackageType(models.Model):
|
||||
_inherit = "stock.package.type"
|
||||
|
||||
package_carrier_type = fields.Selection([('fusion_canada_post', 'Canada Post')])
|
||||
15
fusion_canada_post/models/res_company.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = "res.company"
|
||||
|
||||
def _default_uom_setting(self):
|
||||
weight_uom_id = self.env.ref('uom.product_uom_kgm', raise_if_not_found=False)
|
||||
if not weight_uom_id:
|
||||
uom_categ_id = self.env.ref('uom.product_uom_categ_kgm').id
|
||||
weight_uom_id = self.env['uom.uom'].search([('category_id', '=', uom_categ_id), ('factor', '=', 1)], limit=1)
|
||||
return weight_uom_id
|
||||
|
||||
|
||||
weight_unit_of_measurement_id = fields.Many2one('uom.uom',string="Weight Unit Of Measurement",help="Unit of measure for item weight",default=_default_uom_setting)
|
||||
57
fusion_canada_post/models/sale_order.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
fusion_cp_service_code = fields.Char(
|
||||
string='CP Service Code',
|
||||
copy=False,
|
||||
help='Canada Post service code selected during shipping method selection',
|
||||
)
|
||||
# Per-package dimensions & service info (replaces single-dim fields).
|
||||
fusion_cp_package_ids = fields.One2many(
|
||||
'fusion.cp.order.package',
|
||||
'sale_order_id',
|
||||
string='CP Packages',
|
||||
copy=False,
|
||||
)
|
||||
|
||||
# Legacy single-package dimension fields (kept for backward
|
||||
# compatibility with existing orders — new orders populate the
|
||||
# One2many above and fill these from the first package).
|
||||
fusion_cp_package_length = fields.Float(
|
||||
string='Package Length', copy=False)
|
||||
fusion_cp_package_width = fields.Float(
|
||||
string='Package Width', copy=False)
|
||||
fusion_cp_package_height = fields.Float(
|
||||
string='Package Height', copy=False)
|
||||
|
||||
fusion_cp_shipment_count = fields.Integer(
|
||||
string='CP Shipments',
|
||||
compute='_compute_fusion_cp_shipment_count',
|
||||
)
|
||||
|
||||
def _compute_fusion_cp_shipment_count(self):
|
||||
Shipment = self.env['fusion.cp.shipment']
|
||||
for order in self:
|
||||
order.fusion_cp_shipment_count = Shipment.search_count(
|
||||
[('sale_order_id', '=', order.id)]
|
||||
)
|
||||
|
||||
def action_view_fusion_cp_shipments(self):
|
||||
self.ensure_one()
|
||||
shipments = self.env['fusion.cp.shipment'].search(
|
||||
[('sale_order_id', '=', self.id)]
|
||||
)
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Canada Post Shipments',
|
||||
'res_model': 'fusion.cp.shipment',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('sale_order_id', '=', self.id)],
|
||||
}
|
||||
if len(shipments) == 1:
|
||||
action['view_mode'] = 'form'
|
||||
action['res_id'] = shipments.id
|
||||
return action
|
||||
34
fusion_canada_post/models/stock_picking.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class StockPicking(models.Model):
|
||||
_inherit = 'stock.picking'
|
||||
|
||||
fusion_cp_shipment_count = fields.Integer(
|
||||
string='CP Shipments',
|
||||
compute='_compute_fusion_cp_shipment_count',
|
||||
)
|
||||
|
||||
def _compute_fusion_cp_shipment_count(self):
|
||||
Shipment = self.env['fusion.cp.shipment']
|
||||
for picking in self:
|
||||
picking.fusion_cp_shipment_count = Shipment.search_count(
|
||||
[('picking_id', '=', picking.id)]
|
||||
)
|
||||
|
||||
def action_view_fusion_cp_shipments(self):
|
||||
self.ensure_one()
|
||||
shipments = self.env['fusion.cp.shipment'].search(
|
||||
[('picking_id', '=', self.id)]
|
||||
)
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Canada Post Shipments',
|
||||
'res_model': 'fusion.cp.shipment',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('picking_id', '=', self.id)],
|
||||
}
|
||||
if len(shipments) == 1:
|
||||
action['view_mode'] = 'form'
|
||||
action['res_id'] = shipments.id
|
||||
return action
|
||||
3
fusion_canada_post/pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[build-system]
|
||||
requires = ["whool"]
|
||||
build-backend = "whool.buildapi"
|
||||
12
fusion_canada_post/security/ir.model.access.csv
Normal file
@@ -0,0 +1,12 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_cp_shipment_user,fusion.cp.shipment.user,model_fusion_cp_shipment,base.group_user,1,0,0,0
|
||||
access_fusion_cp_shipment_stock_user,fusion.cp.shipment.stock.user,model_fusion_cp_shipment,stock.group_stock_user,1,1,1,0
|
||||
access_fusion_cp_shipment_manager,fusion.cp.shipment.manager,model_fusion_cp_shipment,stock.group_stock_manager,1,1,1,1
|
||||
access_fusion_cp_tracking_event_user,fusion.cp.tracking.event.user,model_fusion_cp_tracking_event,base.group_user,1,0,0,0
|
||||
access_fusion_cp_tracking_event_stock_user,fusion.cp.tracking.event.stock.user,model_fusion_cp_tracking_event,stock.group_stock_user,1,1,1,0
|
||||
access_fusion_cp_tracking_event_manager,fusion.cp.tracking.event.manager,model_fusion_cp_tracking_event,stock.group_stock_manager,1,1,1,1
|
||||
access_choose_delivery_cp_rate_user,choose.delivery.cp.rate.user,model_choose_delivery_cp_rate,base.group_user,1,1,1,1
|
||||
access_choose_delivery_cp_package_user,choose.delivery.cp.package.user,model_choose_delivery_cp_package,base.group_user,1,1,1,1
|
||||
access_fusion_cp_order_package_user,fusion.cp.order.package.user,model_fusion_cp_order_package,base.group_user,1,0,0,0
|
||||
access_fusion_cp_order_package_stock_user,fusion.cp.order.package.stock.user,model_fusion_cp_order_package,stock.group_stock_user,1,1,1,0
|
||||
access_fusion_cp_order_package_manager,fusion.cp.order.package.manager,model_fusion_cp_order_package,stock.group_stock_manager,1,1,1,1
|
||||
|
BIN
fusion_canada_post/static/description/S1.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
fusion_canada_post/static/description/S2.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
fusion_canada_post/static/description/S3.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
fusion_canada_post/static/description/canada_post_banner.gif
Normal file
|
After Width: | Height: | Size: 990 KiB |
95
fusion_canada_post/static/description/custom.css
Normal file
@@ -0,0 +1,95 @@
|
||||
html {
|
||||
padding: 50px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
html h1 {
|
||||
text-align: center;
|
||||
}
|
||||
html h2 {
|
||||
text-align: center;
|
||||
color: grey;
|
||||
}
|
||||
#exampleSlider {
|
||||
position: relative;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
#exampleSlider {
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
#exampleSlider .MS-content {
|
||||
margin: 15px 5%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
border: 1px solid red;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
#exampleSlider .MS-content {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
#exampleSlider .MS-content .item {
|
||||
display: inline-block;
|
||||
height: 30%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
vertical-align: top;
|
||||
border: 1px solid green;
|
||||
border-right: none;
|
||||
width: 33.33%;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
#exampleSlider .MS-content .item {
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 992px) {
|
||||
#exampleSlider .MS-content .item {
|
||||
width: 33.3333%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
#exampleSlider .MS-content .item {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
#exampleSlider .MS-content .item p {
|
||||
font-size: 30px;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
margin: 0;
|
||||
padding: 10px 0;
|
||||
}
|
||||
#exampleSlider .MS-controls button {
|
||||
position: absolute;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 30px;
|
||||
outline: 0;
|
||||
top: 35px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
#exampleSlider .MS-controls button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
#exampleSlider .MS-controls button:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
#exampleSlider .MS-controls .MS-left {
|
||||
left: 10px;
|
||||
}
|
||||
@media (max-width: 992px) {
|
||||
#exampleSlider .MS-controls .MS-left {
|
||||
left: -2px;
|
||||
}
|
||||
}
|
||||
#exampleSlider .MS-controls .MS-right {
|
||||
right: 10px;
|
||||
}
|
||||
@media (max-width: 992px) {
|
||||
#exampleSlider .MS-controls .MS-right {
|
||||
right: -2px;
|
||||
}
|
||||
}
|
||||
BIN
fusion_canada_post/static/description/dash.png
Normal file
|
After Width: | Height: | Size: 374 KiB |
BIN
fusion_canada_post/static/description/icon.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
173
fusion_canada_post/static/description/index.html
Normal file
@@ -0,0 +1,173 @@
|
||||
<html>
|
||||
<head>
|
||||
<link href="custom.css" rel="stylesheet"/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta charset="utf-8"/>
|
||||
<title>Canada Post Shipping Integration with Odoo</title>
|
||||
<meta name="description" content="Odoo Canada Post Shipping Integration. Canada Post Shipping connector for Odoo"/>
|
||||
<meta name="keywords" content="shipping connector, Odoo Canada Post, Delivery Canada Post, Odoo Canada Post shipping, Canada Post Shipping,
|
||||
integrate Canada Post, Canada Post odoo, shipping solution, accept shipment odoo, sync shipment odoo, Canada Post gateway integration"/>
|
||||
<meta name="robots" content="index, follow"/>
|
||||
<link type="text/css" rel="stylesheet" href="/assets/assets.css"/>
|
||||
<link type="text/css" rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.css"/>
|
||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
|
||||
integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Laila:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
|
||||
integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"
|
||||
crossorigin="anonymous"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="top"></div>
|
||||
<section class="oe_container bg-transparent fixed-top position-relative">
|
||||
<div class="o_equal_col_sm">
|
||||
<div class="shadow bg-white pb32 pt32 px-2"
|
||||
style="border-radius: 15px">
|
||||
|
||||
<h3 class="oe_slogan"
|
||||
style="color: #051f5a; font-family: Raleway; font-weight: 700; text-align: center; opacity: 1; font-size: 35px; margin-bottom: 25px; margin-top: 45px;">
|
||||
Odoo Canada Post Shipping Integration</h3>
|
||||
<img src="dash.png" style="width: 100%;">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="oe_container overflow-hidden"
|
||||
style="position-relative">
|
||||
<div class="o_equal_col_sm">
|
||||
<div class="px-2" style="border-radius:15px">
|
||||
<h3 class="oe_slogan"
|
||||
style="color:#0f1e40; font-family:Montserrat; font-weight:700; text-align:center; text-transform:uppercase; opacity:1; font-size:28px; margin-bottom:20px; margin-top:20px">
|
||||
<span style="color:#2971FD">Why</span> Canada Post Odoo Shipping Integration?
|
||||
</h3>
|
||||
<p class="mb16 text-center text-black-dark px-3"
|
||||
style="font-family:; font-weight:normal; color:#11335b; font-size:18px">
|
||||
Integrate Canada Post to streamline shipping processes by obtaining <b>real-time courier quotes for
|
||||
various services, generating shipping labels based on order details</b>, and providing <b>tracking
|
||||
numbers with
|
||||
links for shipment updates</b>
|
||||
ensuring a seamless and efficient logistics workflow
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="oe_container pb-5">
|
||||
<div class="mt64 mb64">
|
||||
<h2 style="color:#091E42; font-family:'Raleway'; text-align:center; margin:25px auto; text-transform:uppercase"
|
||||
class="oe_slogan">
|
||||
<b>Features</b>
|
||||
</h2>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-sm-12 mt32">
|
||||
<div class="container shadow" style="border-radius:10px; padding:10px 0px">
|
||||
<div class="col-md-3 shadow" style="float:left; margin: -10px 0px;">
|
||||
<img class="img img-responsive"
|
||||
src="images/icons_live_price.png"
|
||||
style="width: 100%;height: auto;"></div>
|
||||
<div class="col-md-9" style="padding-left:0; float:left; width:70%;margin-left: 20px;">
|
||||
<h3 class="mt16 mb0" style="font-family:Roboto; font-weight:500; font-size:22px">Live Shipping
|
||||
Price</h3>
|
||||
<p class=" mt8" style="font-family:Roboto;">Get live price to ensure the best price for
|
||||
your shipment</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12 mt32">
|
||||
<div class="container shadow" style="border-radius:10px; padding:10px 0px">
|
||||
<div class="col-md-3 shadow" style="float:left; margin: -10px 0px;">
|
||||
<img class="img img-responsive"
|
||||
src="images/icons_shipping_label.png"
|
||||
style="width: 100%;height: auto;"></div>
|
||||
<div class="col-md-9" style="padding-left:0; float:left; width:70%;margin-left: 20px;">
|
||||
<h3 class="mt16 mb0" style="font-family:Roboto; font-weight:500; font-size:22px">Shipping
|
||||
Label</h3>
|
||||
<p class="mt8" style="font-family:Roboto;">Generate shipping label using order information</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-sm-12 mt32" style="margin: 0px auto;padding-top: 22px;">
|
||||
<div class="container shadow" style="border-radius:10px; padding:10px 0px">
|
||||
<div class="col-md-3 shadow" style="float:left; margin: -10px 0px;"><img class="img img-responsive"
|
||||
src="images/icons_track_and_trace.png"
|
||||
style="width: 100%;height: auto;">
|
||||
</div>
|
||||
<div class="col-md-9" style="padding-left:0; float:left; width:70%;margin-left: 20px;">
|
||||
<h3 class="mt16 mb0" style="font-family:Roboto; font-weight:500; font-size:22px">Track &
|
||||
Trace</h3>
|
||||
<p class="mt8 mid_p_1200" style="font-family:Roboto;">Live track your shipment using the Canada
|
||||
Post tracking number</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="container">
|
||||
<div class="oe_row oe_spaced mt8 mb8" style="width: auto;">
|
||||
<h3 class="oe_slogan">Canada Post Shipping Configuration</h3>
|
||||
<div class="mt16 mb16" style="margin-left: 10px">
|
||||
<img src="S1.png"
|
||||
style="width: 100%; margin-top: 10px; border-radius: 5px; border: solid #E3E4E4; border-radius: 10px; border-width: 30px 7px 7px 7px;">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="container">
|
||||
<div class="oe_row oe_spaced mt8 mb8" style="width: auto;">
|
||||
<h3 class="oe_slogan">Create Sale Order With Canada Post Delivery Method</h3>
|
||||
<div class="mt16 mb16" style="margin-left: 10px">
|
||||
<img src="S2.png"
|
||||
style="width: 100%; margin-top: 10px; border-radius: 5px; border: solid #E3E4E4; border-radius: 10px; border-width: 30px 7px 7px 7px;">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="container">
|
||||
<div class="oe_row oe_spaced mt8 mb8" style="width: auto;">
|
||||
<h3 class="oe_slogan">Generate Shipment With Tracking Number And Shipping Label</h3>
|
||||
<div class="mt16 mb16" style="margin-left: 10px">
|
||||
<img src="S3.png"
|
||||
style="width: 100%; margin-top: 10px; border-radius: 5px; border: solid #E3E4E4; border-radius: 10px; border-width: 30px 7px 7px 7px;">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="container">
|
||||
<div class="oe_row oe_spaced mt8 mb8" style="width: auto;">
|
||||
<h3 class="oe_slogan">Generated Shipment Label</h3>
|
||||
<div class="mt16 mb16" style="margin: 0px auto;text-align: center;">
|
||||
<img src="label.png"
|
||||
style="margin-top: 10px; border-radius: 5px; border: solid #E3E4E4; border-radius: 10px; border-width: 7px 7px 7px 7px;">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<div class="container">
|
||||
<section class="container">
|
||||
<h4 style="color:#00008b; text-align:center; margin:25px auto; text-transform:uppercase;font-weight: 600;"
|
||||
class="oe_slogan">
|
||||
Contact Us
|
||||
</h4>
|
||||
<div style="text-align:center">
|
||||
<span>Suggestions & Feedback to:</span> <a href="mailto:support@nexasystems.ca">support@nexasystems.ca</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="container" align="center">
|
||||
<section class="container">
|
||||
<a href="#top">
|
||||
<img src="up_arrow.png" alt="up-arrow" style="height: 125px;">
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
fusion_canada_post/static/description/label.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
fusion_canada_post/static/description/up_arrow.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
89
fusion_canada_post/views/choose_delivery_carrier_views.xml
Normal file
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="choose_delivery_carrier_view_form_cp" model="ir.ui.view">
|
||||
<field name="name">choose.delivery.carrier.form.cp</field>
|
||||
<field name="model">choose.delivery.carrier</field>
|
||||
<field name="inherit_id" ref="delivery.choose_delivery_carrier_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Hidden state fields (inside the existing group — no layout impact) -->
|
||||
<xpath expr="//field[@name='delivery_price']" position="after">
|
||||
<field name="fusion_cp_selected_service" invisible="1"/>
|
||||
<field name="fusion_cp_dimension_unit_label" invisible="1"/>
|
||||
<field name="fusion_cp_weight_unit_label" invisible="1"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Hide base "Get rate" button for Canada Post -->
|
||||
<xpath expr="//button[@name='update_price']" position="attributes">
|
||||
<attribute name="invisible">delivery_type in ('fixed', 'base_on_rule', 'fusion_canada_post')</attribute>
|
||||
</xpath>
|
||||
|
||||
<!-- "Get Prices" button (inside the Cost / Get-rate row) -->
|
||||
<xpath expr="//button[@name='update_price']" position="after">
|
||||
<button name="update_price" type="object"
|
||||
invisible="delivery_type != 'fusion_canada_post'">
|
||||
<i class="oi oi-arrow-right me-1"/>Get Prices
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!--
|
||||
Full-width sections AFTER the outer <group>.
|
||||
This avoids them being crammed into the group's
|
||||
label-field grid and gives them the full dialog width.
|
||||
-->
|
||||
<xpath expr="//form/group" position="after">
|
||||
<!-- ── Package list ── -->
|
||||
<div invisible="delivery_type != 'fusion_canada_post'"
|
||||
class="px-3 mt-2">
|
||||
<span class="o_form_label fw-bold mb-1">Packages</span>
|
||||
<field name="fusion_cp_package_ids" nolabel="1">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="package_type_id" string="Box Type"/>
|
||||
<field name="package_length" string="L"/>
|
||||
<field name="package_width" string="W"/>
|
||||
<field name="package_height" string="H"/>
|
||||
<field name="weight" string="Weight"/>
|
||||
<field name="selected_price" string="Cost"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"
|
||||
optional="hide"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
</list>
|
||||
</field>
|
||||
</div>
|
||||
|
||||
<!-- ── Service selection ── -->
|
||||
<div invisible="delivery_type != 'fusion_canada_post' or not fusion_cp_rate_ids"
|
||||
class="px-3 mt-3">
|
||||
<span class="o_form_label fw-bold mb-1">Select a Service</span>
|
||||
<field name="fusion_cp_rate_ids" nolabel="1">
|
||||
<list create="0" delete="0" no_open="1"
|
||||
decoration-bf="is_selected"
|
||||
decoration-success="is_selected">
|
||||
<field name="is_selected" column_invisible="1"/>
|
||||
<field name="service_name" string="Service"/>
|
||||
<field name="price" string="Shipping Cost"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
<field name="expected_delivery"
|
||||
string="Expected Delivery"/>
|
||||
<button name="action_select" type="object"
|
||||
string="Select"
|
||||
class="btn-sm btn-link"
|
||||
icon="fa-circle-o"
|
||||
invisible="is_selected"/>
|
||||
<button name="action_select" type="object"
|
||||
string="Selected"
|
||||
class="btn-sm btn-primary"
|
||||
icon="fa-check-circle"
|
||||
invisible="not is_selected"/>
|
||||
</list>
|
||||
</field>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
77
fusion_canada_post/views/delivery_carrier_view.xml
Normal file
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="delivery_carrier_form_view_fusion_canada_post" model="ir.ui.view">
|
||||
<field name="name">delivery.carrier.form.view.fusion.canada.post</field>
|
||||
<field name="model">delivery.carrier</field>
|
||||
<field name="inherit_id" ref="delivery.view_delivery_carrier_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- Rename "Get Rate" → "Get Prices" on the Integration Level radio -->
|
||||
<xpath expr="//field[@name='integration_level']" position="attributes">
|
||||
<attribute name="invisible">delivery_type == 'fusion_canada_post'</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='integration_level']" position="after">
|
||||
<field name="integration_level"
|
||||
invisible="delivery_type != 'fusion_canada_post'"
|
||||
widget="radio"
|
||||
options="{'horizontal': true}"
|
||||
help="Get Prices: only fetch shipping prices on sale orders. Get Prices and Create Shipment: also generate labels and tracking when the delivery order is validated."/>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//page[@name='destination']" position='before'>
|
||||
<page string="Configuration" name="configuration" invisible="delivery_type != 'fusion_canada_post'">
|
||||
<group string="API Credentials"
|
||||
invisible="delivery_type != 'fusion_canada_post'">
|
||||
<group>
|
||||
<field name="username" string="API Key"
|
||||
required="delivery_type == 'fusion_canada_post'"
|
||||
help="API key from the Canada Post Developer Program."/>
|
||||
<field name="password" string="API Password" password="True"
|
||||
required="delivery_type == 'fusion_canada_post'"
|
||||
help="API password from the Canada Post Developer Program."/>
|
||||
<field name="customer_number" required="delivery_type == 'fusion_canada_post'"
|
||||
help="Your Canada Post customer number (mailed-by number)."/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="fusion_cp_type" string="Account Type"
|
||||
required="delivery_type == 'fusion_canada_post'"/>
|
||||
<field name="fusion_cp_contract_id" required="fusion_cp_type == 'commercial'"
|
||||
invisible="fusion_cp_type == 'counter'"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Shipping Options">
|
||||
<field name="service_type" string="Default Service"
|
||||
required="delivery_type == 'fusion_canada_post'"
|
||||
help="Default shipping service. Users can choose a different service when adding shipping to a sale order."/>
|
||||
<field name="option_code" string="Shipping Option"/>
|
||||
<field name="reason_for_export"/>
|
||||
<field name="product_packaging_id"
|
||||
help="Optional default package type. Dimensions will pre-fill in the shipping wizard but can be overridden per shipment."
|
||||
/>
|
||||
<field name="fusion_cp_dimension_unit"
|
||||
required="delivery_type == 'fusion_canada_post'"
|
||||
help="Unit for package dimensions. Values are automatically converted to centimetres for the Canada Post API."/>
|
||||
</group>
|
||||
<group string="Payment & Tracking">
|
||||
<field
|
||||
name="fusion_cp_payment_method"
|
||||
string="Payment Method"
|
||||
required="delivery_type == 'fusion_canada_post'"
|
||||
/>
|
||||
<field name="tracking_link"
|
||||
help="Custom tracking URL. Leave blank to use the default Canada Post tracking page."/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Label Settings"
|
||||
invisible="delivery_type != 'fusion_canada_post'">
|
||||
<group>
|
||||
<field name="fusion_cp_output_format"
|
||||
required="delivery_type == 'fusion_canada_post'"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
266
fusion_canada_post/views/fusion_cp_shipment_views.xml
Normal file
@@ -0,0 +1,266 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="view_fusion_cp_shipment_search" model="ir.ui.view">
|
||||
<field name="name">fusion.cp.shipment.search</field>
|
||||
<field name="model">fusion.cp.shipment</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Shipments">
|
||||
<field name="name"/>
|
||||
<field name="tracking_number"/>
|
||||
<field name="shipment_id"/>
|
||||
<field name="sale_order_id"/>
|
||||
<field name="picking_id"/>
|
||||
<field name="recipient_name"/>
|
||||
<filter name="filter_confirmed" string="Confirmed"
|
||||
domain="[('status','=','confirmed')]"/>
|
||||
<filter name="filter_shipped" string="Shipped"
|
||||
domain="[('status','=','shipped')]"/>
|
||||
<filter name="filter_delivered" string="Delivered"
|
||||
domain="[('status','=','delivered')]"/>
|
||||
<filter name="filter_cancelled" string="Cancelled"
|
||||
domain="[('status','=','cancelled')]"/>
|
||||
<group>
|
||||
<filter name="group_status" string="Status"
|
||||
context="{'group_by': 'status'}"/>
|
||||
<filter name="group_service" string="Service Type"
|
||||
context="{'group_by': 'service_type'}"/>
|
||||
<filter name="group_date" string="Shipment Date"
|
||||
context="{'group_by': 'shipment_date:month'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- List View -->
|
||||
<record id="view_fusion_cp_shipment_list" model="ir.ui.view">
|
||||
<field name="name">fusion.cp.shipment.list</field>
|
||||
<field name="model">fusion.cp.shipment</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Canada Post Shipments" default_order="shipment_date desc">
|
||||
<field name="name" decoration-bf="1"/>
|
||||
<field name="tracking_number"/>
|
||||
<field name="recipient_name"/>
|
||||
<field name="sale_order_id" optional="show"/>
|
||||
<field name="picking_id" optional="show"/>
|
||||
<field name="service_type"/>
|
||||
<field name="shipping_cost" sum="Total Cost"/>
|
||||
<field name="weight" optional="hide"/>
|
||||
<field name="package_name" optional="hide"/>
|
||||
<field name="shipment_date"/>
|
||||
<field name="status"
|
||||
decoration-info="status == 'draft'"
|
||||
decoration-success="status in ('confirmed','shipped')"
|
||||
decoration-bf="status == 'delivered'"
|
||||
decoration-danger="status == 'cancelled'"
|
||||
widget="badge"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="view_fusion_cp_shipment_form" model="ir.ui.view">
|
||||
<field name="name">fusion.cp.shipment.form</field>
|
||||
<field name="model">fusion.cp.shipment</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Canada Post Shipment">
|
||||
<header>
|
||||
<button name="action_refresh_tracking" type="object"
|
||||
string="Refresh Tracking"
|
||||
class="btn-primary"
|
||||
icon="fa-refresh"
|
||||
invisible="not tracking_number or status == 'cancelled'"/>
|
||||
<button name="action_track_on_canada_post" type="object"
|
||||
string="Track on Canada Post"
|
||||
class="btn-secondary"
|
||||
icon="fa-external-link"
|
||||
invisible="not tracking_number"/>
|
||||
<button name="action_void_shipment" type="object"
|
||||
string="Void Shipment"
|
||||
class="btn-danger"
|
||||
icon="fa-ban"
|
||||
invisible="status in ('cancelled', 'delivered')"
|
||||
confirm="Are you sure you want to void this shipment? This cannot be undone."/>
|
||||
<button name="action_reissue_shipment" type="object"
|
||||
string="Reissue Shipment"
|
||||
class="btn-warning"
|
||||
icon="fa-repeat"
|
||||
invisible="status != 'cancelled'"/>
|
||||
<field name="status" widget="statusbar"
|
||||
statusbar_visible="confirmed,shipped,delivered"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div name="button_box" class="oe_button_box">
|
||||
<button name="action_open_sale_order" type="object"
|
||||
class="oe_stat_button" icon="fa-shopping-cart"
|
||||
invisible="not sale_order_id">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Sale Order</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_open_picking" type="object"
|
||||
class="oe_stat_button" icon="fa-truck"
|
||||
invisible="not picking_id">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Transfer</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_refresh_tracking" type="object"
|
||||
class="oe_stat_button" icon="fa-map-marker"
|
||||
invisible="not tracking_number">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value">
|
||||
<field name="tracking_event_count"/>
|
||||
</span>
|
||||
<span class="o_stat_text">Events</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Shipment Details">
|
||||
<field name="tracking_number"/>
|
||||
<field name="shipment_id"/>
|
||||
<field name="carrier_id"/>
|
||||
<field name="service_type"/>
|
||||
<field name="shipment_date"/>
|
||||
<field name="shipping_cost"/>
|
||||
<field name="weight"/>
|
||||
<field name="package_name"
|
||||
invisible="not package_name"/>
|
||||
</group>
|
||||
<group string="Links">
|
||||
<field name="sale_order_id"/>
|
||||
<field name="picking_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Sender">
|
||||
<field name="sender_name"/>
|
||||
<field name="sender_address"/>
|
||||
</group>
|
||||
<group string="Recipient">
|
||||
<field name="recipient_name"/>
|
||||
<field name="recipient_address"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Labels & Documents" name="labels">
|
||||
<field name="label_attachment_id" invisible="1"/>
|
||||
<field name="full_label_attachment_id" invisible="1"/>
|
||||
<field name="receipt_attachment_id" invisible="1"/>
|
||||
<field name="commercial_invoice_attachment_id" invisible="1"/>
|
||||
<group string="Labels">
|
||||
<span class="o_form_label" invisible="not label_attachment_id">Printable Label (4x6)</span>
|
||||
<button name="action_view_label" type="object"
|
||||
class="btn btn-link p-0"
|
||||
icon="fa-file-pdf-o"
|
||||
string="Open"
|
||||
invisible="not label_attachment_id"/>
|
||||
<span class="o_form_label" invisible="not full_label_attachment_id">Full Label (8.5x11)</span>
|
||||
<button name="action_view_full_label" type="object"
|
||||
class="btn btn-link p-0"
|
||||
icon="fa-file-pdf-o"
|
||||
string="Open"
|
||||
invisible="not full_label_attachment_id"/>
|
||||
</group>
|
||||
<group string="Documents">
|
||||
<span class="o_form_label" invisible="not receipt_attachment_id">Receipt</span>
|
||||
<button name="action_view_receipt" type="object"
|
||||
class="btn btn-link p-0"
|
||||
icon="fa-file-pdf-o"
|
||||
string="Open"
|
||||
invisible="not receipt_attachment_id"/>
|
||||
<span class="o_form_label" invisible="not commercial_invoice_attachment_id">Commercial Invoice</span>
|
||||
<button name="action_view_commercial_invoice" type="object"
|
||||
class="btn btn-link p-0"
|
||||
icon="fa-file-pdf-o"
|
||||
string="Open"
|
||||
invisible="not commercial_invoice_attachment_id"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Tracking History" name="tracking_history">
|
||||
<group>
|
||||
<group>
|
||||
<field name="last_tracking_update"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="delivery_date"
|
||||
invisible="status != 'delivered'"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="tracking_event_ids" readonly="1" nolabel="1">
|
||||
<list string="Tracking Events"
|
||||
default_order="event_datetime desc">
|
||||
<field name="event_datetime" string="Date/Time"/>
|
||||
<field name="event_description"/>
|
||||
<field name="event_site" string="Location"/>
|
||||
<field name="event_province"/>
|
||||
<field name="signatory_name" optional="hide"/>
|
||||
<field name="event_type" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Kanban View -->
|
||||
<record id="view_fusion_cp_shipment_kanban" model="ir.ui.view">
|
||||
<field name="name">fusion.cp.shipment.kanban</field>
|
||||
<field name="model">fusion.cp.shipment</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="status" class="o_kanban_small_column">
|
||||
<field name="name"/>
|
||||
<field name="tracking_number"/>
|
||||
<field name="status"/>
|
||||
<field name="shipping_cost"/>
|
||||
<field name="shipment_date"/>
|
||||
<field name="recipient_name"/>
|
||||
<field name="service_type"/>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<div class="oe_kanban_details">
|
||||
<strong><field name="name"/></strong>
|
||||
<div><field name="recipient_name"/></div>
|
||||
<div class="text-muted">
|
||||
<i class="fa fa-barcode"/> <field name="tracking_number"/>
|
||||
</div>
|
||||
<div class="text-muted">
|
||||
<field name="service_type"/> |
|
||||
<field name="shipment_date" widget="date"/>
|
||||
</div>
|
||||
<div>
|
||||
<strong>$<field name="shipping_cost"/></strong>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Window Action -->
|
||||
<record id="action_fusion_cp_shipment" model="ir.actions.act_window">
|
||||
<field name="name">Shipments</field>
|
||||
<field name="res_model">fusion.cp.shipment</field>
|
||||
<field name="view_mode">list,form,kanban</field>
|
||||
<field name="search_view_id" ref="view_fusion_cp_shipment_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No shipments yet
|
||||
</p>
|
||||
<p>Shipments are created automatically when you validate a delivery
|
||||
order with a Canada Post carrier.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
40
fusion_canada_post/views/menus.xml
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Top-level app menu -->
|
||||
<menuitem id="menu_fusion_cp_root"
|
||||
name="Canada Post"
|
||||
web_icon="fusion_canada_post,static/description/icon.png"
|
||||
sequence="85"/>
|
||||
|
||||
<!-- Shipments -->
|
||||
<menuitem id="menu_fusion_cp_shipments"
|
||||
name="Shipments"
|
||||
parent="menu_fusion_cp_root"
|
||||
action="action_fusion_cp_shipment"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Configuration parent -->
|
||||
<menuitem id="menu_fusion_cp_config"
|
||||
name="Configuration"
|
||||
parent="menu_fusion_cp_root"
|
||||
sequence="90"/>
|
||||
|
||||
<!-- Carrier Settings action -->
|
||||
<record id="action_fusion_cp_carrier_settings" model="ir.actions.act_window">
|
||||
<field name="name">Canada Post Carrier</field>
|
||||
<field name="res_model">delivery.carrier</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="domain">[('delivery_type', '=', 'fusion_canada_post')]</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Configure your Canada Post carrier
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fusion_cp_carrier"
|
||||
name="Carrier Settings"
|
||||
parent="menu_fusion_cp_config"
|
||||
action="action_fusion_cp_carrier_settings"
|
||||
sequence="10"/>
|
||||
</odoo>
|
||||
41
fusion_canada_post/views/res_config_settings_views.xml
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form_fusion_cp" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.fusion.canada.post</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<app data-string="Canada Post" string="Canada Post"
|
||||
name="fusion_canada_post">
|
||||
<block title="Carrier Configuration" name="carrier_config_block">
|
||||
<setting string="Canada Post Carrier Settings"
|
||||
help="Configure your Canada Post API credentials, service types, label format, and shipping preferences.">
|
||||
<div class="content-group">
|
||||
<div class="mt8">
|
||||
<button name="%(fusion_canada_post.action_fusion_cp_carrier_settings)d"
|
||||
type="action"
|
||||
class="btn-link"
|
||||
icon="oi-arrow-right"
|
||||
string="Open Carrier Configuration"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
<setting string="Shipment History"
|
||||
help="View and manage all Canada Post shipments, labels, and tracking information.">
|
||||
<div class="content-group">
|
||||
<div class="mt8">
|
||||
<button name="%(fusion_canada_post.action_fusion_cp_shipment)d"
|
||||
type="action"
|
||||
class="btn-link"
|
||||
icon="oi-arrow-right"
|
||||
string="View Shipments"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
42
fusion_canada_post/views/sale_order_views.xml
Normal file
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_order_form_fusion_cp" model="ir.ui.view">
|
||||
<field name="name">sale.order.form.fusion.cp</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_view_fusion_cp_shipments" type="object"
|
||||
class="oe_stat_button" icon="fa-plane"
|
||||
invisible="fusion_cp_shipment_count == 0">
|
||||
<field name="fusion_cp_shipment_count" widget="statinfo"
|
||||
string="CP Shipments"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- Show CP package details on the sale order -->
|
||||
<xpath expr="//group[@name='note_group']" position="before">
|
||||
<group string="Canada Post Packages"
|
||||
invisible="not fusion_cp_package_ids"
|
||||
colspan="6">
|
||||
<field name="fusion_cp_package_ids" nolabel="1"
|
||||
colspan="6" readonly="1">
|
||||
<list create="0" delete="0" no_open="1">
|
||||
<field name="package_type_id" string="Box Type"
|
||||
optional="show"/>
|
||||
<field name="package_length" string="Length"/>
|
||||
<field name="package_width" string="Width"/>
|
||||
<field name="package_height" string="Height"/>
|
||||
<field name="weight" string="Weight"/>
|
||||
<field name="service_name" string="Service"/>
|
||||
<field name="price" string="Cost"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
18
fusion_canada_post/views/stock_picking_views.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_picking_form_fusion_cp" model="ir.ui.view">
|
||||
<field name="name">stock.picking.form.fusion.cp</field>
|
||||
<field name="model">stock.picking</field>
|
||||
<field name="inherit_id" ref="stock.view_picking_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_view_fusion_cp_shipments" type="object"
|
||||
class="oe_stat_button" icon="fa-plane"
|
||||
invisible="fusion_cp_shipment_count == 0">
|
||||
<field name="fusion_cp_shipment_count" widget="statinfo"
|
||||
string="CP Shipments"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
fusion_canada_post/wizard/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import choose_delivery_cp_rate
|
||||
446
fusion_canada_post/wizard/choose_delivery_cp_rate.py
Normal file
@@ -0,0 +1,446 @@
|
||||
import json
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class ChooseDeliveryCPPackage(models.TransientModel):
|
||||
"""One package row in the Add Shipping wizard for Canada Post."""
|
||||
|
||||
_name = 'choose.delivery.cp.package'
|
||||
_description = 'Canada Post Package (Wizard)'
|
||||
_order = 'sequence, id'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'choose.delivery.carrier',
|
||||
string='Wizard',
|
||||
ondelete='cascade',
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
package_type_id = fields.Many2one(
|
||||
'stock.package.type',
|
||||
string='Box Type',
|
||||
domain="[('package_carrier_type', '=', 'fusion_canada_post')]",
|
||||
)
|
||||
package_length = fields.Float(string='Length')
|
||||
package_width = fields.Float(string='Width')
|
||||
package_height = fields.Float(string='Height')
|
||||
weight = fields.Float(string='Weight')
|
||||
|
||||
# Per-package cost for the selected service (updated on service select)
|
||||
selected_price = fields.Float(
|
||||
string='Cost', digits='Product Price', readonly=True)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
related='wizard_id.currency_id',
|
||||
)
|
||||
|
||||
@api.onchange('package_type_id')
|
||||
def _onchange_package_type_id(self):
|
||||
"""Pre-fill dimensions from selected box type."""
|
||||
if self.package_type_id:
|
||||
self.package_length = self.package_type_id.packaging_length
|
||||
self.package_width = self.package_type_id.width
|
||||
self.package_height = self.package_type_id.height
|
||||
|
||||
|
||||
class ChooseDeliveryCPRate(models.TransientModel):
|
||||
_name = 'choose.delivery.cp.rate'
|
||||
_description = 'Canada Post Rate Option'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'choose.delivery.carrier',
|
||||
string='Wizard',
|
||||
ondelete='cascade',
|
||||
)
|
||||
service_code = fields.Char(string='Service Code')
|
||||
service_name = fields.Char(string='Service')
|
||||
price = fields.Float(string='Shipping Cost', digits='Product Price')
|
||||
expected_delivery = fields.Char(string='Expected Delivery Date')
|
||||
is_selected = fields.Boolean(string='Selected', default=False)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
related='wizard_id.currency_id',
|
||||
)
|
||||
# JSON: [{"pkg_id": <int>, "price": <float>}, ...]
|
||||
per_package_prices = fields.Text(string='Per-Package Prices')
|
||||
|
||||
def action_select(self):
|
||||
"""Select this rate and deselect others. Update per-package costs."""
|
||||
self.ensure_one()
|
||||
# Deselect all, then select this one
|
||||
self.wizard_id.fusion_cp_rate_ids.write({'is_selected': False})
|
||||
self.is_selected = True
|
||||
|
||||
# Update per-package costs from stored JSON
|
||||
if self.per_package_prices:
|
||||
try:
|
||||
pkg_prices = json.loads(self.per_package_prices)
|
||||
for pp in pkg_prices:
|
||||
pkg = self.env['choose.delivery.cp.package'].browse(
|
||||
pp['pkg_id'])
|
||||
if pkg.exists():
|
||||
pkg.selected_price = pp['price']
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# Apply margin from the carrier
|
||||
carrier = self.wizard_id.carrier_id
|
||||
price = self.price
|
||||
if carrier:
|
||||
price = carrier._apply_margins(price, self.wizard_id.order_id)
|
||||
# Check free_over
|
||||
if carrier.free_over:
|
||||
order = self.wizard_id.order_id
|
||||
amount = order.currency_id._convert(
|
||||
order.amount_untaxed,
|
||||
order.company_id.currency_id,
|
||||
order.company_id,
|
||||
fields.Date.today(),
|
||||
)
|
||||
if amount >= carrier.amount:
|
||||
price = 0.0
|
||||
|
||||
self.wizard_id.write({
|
||||
'delivery_price': price,
|
||||
'display_price': price,
|
||||
'fusion_cp_selected_service': self.service_code,
|
||||
'fusion_cp_selected_service_name': self.service_name,
|
||||
'fusion_cp_selected_expected_delivery': self.expected_delivery,
|
||||
})
|
||||
# Re-open the wizard to show updated selection
|
||||
return {
|
||||
'name': _('Add a shipping method'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'choose.delivery.carrier',
|
||||
'res_id': self.wizard_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
|
||||
class ChooseDeliveryCarrier(models.TransientModel):
|
||||
_inherit = 'choose.delivery.carrier'
|
||||
|
||||
fusion_cp_rate_ids = fields.One2many(
|
||||
'choose.delivery.cp.rate',
|
||||
'wizard_id',
|
||||
string='Available Services',
|
||||
)
|
||||
fusion_cp_selected_service = fields.Char(
|
||||
string='Selected CP Service Code',
|
||||
)
|
||||
fusion_cp_selected_service_name = fields.Char(
|
||||
string='Selected CP Service Name',
|
||||
)
|
||||
fusion_cp_selected_expected_delivery = fields.Char(
|
||||
string='Selected CP Expected Delivery',
|
||||
)
|
||||
|
||||
# ── Package list ──
|
||||
fusion_cp_package_ids = fields.One2many(
|
||||
'choose.delivery.cp.package',
|
||||
'wizard_id',
|
||||
string='Packages',
|
||||
)
|
||||
|
||||
# ── Unit labels ──
|
||||
fusion_cp_dimension_unit_label = fields.Char(
|
||||
string='Dimension Unit',
|
||||
compute='_compute_fusion_cp_dimension_unit_label',
|
||||
)
|
||||
fusion_cp_weight_unit_label = fields.Char(
|
||||
string='Weight Unit',
|
||||
compute='_compute_fusion_cp_weight_unit_label',
|
||||
)
|
||||
|
||||
@api.depends('carrier_id')
|
||||
def _compute_fusion_cp_dimension_unit_label(self):
|
||||
for rec in self:
|
||||
if (rec.carrier_id
|
||||
and rec.carrier_id.delivery_type == 'fusion_canada_post'):
|
||||
rec.fusion_cp_dimension_unit_label = (
|
||||
rec.carrier_id.fusion_cp_dimension_unit or 'cm')
|
||||
else:
|
||||
rec.fusion_cp_dimension_unit_label = ''
|
||||
|
||||
@api.depends('carrier_id')
|
||||
def _compute_fusion_cp_weight_unit_label(self):
|
||||
for rec in self:
|
||||
if (rec.carrier_id
|
||||
and rec.carrier_id.delivery_type == 'fusion_canada_post'):
|
||||
uom = (rec.order_id.company_id
|
||||
.weight_unit_of_measurement_id)
|
||||
rec.fusion_cp_weight_unit_label = uom.name if uom else 'kg'
|
||||
else:
|
||||
rec.fusion_cp_weight_unit_label = ''
|
||||
|
||||
@api.onchange('carrier_id')
|
||||
def _onchange_carrier_id_cp_packages(self):
|
||||
"""When a CP carrier is selected, create one default package."""
|
||||
if (self.carrier_id
|
||||
and self.carrier_id.delivery_type == 'fusion_canada_post'
|
||||
and not self.fusion_cp_package_ids):
|
||||
vals = {
|
||||
'sequence': 10,
|
||||
'weight': self.total_weight or 0.0,
|
||||
}
|
||||
# Pre-fill from default package type if set on carrier
|
||||
if self.carrier_id.product_packaging_id:
|
||||
pkg = self.carrier_id.product_packaging_id
|
||||
vals['package_type_id'] = pkg.id
|
||||
vals['package_length'] = pkg.packaging_length
|
||||
vals['package_width'] = pkg.width
|
||||
vals['package_height'] = pkg.height
|
||||
self.fusion_cp_package_ids = [(5, 0, 0), (0, 0, vals)]
|
||||
|
||||
# ── Rate fetching ──
|
||||
|
||||
def update_price(self):
|
||||
"""Override: for Canada Post, fetch all rates for all packages."""
|
||||
if self.carrier_id.delivery_type == 'fusion_canada_post':
|
||||
return self._update_cp_rates()
|
||||
return super().update_price()
|
||||
|
||||
def _get_cp_package_info_for_pkg(self, pkg):
|
||||
"""Build package_info dict for a single package, converted to cm."""
|
||||
carrier = self.carrier_id
|
||||
return {
|
||||
'length': round(carrier._fusion_cp_convert_dimension_to_cm(
|
||||
pkg.package_length), 1),
|
||||
'width': round(carrier._fusion_cp_convert_dimension_to_cm(
|
||||
pkg.package_width), 1),
|
||||
'height': round(carrier._fusion_cp_convert_dimension_to_cm(
|
||||
pkg.package_height), 1),
|
||||
}
|
||||
|
||||
def _update_cp_rates(self):
|
||||
"""Fetch CP service rates for every package and aggregate."""
|
||||
carrier = self.carrier_id
|
||||
packages = self.fusion_cp_package_ids
|
||||
|
||||
if not packages:
|
||||
raise UserError(_(
|
||||
"Please add at least one package with dimensions."))
|
||||
|
||||
from_unit = (self.order_id.company_id
|
||||
.weight_unit_of_measurement_id)
|
||||
|
||||
# ── Validate every package ──
|
||||
for pkg in packages:
|
||||
if not (pkg.package_length and pkg.package_width
|
||||
and pkg.package_height):
|
||||
raise UserError(_(
|
||||
"Please enter dimensions (L × W × H) "
|
||||
"for all packages."))
|
||||
if not pkg.weight:
|
||||
raise UserError(_(
|
||||
"Please enter weight for all packages."))
|
||||
package_info = self._get_cp_package_info_for_pkg(pkg)
|
||||
weight_kg = pkg.weight
|
||||
if from_unit:
|
||||
weight_kg = round(carrier.convert_weight(
|
||||
from_unit, carrier.weight_uom_id, weight_kg), 2)
|
||||
carrier._fusion_cp_validate_package(weight_kg, package_info)
|
||||
|
||||
# ── Clear old rates ──
|
||||
self.fusion_cp_rate_ids.unlink()
|
||||
|
||||
# ── Fetch rates per package ──
|
||||
# {service_code: {service_name, packages: [{pkg_id, price, exp}]}}
|
||||
all_service_rates = {}
|
||||
|
||||
for pkg in packages:
|
||||
package_info = self._get_cp_package_info_for_pkg(pkg)
|
||||
carrier_ctx = carrier.with_context(
|
||||
order_weight=pkg.weight, # raw, in company UOM
|
||||
cp_package_info=package_info,
|
||||
)
|
||||
rates = carrier_ctx.fusion_canada_post_rate_shipment_all(
|
||||
self.order_id)
|
||||
|
||||
if isinstance(rates, dict) and rates.get('error_message'):
|
||||
raise UserError(rates['error_message'])
|
||||
|
||||
if not rates:
|
||||
raise UserError(_(
|
||||
"No shipping prices available for this order."))
|
||||
|
||||
for rate in rates:
|
||||
code = rate['service_code']
|
||||
if code not in all_service_rates:
|
||||
all_service_rates[code] = {
|
||||
'service_name': rate['service_name'],
|
||||
'packages': [],
|
||||
}
|
||||
all_service_rates[code]['packages'].append({
|
||||
'pkg_id': pkg.id,
|
||||
'price': rate['price'],
|
||||
'expected_delivery': rate.get(
|
||||
'expected_delivery', ''),
|
||||
})
|
||||
|
||||
# ── Keep only services available for ALL packages ──
|
||||
num_packages = len(packages)
|
||||
available = {
|
||||
code: data
|
||||
for code, data in all_service_rates.items()
|
||||
if len(data['packages']) == num_packages
|
||||
}
|
||||
|
||||
if not available:
|
||||
raise UserError(_(
|
||||
"No single shipping service covers all packages. "
|
||||
"Try adjusting package dimensions or weight."))
|
||||
|
||||
# ── Find cheapest service ──
|
||||
cheapest_code = min(
|
||||
available.keys(),
|
||||
key=lambda c: sum(
|
||||
p['price'] for p in available[c]['packages']))
|
||||
|
||||
# ── Create combined rate lines ──
|
||||
vals_list = []
|
||||
for code, data in available.items():
|
||||
total_price = sum(p['price'] for p in data['packages'])
|
||||
expected_dates = [
|
||||
p['expected_delivery'] for p in data['packages']
|
||||
if p['expected_delivery']
|
||||
]
|
||||
expected = max(expected_dates) if expected_dates else ''
|
||||
is_sel = (code == cheapest_code)
|
||||
vals_list.append({
|
||||
'wizard_id': self.id,
|
||||
'service_code': code,
|
||||
'service_name': data['service_name'],
|
||||
'price': total_price,
|
||||
'expected_delivery': expected,
|
||||
'is_selected': is_sel,
|
||||
'per_package_prices': json.dumps(data['packages']),
|
||||
})
|
||||
|
||||
self.env['choose.delivery.cp.rate'].create(vals_list)
|
||||
|
||||
# ── Auto-select cheapest: update packages and wizard ──
|
||||
cheapest_data = available[cheapest_code]
|
||||
selected_price = sum(
|
||||
p['price'] for p in cheapest_data['packages'])
|
||||
|
||||
for pkg_data in cheapest_data['packages']:
|
||||
pkg_rec = packages.filtered(
|
||||
lambda p, pid=pkg_data['pkg_id']: p.id == pid)
|
||||
if pkg_rec:
|
||||
pkg_rec.selected_price = pkg_data['price']
|
||||
|
||||
# Apply carrier margins
|
||||
price = selected_price
|
||||
if self.carrier_id:
|
||||
price = self.carrier_id._apply_margins(
|
||||
price, self.order_id)
|
||||
if self.carrier_id.free_over:
|
||||
amount = self.order_id.currency_id._convert(
|
||||
self.order_id.amount_untaxed,
|
||||
self.order_id.company_id.currency_id,
|
||||
self.order_id.company_id,
|
||||
fields.Date.today(),
|
||||
)
|
||||
if amount >= self.carrier_id.amount:
|
||||
price = 0.0
|
||||
|
||||
expected_dates = [
|
||||
p['expected_delivery'] for p in cheapest_data['packages']
|
||||
if p['expected_delivery']
|
||||
]
|
||||
|
||||
self.write({
|
||||
'delivery_price': price,
|
||||
'display_price': price,
|
||||
'fusion_cp_selected_service': cheapest_code,
|
||||
'fusion_cp_selected_service_name': (
|
||||
cheapest_data['service_name']),
|
||||
'fusion_cp_selected_expected_delivery': (
|
||||
max(expected_dates) if expected_dates else ''),
|
||||
})
|
||||
|
||||
return {
|
||||
'name': _('Add a shipping method'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_mode': 'form',
|
||||
'res_model': 'choose.delivery.carrier',
|
||||
'res_id': self.id,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
# ── Confirm ──
|
||||
|
||||
def button_confirm(self):
|
||||
"""Override: store per-package info on the sale order and enhance
|
||||
the delivery line description."""
|
||||
if self.carrier_id.delivery_type == 'fusion_canada_post':
|
||||
order = self.order_id
|
||||
|
||||
# Clear previous package records
|
||||
order.fusion_cp_package_ids.unlink()
|
||||
|
||||
selected_code = self.fusion_cp_selected_service or ''
|
||||
selected_name = self.fusion_cp_selected_service_name or ''
|
||||
selected_delivery = (
|
||||
self.fusion_cp_selected_expected_delivery or '')
|
||||
|
||||
pkg_vals = []
|
||||
for idx, pkg in enumerate(
|
||||
self.fusion_cp_package_ids.sorted('sequence')):
|
||||
pkg_vals.append((0, 0, {
|
||||
'sequence': (idx + 1) * 10,
|
||||
'package_type_id': (
|
||||
pkg.package_type_id.id
|
||||
if pkg.package_type_id else False),
|
||||
'package_length': pkg.package_length,
|
||||
'package_width': pkg.package_width,
|
||||
'package_height': pkg.package_height,
|
||||
'weight': pkg.weight,
|
||||
'service_code': selected_code,
|
||||
'service_name': selected_name,
|
||||
'price': pkg.selected_price,
|
||||
'expected_delivery': selected_delivery,
|
||||
}))
|
||||
|
||||
write_vals = {
|
||||
'fusion_cp_package_ids': pkg_vals,
|
||||
'fusion_cp_service_code': selected_code,
|
||||
}
|
||||
|
||||
# Backward compat: first-package dims in legacy fields
|
||||
first_pkg = self.fusion_cp_package_ids.sorted('sequence')[:1]
|
||||
if first_pkg:
|
||||
write_vals['fusion_cp_package_length'] = (
|
||||
first_pkg.package_length)
|
||||
write_vals['fusion_cp_package_width'] = (
|
||||
first_pkg.package_width)
|
||||
write_vals['fusion_cp_package_height'] = (
|
||||
first_pkg.package_height)
|
||||
|
||||
order.write(write_vals)
|
||||
|
||||
res = super().button_confirm()
|
||||
|
||||
# Enhance delivery line description with service details
|
||||
if (self.carrier_id.delivery_type == 'fusion_canada_post'
|
||||
and self.fusion_cp_selected_service_name):
|
||||
delivery_line = self.order_id.order_line.filtered(
|
||||
'is_delivery')
|
||||
if delivery_line:
|
||||
line = delivery_line[-1]
|
||||
parts = [line.name]
|
||||
parts.append(
|
||||
"Service: %s"
|
||||
% self.fusion_cp_selected_service_name)
|
||||
num_pkgs = len(self.fusion_cp_package_ids)
|
||||
if num_pkgs > 1:
|
||||
parts.append("Packages: %d" % num_pkgs)
|
||||
if self.fusion_cp_selected_expected_delivery:
|
||||
parts.append(
|
||||
"Expected Delivery: %s"
|
||||
% self.fusion_cp_selected_expected_delivery)
|
||||
line.name = '\n'.join(parts)
|
||||
return res
|
||||