+ Why Canada Post Odoo Shipping Integration App From Synodica?
+
+
+ Integrate Canada Post streamline shipping processes by obtaining real-time courier quotes for
+ various services, generating shipping labels based on order details, and providing tracking
+ numbers with
+ links for shipment updates
+ ensuring a seamless and efficient logistics workflow
+
+
+
+
diff --git a/delivery_canadapost/static/description/label.png b/delivery_canadapost/static/description/label.png
new file mode 100644
index 00000000..6ac340ba
Binary files /dev/null and b/delivery_canadapost/static/description/label.png differ
diff --git a/delivery_canadapost/static/description/play_button.png b/delivery_canadapost/static/description/play_button.png
new file mode 100644
index 00000000..8a5a6cae
Binary files /dev/null and b/delivery_canadapost/static/description/play_button.png differ
diff --git a/delivery_canadapost/static/description/synodica_services.png b/delivery_canadapost/static/description/synodica_services.png
new file mode 100644
index 00000000..d4b0e65f
Binary files /dev/null and b/delivery_canadapost/static/description/synodica_services.png differ
diff --git a/delivery_canadapost/static/description/up_arrow.png b/delivery_canadapost/static/description/up_arrow.png
new file mode 100644
index 00000000..630bd3fb
Binary files /dev/null and b/delivery_canadapost/static/description/up_arrow.png differ
diff --git a/delivery_canadapost/views/delivery_carrier_view.xml b/delivery_canadapost/views/delivery_carrier_view.xml
new file mode 100644
index 00000000..b472508c
--- /dev/null
+++ b/delivery_canadapost/views/delivery_carrier_view.xml
@@ -0,0 +1,45 @@
+
+
+
+ delivery.carrier.form.view.canada.post
+ delivery.carrier
+
+
+
+
+
+ Note : Weight UOM must be select KG in Canada Post provider.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_canada_post/README.md b/fusion_canada_post/README.md
new file mode 100644
index 00000000..b47eb2d2
--- /dev/null
+++ b/fusion_canada_post/README.md
@@ -0,0 +1,7 @@
+# Nexa Systems Inc
+
+## Changelog
+
+### 19.0.1.0.0
+
+- Initial version
diff --git a/fusion_canada_post/__init__.py b/fusion_canada_post/__init__.py
new file mode 100644
index 00000000..b8fa12fa
--- /dev/null
+++ b/fusion_canada_post/__init__.py
@@ -0,0 +1,3 @@
+from . import models
+from . import wizard
+from . import fusion_cp_api
diff --git a/fusion_canada_post/__manifest__.py b/fusion_canada_post/__manifest__.py
new file mode 100644
index 00000000..0dd92450
--- /dev/null
+++ b/fusion_canada_post/__manifest__.py
@@ -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",
+}
diff --git a/fusion_canada_post/data/fusion_canada_post_data.xml b/fusion_canada_post/data/fusion_canada_post_data.xml
new file mode 100644
index 00000000..c55646d4
--- /dev/null
+++ b/fusion_canada_post/data/fusion_canada_post_data.xml
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+ CP Box 26×20×20 in, 35 lb
+ fusion_canada_post
+ 26
+ 20
+ 20
+ 35
+
+
+
+ CP Box 26×26×9 in, 15 lb
+ fusion_canada_post
+ 26
+ 26
+ 9
+ 15
+
+
+
+ CP Box 32×28×10 in, 12 lb
+ fusion_canada_post
+ 32
+ 28
+ 10
+ 12
+
+
+
+ CP Box 34×28×10 in, 10 lb
+ fusion_canada_post
+ 34
+ 28
+ 10
+ 10
+
+
+
+ CP Box 9×25×27 in, 16 lb
+ fusion_canada_post
+ 9
+ 25
+ 27
+ 16
+
+
+
+ CP Box 9×25×29 in, 17 lb
+ fusion_canada_post
+ 9
+ 25
+ 29
+ 17
+
+
+
+ CP Box 10×10×6 in, 35 lb
+ fusion_canada_post
+ 10
+ 10
+ 6
+ 35
+
+
+
+ CP Box 25×15×30 in, 25 lb
+ fusion_canada_post
+ 25
+ 15
+ 30
+ 25
+
+
+
+ CP Box 10×10×10 in, 2 lb
+ fusion_canada_post
+ 10
+ 10
+ 10
+ 2
+
+
+
+ CP Box 28×15×30 in, 40 lb
+ fusion_canada_post
+ 28
+ 15
+ 30
+ 40
+
+
+
+ CP Box 28×10×30 in, 16 lb
+ fusion_canada_post
+ 28
+ 10
+ 30
+ 16
+
+
+
+ CP Box 22×10×6 cm, 10 kg
+ fusion_canada_post
+ 22
+ 10
+ 6
+ 10
+
+
+
+ CP Box 22×13×6 in, 10 lb
+ fusion_canada_post
+ 22
+ 13
+ 6
+ 10
+
+
+
+ CP Box 12×10×5 in, 2 lb
+ fusion_canada_post
+ 12
+ 10
+ 5
+ 2
+
+
+
+
+ CP Box 10×10×10 in (Legacy)
+ fusion_canada_post
+ 10
+ 10
+ 10
+ 2
+
+
+
+
+
+ SHIPPING BY CANADA POST
+ CPSHIPPING
+ service
+
+
+
+ 0.0
+
+
+
+
+
+ Canada Post
+
+ fusion_canada_post
+ commercial
+ in
+ SO
+ DOM.RP
+
+
+
diff --git a/fusion_canada_post/data/ir_sequence_data.xml b/fusion_canada_post/data/ir_sequence_data.xml
new file mode 100644
index 00000000..24e5649a
--- /dev/null
+++ b/fusion_canada_post/data/ir_sequence_data.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ Fusion CP Shipment
+ fusion.cp.shipment
+ FCP-
+ 5
+
+
+
+
diff --git a/fusion_canada_post/fusion_cp_api/__init__.py b/fusion_canada_post/fusion_cp_api/__init__.py
new file mode 100644
index 00000000..83831eec
--- /dev/null
+++ b/fusion_canada_post/fusion_cp_api/__init__.py
@@ -0,0 +1,2 @@
+from . import fusion_cp_response
+from . import utils
diff --git a/fusion_canada_post/fusion_cp_api/fusion_cp_response.py b/fusion_canada_post/fusion_cp_api/fusion_cp_response.py
new file mode 100644
index 00000000..ffb3d420
--- /dev/null
+++ b/fusion_canada_post/fusion_cp_api/fusion_cp_response.py
@@ -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())
diff --git a/fusion_canada_post/fusion_cp_api/utils.py b/fusion_canada_post/fusion_cp_api/utils.py
new file mode 100644
index 00000000..cad803bd
--- /dev/null
+++ b/fusion_canada_post/fusion_cp_api/utils.py
@@ -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)
diff --git a/fusion_canada_post/models/__init__.py b/fusion_canada_post/models/__init__.py
new file mode 100644
index 00000000..e632858a
--- /dev/null
+++ b/fusion_canada_post/models/__init__.py
@@ -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
diff --git a/fusion_canada_post/models/delivery_carrier.py b/fusion_canada_post/models/delivery_carrier.py
new file mode 100644
index 00000000..76c8f7b6
--- /dev/null
+++ b/fusion_canada_post/models/delivery_carrier.py
@@ -0,0 +1,1185 @@
+import string
+import random
+import base64
+from requests import request
+import xml.etree.ElementTree as etree
+import logging
+_logger = logging.getLogger(__name__)
+from odoo import models, fields, api, _
+from odoo.exceptions import RedirectWarning,ValidationError
+from odoo.addons.fusion_canada_post.fusion_cp_api.fusion_cp_response import Response
+
+
+class DeliveryCarrier(models.Model):
+ _inherit = 'delivery.carrier'
+
+ delivery_type = fields.Selection(selection_add=[('fusion_canada_post', 'Canada Post')], ondelete={
+ 'fusion_canada_post': lambda recs: recs.write({'delivery_type': 'fixed', 'fixed_price': 0})})
+ option_code = fields.Selection([
+ ('SO', 'Signature Required'),
+ ('COV', 'Coverage (Insurance)'),
+ ('COD', 'Collect on Delivery'),
+ ('PA18', 'Proof of Age – 18+'),
+ ('PA19', 'Proof of Age – 19+'),
+ ('HFP', 'Card for Pickup'),
+ ('DNS', 'Do Not Safe Drop'),
+ ('LAD', 'Leave at Door'),
+ ], string="Shipping Option",
+ help="Optional delivery option applied to all shipments using this carrier.")
+ service_type = fields.Selection([
+ ('DOM.RP', 'Regular Parcel'),
+ ('DOM.EP', 'Expedited Parcel'),
+ ('DOM.XP', 'Xpresspost'),
+ ('DOM.PC', 'Priority'),
+ ('USA.XP', 'Xpresspost USA'),
+ ('USA.EP', 'Expedited Parcel USA'),
+ ('INT.IP.SURF', 'International Parcel – Surface'),
+ ('INT.PW.PARCEL', 'Priority Worldwide Parcel'),
+ ('INT.XP', 'Xpresspost International'),
+ ], string="Default Service",
+ help="Default Canada Post service. Users can select a different service when adding shipping to a sale order.")
+ product_packaging_id = fields.Many2one('stock.package.type', string="Default Package Type",help="Optional default package type. Dimensions will pre-fill in the shipping wizard but can be overridden per shipment.")
+
+ reason_for_export = fields.Selection([
+ ('DOC', 'Document'),
+ ('SAM', 'Commercial Sample'),
+ ('REP', 'Repair or Warranty'),
+ ('SOG', 'Sale of Goods'),
+ ('OTH', 'Other'),
+ ], string="Reason for Export", default="SOG",
+ help="Required for international and cross-border shipments.")
+
+ username = fields.Char("Username", copy=False, help="API key from Canada Post Developer Program.")
+ password = fields.Char("Password", copy=False, help="API password from Canada Post Developer Program.")
+ customer_number = fields.Char("Customer Number", copy=False, help="Your Canada Post customer number (mailed-by number).")
+ tracking_link = fields.Char(string="Tracking Link",help="Base URL for tracking shipments. Leave blank to use the default Canada Post tracking page.",size=256)
+
+ fusion_cp_type = fields.Selection(selection=[('commercial', 'Contract Shipping'), ('counter', 'Non-Contract Shipping')], string="Customer Type", required=False)
+ fusion_cp_contract_id = fields.Char(string="Contract ID")
+ fusion_cp_payment_method = fields.Selection([('CreditCard', 'Credit Card'), ('Account', 'Account')],
+ string="Payment Method", default='CreditCard',
+ help="How Canada Post charges for shipments on this account.")
+ fusion_cp_output_format = fields.Selection(
+ [('4x6', '4×6 Thermal Label'), ('8.5x11', '8.5×11 Full Page')],
+ string="Label Output Format",
+ default='4x6',
+ help="Label format returned by Canada Post. "
+ "4×6 is for thermal label printers, "
+ "8.5×11 is the full page format.",
+ )
+ fusion_cp_dimension_unit = fields.Selection(
+ [('cm', 'Centimeters (cm)'), ('in', 'Inches (in)')],
+ string="Package Dimension Unit",
+ default='cm',
+ help="Unit of measurement for package dimensions (length, width, height). "
+ "Choose the unit you use when entering dimensions on your package types. "
+ "Dimensions will be automatically converted to centimeters for the Canada Post API.",
+ )
+
+ def _default_uom_in_delive(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_uom_id = fields.Many2one('uom.uom', string='API Weight Unit',help="Weight unit expected by the Canada Post API (kg). Used internally for converting weights before sending requests.",default=_default_uom_in_delive)
+
+ @api.model
+ def get_fusion_cp_url(self):
+ if self.prod_environment:
+ return "https://soa-gw.canadapost.ca/rs/"
+ else:
+ return "https://ct.soa-gw.canadapost.ca/rs/"
+
+ def _compute_can_generate_return(self):
+ super(DeliveryCarrier, self)._compute_can_generate_return()
+ for carrier in self:
+ if carrier.delivery_type == 'fusion_canada_post':
+ carrier.can_generate_return = True
+
+ @api.model
+ def validating_address(self, partner, additional_fields=[]):
+ missing_value = []
+ mandatory_fields = ['country_id', 'city', 'zip']
+ mandatory_fields.extend(additional_fields)
+ if not partner.street and not partner.street2 :
+ mandatory_fields.append('street')
+ for field in mandatory_fields :
+ if not getattr(partner, field) :
+ missing_value.append(field)
+ return missing_value
+
+ def check_required_value_to_ship(self, orders):
+ for order in orders :
+ if not order.order_line:
+ return _("There are no items to ship. Please add products to the order.")
+ else :
+ order_lines_without_weight = order.order_line.filtered(lambda line_item: not line_item.product_id.type in ['service', 'digital'] and not line_item.product_id.weight and not line_item.is_delivery)
+ for order_line in order_lines_without_weight :
+ return _("Product '%s' has no weight defined. Please set the weight before shipping.") % order_line.product_id.name
+
+ # validating customer address
+ missing_value = self.validating_address(order.partner_shipping_id)
+ if missing_value :
+ fields = ", ".join(missing_value)
+ return (_("Incomplete customer address. Missing field(s): %s") % fields)
+
+ # validation shipper address
+ missing_value = self.validating_address(order.warehouse_id.partner_id)
+ if missing_value :
+ fields = ", ".join(missing_value)
+ return (_("Incomplete warehouse address. Missing field(s): %s") % fields)
+ return False
+
+ def convert_weight(self, from_uom_unit, to_uom_unit, weight):
+ if not from_uom_unit:
+ from_uom_unit = self.env[
+ "product.template"
+ ]._get_weight_uom_id_from_ir_config_parameter()
+ return from_uom_unit._compute_quantity(weight, to_uom_unit)
+
+ def check_max_weight(self, order, shipment_weight):
+ for order_line in order.order_line:
+ if order_line.product_id and order_line.product_id.weight > shipment_weight:
+ return (_("Product weight exceeds the maximum allowed weight for this package type."))
+ return False
+
+ def fusion_canada_post_rate_shipment(self, order):
+ # check the address validation
+ check_value = self.check_required_value_to_ship(order)
+ # check the product weight is appropriate to maximum weight.
+ if check_value:
+ return {'success': False, 'price': 0.0, 'error_message': check_value, 'warning_message': False}
+ # check the product weight is appropriate to maximum weight.
+ if self.product_packaging_id:
+ shipment_weight = self.product_packaging_id.max_weight
+ check_weight = {}
+ if shipment_weight != 0.0:
+ check_weight = self.check_max_weight(order, shipment_weight)
+ if check_weight:
+ return {'success': False, 'price': 0.0, 'error_message': check_weight, 'warning_message': False}
+
+ shipper_address = order.warehouse_id.partner_id or order.company_id.partner_id
+ recipient_address = order.partner_shipping_id or order.partner_id
+ # Convert weight in to the delivery method's weight UOM
+ carrier_ctx = self.env.context
+ weight = carrier_ctx.get("order_weight", 0.0) or order._get_estimated_weight() or 0.0
+ total_weight = self.convert_weight(order.company_id and order.company_id.weight_unit_of_measurement_id,
+ self.weight_uom_id,
+ weight)
+ declared_value = round(order.amount_untaxed, 2)
+ declared_currency = order.currency_id.name
+
+ # Build package_info from SO dimensions or default package type
+ package_info = None
+ if (order.fusion_cp_package_length
+ and order.fusion_cp_package_width
+ and order.fusion_cp_package_height):
+ package_info = {
+ 'length': round(self._fusion_cp_convert_dimension_to_cm(
+ order.fusion_cp_package_length), 1),
+ 'width': round(self._fusion_cp_convert_dimension_to_cm(
+ order.fusion_cp_package_width), 1),
+ 'height': round(self._fusion_cp_convert_dimension_to_cm(
+ order.fusion_cp_package_height), 1),
+ }
+ elif self.product_packaging_id:
+ package_info = self.get_fusion_cp_parcel(
+ self.product_packaging_id)
+
+ price = 0.0
+ rate_dict = self.fusion_canada_post_get_shipping_rate(shipper_address, recipient_address, total_weight,
+ picking_bulk_weight=False, packages=False,
+ declared_value=declared_value, declared_currency=declared_currency,
+ company_id=order.company_id,
+ package_info=package_info)
+ _logger.info("Rate Response Data : %s" % (rate_dict))
+ if rate_dict.get('messages', False):
+ return {'success': False, 'price':price, 'error_message': rate_dict['messages']['message']['description'],
+ 'warning_message': False}
+ if rate_dict.get('price-quotes',False) and rate_dict.get('price-quotes').get('price-quote',False) :
+ cnt = 0;
+ quotes = rate_dict['price-quotes']['price-quote']
+ if isinstance(quotes, dict):
+ quotes = [quotes]
+ for quote in quotes:
+ if quote['service-code']==self.service_type:
+ price = quote['price-details']['due']
+ cnt+=1
+ if cnt==0:
+ return {'success': False, 'price': price, 'error_message': "Canada Post does not offer pricing for the selected service type to this destination.",
+ 'warning_message': False}
+
+ if self.fusion_cp_type == 'counter' and self.service_type in ['DOM.RP','DOM.EP','DOM.XP','DOM.PC'] and self.option_code == 'COD':
+ if order.amount_total + float(price)>= 999.99:
+ raise ValidationError(
+ _("The COD amount cannot exceed $1,000.00 for non-contract shipping.")
+ )
+ if self.fusion_cp_type == 'commercial' and self.service_type in ['DOM.RP','DOM.EP','DOM.XP','DOM.PC'] and self.option_code == 'COD':
+ if order.amount_total + float(price) >= 5000.00:
+ raise ValidationError(
+ _("The COD amount cannot exceed $5,000.00 for contract shipping.")
+ )
+ return {'success': True, 'price': float(price), 'error_message': False, 'warning_message': False}
+
+ def fusion_canada_post_get_shipping_rate(self, shipper_address, recipient_address, total_weight, picking_bulk_weight,
+ packages=False, declared_value=False, declared_currency=False, company_id=False,
+ package_info=None):
+ result = {}
+ # built request data
+ service_root = etree.Element("mailing-scenario")
+
+ if self.fusion_cp_type == 'commercial':
+ service_root.attrib["xmlns"] = "http://www.canadapost.ca/ws/ship/rate-v4"
+ else:
+ service_root.attrib["xmlns"] = "http://www.canadapost.ca/ws/ship/rate-v3"
+ etree.SubElement(service_root, "customer-number").text = self.customer_number
+ parcel = etree.SubElement(service_root, "parcel-characteristics")
+ etree.SubElement(parcel, "weight").text = str(total_weight)
+
+ # Include dimensions if provided (improves cubing/volumetric accuracy)
+ if package_info:
+ dims = etree.SubElement(parcel, "dimensions")
+ etree.SubElement(dims, "length").text = str(
+ package_info.get('length', 1))
+ etree.SubElement(dims, "width").text = str(
+ package_info.get('width', 1))
+ etree.SubElement(dims, "height").text = str(
+ package_info.get('height', 1))
+
+ etree.SubElement(service_root, "origin-postal-code").text = "%s" % (shipper_address.zip.replace(" ","").upper())
+
+ # Determine destination type from recipient country (not service_type)
+ destination = etree.SubElement(service_root, "destination")
+ country_code = recipient_address.country_id and recipient_address.country_id.code or ''
+ if country_code == 'CA':
+ domestic = etree.SubElement(destination, "domestic")
+ etree.SubElement(domestic, "postal-code").text = "%s" % (recipient_address.zip.replace(" ","").upper())
+ elif country_code == 'US':
+ united_states = etree.SubElement(destination, "united-states")
+ etree.SubElement(united_states, "zip-code").text = "%s" % (recipient_address.zip.upper())
+ else:
+ international = etree.SubElement(destination, "international")
+ etree.SubElement(international, "country-code").text = "%s" % (country_code)
+
+ url='%sship/price'%(self.get_fusion_cp_url())
+
+ base_data = etree.tostring(service_root).decode('utf-8')
+
+ if self.fusion_cp_type == 'commercial':
+ headers = {"Accept": "application/vnd.cpc.ship.rate-v4+xml","Content-Type":"application/vnd.cpc.ship.rate-v4+xml"}
+ else:
+ headers = {"Accept": "application/vnd.cpc.ship.rate-v3+xml","Content-Type":"application/vnd.cpc.ship.rate-v3+xml"}
+ _logger.info("Rate Request Data : %s" % (base_data))
+ try:
+ response_body = request(method='POST', url=url, data=base_data, headers=headers, auth=(self.username,self.password))
+ api = Response(response_body)
+ result = api.dict()
+ _logger.info("Rate Response Data : %s" % (result))
+ except Exception as e:
+ result['error_message'] = e.message
+ return result
+ return result
+
+ def fusion_canada_post_rate_shipment_all(self, order):
+ """Return ALL available Canada Post service rates for the order."""
+ check_value = self.check_required_value_to_ship(order)
+ if check_value:
+ return {'success': False, 'error_message': check_value}
+
+ shipper_address = order.warehouse_id.partner_id or order.company_id.partner_id
+ recipient_address = order.partner_shipping_id or order.partner_id
+ carrier_ctx = self.env.context
+ weight = carrier_ctx.get("order_weight", 0.0) or order._get_estimated_weight() or 0.0
+ total_weight = self.convert_weight(
+ order.company_id and order.company_id.weight_unit_of_measurement_id,
+ self.weight_uom_id, weight)
+ declared_value = round(order.amount_untaxed, 2)
+ declared_currency = order.currency_id.name
+
+ # Get package dimensions from context (set by wizard)
+ package_info = self.env.context.get('cp_package_info')
+
+ rate_dict = self.fusion_canada_post_get_shipping_rate(
+ shipper_address, recipient_address, total_weight,
+ picking_bulk_weight=False, packages=False,
+ declared_value=declared_value, declared_currency=declared_currency,
+ company_id=order.company_id,
+ package_info=package_info)
+
+ if rate_dict.get('messages', False):
+ return {
+ 'success': False,
+ 'error_message': rate_dict['messages']['message']['description'],
+ }
+
+ rates = []
+ if rate_dict.get('price-quotes') and rate_dict['price-quotes'].get('price-quote'):
+ quotes = rate_dict['price-quotes']['price-quote']
+ if isinstance(quotes, dict):
+ quotes = [quotes]
+ for quote in quotes:
+ expected_delivery = ''
+ service_standard = quote.get('service-standard', {})
+ if isinstance(service_standard, dict):
+ expected_delivery = service_standard.get(
+ 'expected-delivery-date', '')
+ rates.append({
+ 'service_code': quote.get('service-code', ''),
+ 'service_name': quote.get('service-name', ''),
+ 'price': float(quote.get('price-details', {}).get('due', 0)),
+ 'expected_delivery': expected_delivery,
+ })
+
+ if not rates:
+ return {
+ 'success': False,
+ 'error_message': _("No shipping prices available for this destination."),
+ }
+
+ return rates
+
+ def get_group_id(self):
+ size = 15
+ chars = string.ascii_uppercase
+ return ''.join(random.choice(chars) for _ in range(size))
+
+ def fusion_canada_post_send_shipping(self, pickings):
+ response = []
+ for picking in pickings:
+ # Determine service code (order-level selection or carrier default)
+ service_code = self.service_type
+ if picking.sale_id and picking.sale_id.fusion_cp_service_code:
+ service_code = picking.sale_id.fusion_cp_service_code
+
+ sale_order = picking.sale_id
+ so_packages = (
+ sale_order.fusion_cp_package_ids.sorted('sequence')
+ if sale_order else False)
+
+ from_unit = (picking.company_id
+ and picking.company_id
+ .weight_unit_of_measurement_id or "")
+
+ all_tracking = []
+ all_results = []
+ total_price = 0.0
+
+ # ── Decide what drives the shipment loop ──
+ # If the SO has a package list (from the wizard), use it so we
+ # create one shipment per SO-defined package — even when the
+ # picking itself has only one "bulk" package.
+ if so_packages:
+ for so_pkg in so_packages:
+ pkg_weight = round(self.convert_weight(
+ from_unit, self.weight_uom_id,
+ so_pkg.weight), 2)
+
+ package_info = {
+ 'length': round(
+ self._fusion_cp_convert_dimension_to_cm(
+ so_pkg.package_length), 1),
+ 'width': round(
+ self._fusion_cp_convert_dimension_to_cm(
+ so_pkg.package_width), 1),
+ 'height': round(
+ self._fusion_cp_convert_dimension_to_cm(
+ so_pkg.package_height), 1),
+ }
+
+ svc = so_pkg.service_code or service_code
+
+ self._fusion_cp_validate_package(
+ pkg_weight, package_info)
+
+ result = self._fusion_cp_create_single_shipment(
+ picking, pkg_weight, package_info, svc)
+
+ self._fusion_cp_record_shipment(
+ result, picking, sale_order, svc, pkg_weight,
+ pkg_name="Package %d" % (
+ so_packages.ids.index(so_pkg.id) + 1))
+
+ all_tracking.append(
+ result.get('tracking_number', ''))
+ total_price += result.get('exact_price', 0.0)
+ all_results.append(result)
+
+ else:
+ # Fall back to physical packages from the picking
+ packages = self._get_packages_from_picking(
+ picking, self.product_packaging_id)
+
+ for idx, pkg in enumerate(packages):
+ pkg_weight = round(self.convert_weight(
+ from_unit, self.weight_uom_id,
+ pkg.weight), 2)
+
+ # Dimensions: legacy SO fields → package type → default
+ package_info = None
+
+ if sale_order:
+ if (sale_order.fusion_cp_package_length
+ and sale_order.fusion_cp_package_width
+ and sale_order.fusion_cp_package_height):
+ package_info = {
+ 'length': round(
+ self._fusion_cp_convert_dimension_to_cm(
+ sale_order.fusion_cp_package_length
+ ), 1),
+ 'width': round(
+ self._fusion_cp_convert_dimension_to_cm(
+ sale_order.fusion_cp_package_width
+ ), 1),
+ 'height': round(
+ self._fusion_cp_convert_dimension_to_cm(
+ sale_order.fusion_cp_package_height
+ ), 1),
+ }
+
+ if not package_info:
+ pkg_type = (
+ pkg.package_type
+ if hasattr(pkg, 'package_type')
+ and pkg.package_type
+ else self.product_packaging_id)
+ if pkg_type:
+ package_info = self.get_fusion_cp_parcel(
+ pkg_type)
+ else:
+ raise ValidationError(_(
+ "No package dimensions available for "
+ "'%s'. Please set dimensions when "
+ "adding shipping to the sale order.")
+ % (pkg.name or 'package'))
+
+ self._fusion_cp_validate_package(
+ pkg_weight, package_info)
+
+ result = self._fusion_cp_create_single_shipment(
+ picking, pkg_weight, package_info,
+ service_code)
+
+ self._fusion_cp_record_shipment(
+ result, picking, sale_order, service_code,
+ pkg_weight,
+ pkg_name=(pkg.name
+ if hasattr(pkg, 'name') else ''))
+
+ all_tracking.append(
+ result.get('tracking_number', ''))
+ total_price += result.get('exact_price', 0.0)
+ all_results.append(result)
+
+ # ── Post combined chatter message with ALL labels ──
+ self._fusion_cp_post_shipping_documents(
+ picking, all_results)
+
+ shipping_data = {
+ 'exact_price': total_price,
+ 'tracking_number': ','.join(
+ filter(None, all_tracking)),
+ }
+ response.append(shipping_data)
+ return response
+
+ def _fusion_cp_create_single_shipment(
+ self, picking, pkg_weight, package_info, service_code):
+ """Create one CP shipment for a single package.
+
+ Returns dict with tracking_number, shipment_id, exact_price,
+ and attachment records.
+ """
+ destination_address = picking.partner_id
+ sender_address = (picking.picking_type_id
+ and picking.picking_type_id.warehouse_id
+ and picking.picking_type_id.warehouse_id.partner_id)
+
+ # --- Build XML ---
+ if self.fusion_cp_type == 'commercial':
+ root_node = etree.Element("shipment")
+ root_node.attrib["xmlns"] = (
+ "http://www.canadapost.ca/ws/shipment-v8")
+ etree.SubElement(
+ root_node, "transmit-shipment").text = "true"
+ etree.SubElement(
+ root_node, "provide-receipt-info").text = "true"
+ else:
+ root_node = etree.Element("non-contract-shipment")
+ root_node.attrib["xmlns"] = (
+ "http://www.canadapost.ca/ws/ncshipment-v4")
+
+ etree.SubElement(
+ root_node, "requested-shipping-point"
+ ).text = "%s" % (sender_address.zip.replace(" ", "").upper() or "")
+
+ delivery_spec_node = etree.SubElement(root_node, "delivery-spec")
+ etree.SubElement(
+ delivery_spec_node, "service-code").text = "%s" % service_code
+
+ # Sender
+ sender_node = etree.SubElement(delivery_spec_node, "sender")
+ etree.SubElement(sender_node, "company").text = sender_address.name
+ etree.SubElement(
+ sender_node, "contact-phone"
+ ).text = "%s" % (sender_address.phone or "")
+ address_details = etree.SubElement(sender_node, "address-details")
+ etree.SubElement(
+ address_details, "address-line-1"
+ ).text = sender_address.street or ""
+ etree.SubElement(
+ address_details, "city").text = sender_address.city or ""
+ etree.SubElement(
+ address_details, "prov-state"
+ ).text = "%s" % (sender_address.state_id
+ and sender_address.state_id.code or "")
+ if self.fusion_cp_type == 'commercial':
+ etree.SubElement(
+ address_details, "country-code"
+ ).text = "%s" % (sender_address.country_id
+ and sender_address.country_id.code or "")
+ etree.SubElement(
+ address_details, "postal-zip-code"
+ ).text = "%s" % (sender_address.zip.replace(" ", "").upper() or "")
+
+ # Destination
+ destination_node = etree.SubElement(
+ delivery_spec_node, "destination")
+ etree.SubElement(
+ destination_node, "name").text = destination_address.name
+ etree.SubElement(
+ destination_node, "company").text = destination_address.name
+ etree.SubElement(
+ destination_node, "client-voice-number"
+ ).text = destination_address.phone
+ dest_addr = etree.SubElement(
+ destination_node, "address-details")
+ etree.SubElement(
+ dest_addr, "address-line-1"
+ ).text = destination_address.street or ""
+ etree.SubElement(
+ dest_addr, "city").text = destination_address.city or ""
+ etree.SubElement(
+ dest_addr, "prov-state"
+ ).text = "%s" % (destination_address.state_id
+ and destination_address.state_id.code or "")
+ etree.SubElement(
+ dest_addr, "country-code"
+ ).text = "%s" % (destination_address.country_id
+ and destination_address.country_id.code or "")
+ etree.SubElement(
+ dest_addr, "postal-zip-code"
+ ).text = "%s" % (destination_address.zip.replace(
+ " ", "").upper() or "")
+
+ # Options
+ if (self.option_code
+ and self.service_type not in [
+ 'USA.XP', 'USA.EP', 'INT.IP.SURF',
+ 'INT.PW.PARCEL', 'INT.XP']):
+ options = etree.SubElement(delivery_spec_node, "options")
+ option = etree.SubElement(options, "option")
+ etree.SubElement(
+ option, "option-code").text = str(self.option_code or "")
+ if self.option_code in ['PA18', 'PA19']:
+ option_pa = etree.SubElement(options, "option")
+ etree.SubElement(option_pa, "option-code").text = 'SO'
+ if self.option_code in ('COD', 'COV'):
+ etree.SubElement(
+ option, "option-amount"
+ ).text = str(picking.sale_id.amount_total or "")
+ etree.SubElement(
+ option, "option-qualifier-1").text = "true"
+ else:
+ options = etree.SubElement(delivery_spec_node, "options")
+ option_usa = etree.SubElement(options, "option")
+ etree.SubElement(option_usa, "option-code").text = 'RASE'
+
+ # Parcel characteristics
+ parcel_chars = etree.SubElement(
+ delivery_spec_node, "parcel-characteristics")
+ etree.SubElement(
+ parcel_chars, "weight").text = "%s" % pkg_weight
+ dimensions = etree.SubElement(parcel_chars, "dimensions")
+ etree.SubElement(
+ dimensions, "length"
+ ).text = "%s" % package_info.get('length', 1)
+ etree.SubElement(
+ dimensions, "width"
+ ).text = "%s" % package_info.get('width', 1)
+ etree.SubElement(
+ dimensions, "height"
+ ).text = "%s" % package_info.get('height', 1)
+
+ # Preferences
+ preferences = etree.SubElement(delivery_spec_node, "preferences")
+ etree.SubElement(
+ preferences, "show-packing-instructions").text = "true"
+ if self.fusion_cp_type != 'commercial':
+ etree.SubElement(
+ preferences, "output-format"
+ ).text = self.fusion_cp_output_format or "4x6"
+ else:
+ print_prefs = etree.SubElement(
+ delivery_spec_node, "print-preferences")
+ etree.SubElement(
+ print_prefs, "output-format"
+ ).text = self.fusion_cp_output_format or "4x6"
+
+ # Customs
+ customs = etree.SubElement(delivery_spec_node, "customs")
+ etree.SubElement(
+ customs, "currency"
+ ).text = str(picking.sale_id.currency_id.name)
+ rate = 1.0
+ if picking.sale_id.currency_id.rate:
+ rate = round(picking.sale_id.currency_id.rate, 2)
+ etree.SubElement(
+ customs, "conversion-from-cad").text = str(rate or '')
+ etree.SubElement(
+ customs, "reason-for-export"
+ ).text = "%s" % self.reason_for_export
+
+ sku_list = etree.SubElement(customs, "sku-list")
+ for move_line in picking.move_ids:
+ item = etree.SubElement(sku_list, "item")
+ etree.SubElement(
+ item, "customs-description"
+ ).text = str(move_line.product_id.name)
+ etree.SubElement(
+ item, "unit-weight").text = str(move_line.weight)
+ etree.SubElement(
+ item, "customs-value-per-unit"
+ ).text = str(move_line.product_id.lst_price)
+ etree.SubElement(
+ item, "customs-number-of-units"
+ ).text = str(int(move_line.product_uom_qty))
+
+ # Settlement info (commercial only)
+ if self.fusion_cp_type == 'commercial':
+ settlement_info = etree.SubElement(
+ delivery_spec_node, "settlement-info")
+ etree.SubElement(
+ settlement_info, "contract-id"
+ ).text = self.fusion_cp_contract_id
+ etree.SubElement(
+ settlement_info, "intended-method-of-payment"
+ ).text = self.fusion_cp_payment_method
+
+ # --- Make API call ---
+ api_url = self.get_fusion_cp_url()
+ if self.fusion_cp_type == 'commercial':
+ url = "%s%s/%s/shipment" % (
+ api_url, self.customer_number, self.customer_number)
+ else:
+ url = "%s%s/ncshipment" % (api_url, self.customer_number)
+
+ base_data = etree.tostring(root_node).decode('utf-8')
+
+ if self.fusion_cp_type == 'commercial':
+ headers = {
+ "Accept": "application/vnd.cpc.shipment-v8+xml",
+ "Content-Type": "application/vnd.cpc.shipment-v8+xml",
+ "Accept-language": "en-CA",
+ }
+ else:
+ headers = {
+ "Accept": "application/vnd.cpc.ncshipment-v4+xml",
+ "Content-Type": "application/vnd.cpc.ncshipment-v4+xml",
+ "Accept-language": "en-CA",
+ }
+
+ _logger.info("Create Shipment Request Data: %s", base_data)
+ response_body = request(
+ method='POST', url=url, data=base_data,
+ headers=headers, auth=(self.username, self.password))
+
+ if response_body.status_code == 200:
+ api = Response(response_body)
+ result = api.dict()
+ _logger.info("Create Shipment Response Data: %s", result)
+ else:
+ error_code = "%s" % response_body.status_code
+ error_message = response_body.reason
+ message = error_code + " " + error_message
+ api = Response(response_body)
+ result = api.dict()
+ if result['messages']['message']['description']:
+ raise ValidationError(
+ _("Canada Post: %s")
+ % result['messages']['message']['description'])
+ else:
+ raise ValidationError(
+ _("Canada Post shipment request failed (%s).\n%s")
+ % (message, response_body.text[:500]))
+
+ # --- Extract shipment ID ---
+ if self.fusion_cp_type == 'commercial':
+ if (not result['shipment-info']['shipment-id']
+ or not result['shipment-info']['links']['link']):
+ raise RedirectWarning(
+ "ShipmentRequest Fail \n More Information \n %s"
+ % result)
+ shipment_id = str(result['shipment-info']['shipment-id'])
+ else:
+ if (not result['non-contract-shipment-info']['shipment-id']
+ or not result['non-contract-shipment-info']
+ ['links']['link']):
+ raise RedirectWarning(
+ "ShipmentRequest Fail \n More Information \n %s"
+ % result)
+ shipment_id = str(
+ result['non-contract-shipment-info']['shipment-id'])
+
+ # --- Extract artifact URLs ---
+ label_url = ""
+ receipt_link_url = ""
+ commercial_invoice_url = ""
+ commercial_invoice = False
+ links = (result['shipment-info']['links']['link']
+ if self.fusion_cp_type == 'commercial'
+ else result['non-contract-shipment-info']
+ ['links']['link'])
+ if isinstance(links, dict):
+ links = [links]
+ for link in links:
+ if link['_rel'] == 'label':
+ label_url = link['_href']
+ elif link['_rel'] == 'receipt':
+ receipt_link_url = link['_href']
+ elif link['_rel'] == 'commercialInvoice':
+ commercial_invoice_url = link['_href']
+ commercial_invoice = True
+
+ # --- Download artifacts ---
+ pdf_headers = {'Accept': 'application/pdf'}
+ label_attachment = False
+ receipt_attachment = False
+ full_label_attachment = False
+ ci_attachment = False
+ label_content = b''
+ receipt_pdf_content = b''
+
+ try:
+ if label_url:
+ label_resp = request(
+ method='GET', url=label_url, headers=pdf_headers,
+ auth=(self.username, self.password))
+ _logger.info(
+ "Label Response Status: %s", label_resp.status_code)
+ if label_resp.status_code == 200:
+ label_content = label_resp.content
+ label_attachment = self.env['ir.attachment'].create({
+ 'name': 'Label-%s.pdf' % shipment_id,
+ 'type': 'binary',
+ 'datas': base64.b64encode(label_content),
+ 'res_model': 'stock.picking',
+ 'res_id': picking.id,
+ 'mimetype': 'application/pdf',
+ })
+
+ if not receipt_link_url:
+ if self.fusion_cp_type == 'commercial':
+ receipt_link_url = (
+ "%s%s/%s/shipment/%s/receipt"
+ % (self.get_fusion_cp_url(),
+ self.customer_number,
+ self.customer_number, shipment_id))
+ else:
+ receipt_link_url = (
+ "%s%s/ncshipment/%s/receipt"
+ % (self.get_fusion_cp_url(),
+ self.customer_number, shipment_id))
+
+ if receipt_link_url:
+ receipt_pdf_resp = request(
+ method='GET', url=receipt_link_url,
+ headers=pdf_headers,
+ auth=(self.username, self.password))
+ if receipt_pdf_resp.status_code == 200:
+ receipt_pdf_content = receipt_pdf_resp.content
+ receipt_attachment = (
+ self.env['ir.attachment'].create({
+ 'name': 'Receipt-%s.pdf' % shipment_id,
+ 'type': 'binary',
+ 'datas': base64.b64encode(
+ receipt_pdf_content),
+ 'res_model': 'stock.picking',
+ 'res_id': picking.id,
+ 'mimetype': 'application/pdf',
+ }))
+
+ if label_content and receipt_pdf_content:
+ try:
+ from odoo.tools import pdf as pdf_tools
+ full_content = pdf_tools.merge_pdf(
+ [label_content, receipt_pdf_content])
+ except Exception:
+ full_content = label_content
+ full_label_attachment = (
+ self.env['ir.attachment'].create({
+ 'name': 'FullLabel-%s.pdf' % shipment_id,
+ 'type': 'binary',
+ 'datas': base64.b64encode(full_content),
+ 'res_model': 'stock.picking',
+ 'res_id': picking.id,
+ 'mimetype': 'application/pdf',
+ }))
+
+ if commercial_invoice and commercial_invoice_url:
+ ci_resp = request(
+ method='GET', url=commercial_invoice_url,
+ headers=pdf_headers,
+ auth=(self.username, self.password))
+ if ci_resp.status_code == 200:
+ ci_attachment = self.env['ir.attachment'].create({
+ 'name': 'CommercialInvoice-%s.pdf' % shipment_id,
+ 'type': 'binary',
+ 'datas': base64.b64encode(ci_resp.content),
+ 'res_model': 'stock.picking',
+ 'res_id': picking.id,
+ 'mimetype': 'application/pdf',
+ })
+
+ except Exception as e:
+ _logger.error(
+ "Error downloading Canada Post artifacts: %s", e)
+ picking.message_post(
+ body=_('Error downloading shipping documents '
+ 'for shipment %s: %s') % (shipment_id, str(e)))
+
+ # --- Fetch receipt XML for pricing info ---
+ if self.fusion_cp_type == 'commercial':
+ receipt_xml_headers = {
+ "Accept": "application/vnd.cpc.shipment-v8+xml",
+ "Accept-language": "en-CA",
+ }
+ url_receipt = "%s%s/%s/shipment/%s/receipt" % (
+ self.get_fusion_cp_url(), self.customer_number,
+ self.customer_number, shipment_id)
+ else:
+ receipt_xml_headers = {
+ "Accept": "application/vnd.cpc.ncshipment-v4+xml",
+ "Accept-language": "en-CA",
+ }
+ url_receipt = "%s%s/ncshipment/%s/receipt" % (
+ self.get_fusion_cp_url(), self.customer_number,
+ shipment_id)
+
+ result_receipt = {}
+ try:
+ receipt_response = request(
+ method='GET', url=url_receipt,
+ headers=receipt_xml_headers,
+ auth=(self.username, self.password))
+ if receipt_response.status_code == 200:
+ api_receipt = Response(receipt_response)
+ result_receipt = api_receipt.dict()
+ _logger.info("Receipt XML Data: %s", result_receipt)
+ else:
+ _logger.warning(
+ "Receipt fetch: %s %s",
+ receipt_response.status_code,
+ receipt_response.reason)
+ except Exception as e:
+ _logger.warning("Error fetching receipt XML: %s", e)
+
+ # Extract pricing and tracking
+ extra_price = 0.0
+ tracking_pin = False
+ if self.fusion_cp_type == 'commercial':
+ if result_receipt:
+ try:
+ extra_price = float(
+ result_receipt.get('shipment-receipt', {})
+ .get('cc-receipt-details', {})
+ .get('charge-amount', 0.0))
+ except (TypeError, ValueError):
+ extra_price = 0.0
+ tracking_pin = result['shipment-info'].get(
+ 'tracking-pin', False)
+ else:
+ if result_receipt:
+ try:
+ extra_price = float(
+ result_receipt
+ .get('non-contract-shipment-receipt', {})
+ .get('cc-receipt-details', {})
+ .get('charge-amount', 0.0))
+ except (TypeError, ValueError):
+ extra_price = 0.0
+ tracking_pin = (
+ result['non-contract-shipment-info']
+ .get('tracking-pin', False))
+
+ if not tracking_pin:
+ _logger.info(
+ "This service does not provide tracking. Service: %s",
+ service_code)
+
+ return {
+ 'tracking_number': tracking_pin or '',
+ 'shipment_id': shipment_id,
+ 'exact_price': float(extra_price) or 0.0,
+ 'label_attachment': label_attachment,
+ 'full_label_attachment': full_label_attachment,
+ 'receipt_attachment': receipt_attachment,
+ 'ci_attachment': ci_attachment,
+ 'label_content': label_content,
+ 'receipt_content': receipt_pdf_content,
+ }
+
+ def _fusion_cp_post_shipping_documents(self, picking, all_results):
+ """Post a single chatter message with combined shipping documents.
+
+ Merges all label PDFs into one printable file so the user can
+ print every label at once. Individual labels remain on the
+ ``fusion.cp.shipment`` records for per-shipment access.
+ """
+ from odoo.tools import pdf as pdf_tools
+
+ shipment_ids = [
+ r.get('shipment_id', '') for r in all_results
+ if r.get('shipment_id')]
+
+ # ── Merge all label PDFs ──
+ label_pages = [
+ r['label_content'] for r in all_results
+ if r.get('label_content')]
+ receipt_pages = [
+ r['receipt_content'] for r in all_results
+ if r.get('receipt_content')]
+
+ attach_ids = []
+
+ if label_pages:
+ try:
+ if len(label_pages) > 1:
+ merged = pdf_tools.merge_pdf(label_pages)
+ else:
+ merged = label_pages[0]
+ att = self.env['ir.attachment'].create({
+ 'name': 'Labels-All.pdf',
+ 'type': 'binary',
+ 'datas': base64.b64encode(merged),
+ 'res_model': 'stock.picking',
+ 'res_id': picking.id,
+ 'mimetype': 'application/pdf',
+ })
+ attach_ids.append(att.id)
+ except Exception as e:
+ _logger.warning(
+ "Could not merge label PDFs: %s", e)
+ # Fall back: attach each label individually
+ for r in all_results:
+ if r.get('label_attachment'):
+ attach_ids.append(
+ r['label_attachment'].id)
+
+ if receipt_pages:
+ try:
+ if len(receipt_pages) > 1:
+ merged_rcpt = pdf_tools.merge_pdf(receipt_pages)
+ else:
+ merged_rcpt = receipt_pages[0]
+ att_r = self.env['ir.attachment'].create({
+ 'name': 'Receipts-All.pdf',
+ 'type': 'binary',
+ 'datas': base64.b64encode(merged_rcpt),
+ 'res_model': 'stock.picking',
+ 'res_id': picking.id,
+ 'mimetype': 'application/pdf',
+ })
+ attach_ids.append(att_r.id)
+ except Exception as e:
+ _logger.warning(
+ "Could not merge receipt PDFs: %s", e)
+ for r in all_results:
+ if r.get('receipt_attachment'):
+ attach_ids.append(
+ r['receipt_attachment'].id)
+
+ # Commercial invoices (one per shipment, not merged)
+ for r in all_results:
+ if r.get('ci_attachment'):
+ attach_ids.append(r['ci_attachment'].id)
+
+ # ── Post to chatter ──
+ if len(shipment_ids) == 1:
+ body = _('Canada Post shipment %s created.') % (
+ shipment_ids[0])
+ elif shipment_ids:
+ body = _('Canada Post — %d shipments created: %s') % (
+ len(shipment_ids), ', '.join(shipment_ids))
+ else:
+ body = _('Canada Post shipment created.')
+
+ if attach_ids:
+ picking.message_post(
+ body=body, attachment_ids=attach_ids)
+ else:
+ picking.message_post(body=body)
+
+ def _fusion_cp_record_shipment(
+ self, result, picking, sale_order, service_code,
+ pkg_weight, pkg_name=''):
+ """Create a ``fusion.cp.shipment`` record from shipment result dict.
+
+ Called once per package by ``fusion_canada_post_send_shipping``
+ after ``_fusion_cp_create_single_shipment`` returns successfully.
+ """
+ shipment_vals = {
+ 'tracking_number': result.get('tracking_number', ''),
+ 'shipment_id': result.get('shipment_id', ''),
+ 'carrier_id': self.id,
+ 'sale_order_id': (sale_order.id if sale_order else False),
+ 'picking_id': picking.id,
+ 'service_type': service_code,
+ 'shipment_date': fields.Datetime.now(),
+ 'shipping_cost': result.get('exact_price', 0.0),
+ 'weight': pkg_weight,
+ 'status': 'confirmed',
+ 'company_id': picking.company_id.id,
+ 'package_name': pkg_name,
+ }
+
+ # Attach label / receipt / CI documents
+ if result.get('label_attachment'):
+ shipment_vals['label_attachment_id'] = (
+ result['label_attachment'].id)
+ if result.get('full_label_attachment'):
+ shipment_vals['full_label_attachment_id'] = (
+ result['full_label_attachment'].id)
+ if result.get('receipt_attachment'):
+ shipment_vals['receipt_attachment_id'] = (
+ result['receipt_attachment'].id)
+ if result.get('ci_attachment'):
+ shipment_vals['commercial_invoice_attachment_id'] = (
+ result['ci_attachment'].id)
+
+ self.env['fusion.cp.shipment'].create(shipment_vals)
+
+ def fusion_canada_post_get_tracking_link(self, picking):
+ link = picking.carrier_id.tracking_link or 'https://www.canadapost.ca/trackweb/en#/resultList?searchFor='
+ res = '%s %s' % (link, picking.carrier_tracking_ref)
+ return res
+
+ def fusion_canada_post_cancel_shipment(self, picking):
+ """Cancel all CP shipments linked to this picking via void API."""
+ shipments = self.env['fusion.cp.shipment'].search([
+ ('picking_id', '=', picking.id),
+ ('status', '!=', 'cancelled'),
+ ])
+ for shipment in shipments:
+ try:
+ shipment.action_void_shipment()
+ except ValidationError as e:
+ _logger.warning(
+ "Failed to void CP shipment %s: %s",
+ shipment.name, e)
+ raise
+
+ def _fusion_cp_validate_package(self, weight_kg, package_info):
+ """Validate package dimensions and weight against Canada Post limits.
+
+ ``package_info`` values are already in **cm** (converted by
+ ``get_fusion_cp_parcel``).
+
+ CP limits:
+ - Max weight: 30 kg
+ - Max longest dimension: 100 cm
+ - Max second longest dimension: 76 cm
+ - Volumetric weight: L × W × H (cm) / 5000
+ - Billable weight = max(actual, volumetric)
+
+ Raises ValidationError if hard limits exceeded.
+ Logs warning if volumetric weight exceeds actual weight.
+ """
+ errors = []
+ length_cm = float(package_info.get('length', 0))
+ width_cm = float(package_info.get('width', 0))
+ height_cm = float(package_info.get('height', 0))
+
+ # Sort dimensions to find longest and second longest
+ dims = sorted([length_cm, width_cm, height_cm], reverse=True)
+
+ # Determine user's preferred display unit
+ dim_unit = self.fusion_cp_dimension_unit or 'cm'
+ if dim_unit == 'in':
+ unit_label = 'in'
+ # Convert cm limits to inches for the error message
+ max_longest = 39.4 # 100 cm ≈ 39.4 in
+ max_second = 29.9 # 76 cm ≈ 29.9 in
+ factor = 1 / 2.54 # cm → inches
+ else:
+ unit_label = 'cm'
+ max_longest = 100
+ max_second = 76
+ factor = 1
+
+ if weight_kg > 30:
+ errors.append(
+ _("Weight %.2f kg exceeds Canada Post maximum of 30 kg.")
+ % weight_kg)
+ if dims[0] > 100:
+ errors.append(
+ _("Longest dimension %.1f %s exceeds Canada Post "
+ "maximum of %.1f %s.") % (
+ dims[0] * factor, unit_label,
+ max_longest, unit_label))
+ if dims[1] > 76:
+ errors.append(
+ _("Second longest dimension %.1f %s exceeds Canada Post "
+ "maximum of %.1f %s.") % (
+ dims[1] * factor, unit_label,
+ max_second, unit_label))
+
+ if errors:
+ raise ValidationError('\n'.join(errors))
+
+ # Volumetric weight warning (CP cubing)
+ if length_cm and width_cm and height_cm:
+ vol_weight = (length_cm * width_cm * height_cm) / 5000
+ if vol_weight > weight_kg:
+ _logger.warning(
+ "CP cubing: volumetric weight %.2f kg > actual %.2f kg "
+ "for package (%.1f × %.1f × %.1f cm). Canada Post will "
+ "bill at the higher volumetric weight.",
+ vol_weight, weight_kg, length_cm, width_cm, height_cm)
+
+ def get_fusion_cp_parcel(self, package):
+ """Get package dimensions in cm for Canada Post API.
+
+ Uses the carrier's ``fusion_cp_dimension_unit`` setting to determine
+ the source unit (cm or inches) instead of relying on the company's
+ default length UOM, which may be set to feet or another unit.
+ """
+ packaging_length = self.sudo()._fusion_cp_convert_dimension_to_cm(
+ package.packaging_length)
+ width = self.sudo()._fusion_cp_convert_dimension_to_cm(
+ package.width)
+ height = self.sudo()._fusion_cp_convert_dimension_to_cm(
+ package.height)
+ return {
+ "length": round(packaging_length, 1),
+ "width": round(width, 1),
+ "height": round(height, 1),
+ }
+
+ def _fusion_cp_convert_dimension_to_cm(self, dimension):
+ """Convert a dimension value to centimeters using the carrier's
+ configured dimension unit (``fusion_cp_dimension_unit``).
+
+ If the carrier is set to *cm*, the value is returned as-is.
+ If set to *in* (inches), it is converted from inches → cm.
+ """
+ if not dimension:
+ return 0.0
+ dim_unit = self.fusion_cp_dimension_unit or 'cm'
+ if dim_unit == 'cm':
+ return dimension
+ # inches → cm
+ target_uom = self.env.ref("uom.product_uom_cm")
+ from_uom = self.env.ref("uom.product_uom_inch")
+ return from_uom._compute_quantity(dimension, target_uom)
diff --git a/fusion_canada_post/models/fusion_cp_order_package.py b/fusion_canada_post/models/fusion_cp_order_package.py
new file mode 100644
index 00000000..f8290e15
--- /dev/null
+++ b/fusion_canada_post/models/fusion_cp_order_package.py
@@ -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',
+ )
diff --git a/fusion_canada_post/models/fusion_cp_shipment.py b/fusion_canada_post/models/fusion_cp_shipment.py
new file mode 100644
index 00000000..35ee7f93
--- /dev/null
+++ b/fusion_canada_post/models/fusion_cp_shipment.py
@@ -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',
+ }
diff --git a/fusion_canada_post/models/fusion_cp_tracking_event.py b/fusion_canada_post/models/fusion_cp_tracking_event.py
new file mode 100644
index 00000000..4ba85709
--- /dev/null
+++ b/fusion_canada_post/models/fusion_cp_tracking_event.py
@@ -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',
+ )
diff --git a/fusion_canada_post/models/product_packaging.py b/fusion_canada_post/models/product_packaging.py
new file mode 100644
index 00000000..cc8d7f1b
--- /dev/null
+++ b/fusion_canada_post/models/product_packaging.py
@@ -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')])
diff --git a/fusion_canada_post/models/res_company.py b/fusion_canada_post/models/res_company.py
new file mode 100644
index 00000000..461fd1ca
--- /dev/null
+++ b/fusion_canada_post/models/res_company.py
@@ -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)
diff --git a/fusion_canada_post/models/sale_order.py b/fusion_canada_post/models/sale_order.py
new file mode 100644
index 00000000..36c32c2d
--- /dev/null
+++ b/fusion_canada_post/models/sale_order.py
@@ -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
diff --git a/fusion_canada_post/models/stock_picking.py b/fusion_canada_post/models/stock_picking.py
new file mode 100644
index 00000000..e4e7794d
--- /dev/null
+++ b/fusion_canada_post/models/stock_picking.py
@@ -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
diff --git a/fusion_canada_post/pyproject.toml b/fusion_canada_post/pyproject.toml
new file mode 100644
index 00000000..4231d0cc
--- /dev/null
+++ b/fusion_canada_post/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["whool"]
+build-backend = "whool.buildapi"
diff --git a/fusion_canada_post/security/ir.model.access.csv b/fusion_canada_post/security/ir.model.access.csv
new file mode 100644
index 00000000..cf8cd538
--- /dev/null
+++ b/fusion_canada_post/security/ir.model.access.csv
@@ -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
diff --git a/fusion_canada_post/static/description/S1.png b/fusion_canada_post/static/description/S1.png
new file mode 100644
index 00000000..616b44d8
Binary files /dev/null and b/fusion_canada_post/static/description/S1.png differ
diff --git a/fusion_canada_post/static/description/S2.png b/fusion_canada_post/static/description/S2.png
new file mode 100644
index 00000000..63aabb19
Binary files /dev/null and b/fusion_canada_post/static/description/S2.png differ
diff --git a/fusion_canada_post/static/description/S3.png b/fusion_canada_post/static/description/S3.png
new file mode 100644
index 00000000..e471bfc6
Binary files /dev/null and b/fusion_canada_post/static/description/S3.png differ
diff --git a/fusion_canada_post/static/description/canada_post_banner.gif b/fusion_canada_post/static/description/canada_post_banner.gif
new file mode 100644
index 00000000..37ebfc25
Binary files /dev/null and b/fusion_canada_post/static/description/canada_post_banner.gif differ
diff --git a/fusion_canada_post/static/description/custom.css b/fusion_canada_post/static/description/custom.css
new file mode 100644
index 00000000..221547a5
--- /dev/null
+++ b/fusion_canada_post/static/description/custom.css
@@ -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;
+ }
+}
diff --git a/fusion_canada_post/static/description/dash.png b/fusion_canada_post/static/description/dash.png
new file mode 100644
index 00000000..c576708b
Binary files /dev/null and b/fusion_canada_post/static/description/dash.png differ
diff --git a/fusion_canada_post/static/description/icon.png b/fusion_canada_post/static/description/icon.png
new file mode 100644
index 00000000..6025652a
Binary files /dev/null and b/fusion_canada_post/static/description/icon.png differ
diff --git a/fusion_canada_post/static/description/images/icons_live_price.png b/fusion_canada_post/static/description/images/icons_live_price.png
new file mode 100644
index 00000000..57194a52
Binary files /dev/null and b/fusion_canada_post/static/description/images/icons_live_price.png differ
diff --git a/fusion_canada_post/static/description/images/icons_shipping_label.png b/fusion_canada_post/static/description/images/icons_shipping_label.png
new file mode 100644
index 00000000..a7fb1e35
Binary files /dev/null and b/fusion_canada_post/static/description/images/icons_shipping_label.png differ
diff --git a/fusion_canada_post/static/description/images/icons_track_and_trace.png b/fusion_canada_post/static/description/images/icons_track_and_trace.png
new file mode 100644
index 00000000..1bae861b
Binary files /dev/null and b/fusion_canada_post/static/description/images/icons_track_and_trace.png differ
diff --git a/fusion_canada_post/static/description/index.html b/fusion_canada_post/static/description/index.html
new file mode 100644
index 00000000..27cbcc44
--- /dev/null
+++ b/fusion_canada_post/static/description/index.html
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+ Canada Post Shipping Integration with Odoo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Odoo Canada Post Shipping Integration
+
+
+
+
+
+
+
+
+
+ Why Canada Post Odoo Shipping Integration?
+
+
+ Integrate Canada Post to streamline shipping processes by obtaining real-time courier quotes for
+ various services, generating shipping labels based on order details, and providing tracking
+ numbers with
+ links for shipment updates
+ ensuring a seamless and efficient logistics workflow
+
+
+
+
+
+
+
+
+ Features
+
+
+
+
+
+
+
+
Live Shipping
+ Price
+
Get live price to ensure the best price for
+ your shipment
+
+
+
+
+
+
+
+
+
Shipping
+ Label
+
Generate shipping label using order information
+
+
+
+
+
+
+
+
+
+
+
Track &
+ Trace
+
Live track your shipment using the Canada
+ Post tracking number
+
+
+
+
+
+
+
+
+
+
+
Canada Post Shipping Configuration
+
+
+
+
+
+
+
+
+
Create Sale Order With Canada Post Delivery Method
+
+
+
+
+
+
+
+
+
Generate Shipment With Tracking Number And Shipping Label