Files
Odoo-Modules/fusion_canada_post/models/delivery_carrier.py
gsinghpal f81e0cd918 changes
2026-03-09 23:45:00 -04:00

1186 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)