1186 lines
52 KiB
Python
1186 lines
52 KiB
Python
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)
|